visible true

技術的なメモを書く

robolectric3でShadowクラスを作るメモ

robolectric3のドキュメント通りにShadowクラスを書いてもうまく動かず、結局robolectric自身のソースを読んで理解してめんどくさかったのでメモしておきます。

Shadowクラスを定義する

Shadowクラスは以下の手順で宣言します。

  • クラス宣言に@Implementsアノテーションを付与し、書き換え対象となるクラスを設定する
  • フィールドに@RealObjectアノテーションを付与した書き換え対象のクラスを宣言する。これは本当のオブジェクトの処理を呼び出したい場合に使います。

Shadowクラスはだいたい${application_id}.testtool.shadowパッケージに置いてます。本エントリではGsonBuilderの書き換えを行います。

import com.google.gson.GsonBuilder;

import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.annotation.RealObject;

@Implements(GsonBuilder.class)
public class ShadowGsonBuilder {
  @RealObject
  private GsonBuilder realObject;
  //色々
}

メソッドを書き換える

メソッドを書き換えるには@Implementationアノテーションを使います。

@Implementation
public GsonBuilder setDateFormat(String format) {
  return Shadow.directlyOn(realObject, GsonBuilder.class)
          .setDateFormat(format.replace("Z", "X"));
}

ここではGsonBuilderのsetDateFormat(String)を書き換えてます。Gson 2.4以下ではタイムゾーンZが利用できずISO8601のパース時にコケます*1。代わりにXを使わないといけないのですがXはJava7以降でサポートされたものでAndroid上では動作しません*2。という事でsetDateFormat(String)の引数をrobolectricでのテスト時だけ書き換えるようにしてます。

return realObject.setDateFormat(format.replace("Z", "X"))と書くと無限ループしてStackOverflowErrorになります。そこでShadow.directlyOn()を使って本物のクラスのメソッドを呼び出します。

コンストラクタを書き換える

コンストラクタを書き換えたいケースはほぼないと思いますがもし書き換えるなら以下のようになります*3。ポイントは__constructor__というメソッド名とShadow.invokeConstructor()でしょう。

@Implements(Paint.class)
public class ShadowPaint {
  //省略
  @RealObject Paint paint;

  public void __constructor__(int flags) {
    //省略
    Shadow.invokeConstructor(Paint.class, paint, ReflectionHelpers.ClassParameter.from(int.class, flags));
  }

  //省略
}

カスタムRobolectricTestRunnerを作りShadowクラスを登録する

Shadowクラスを作ったら次にカスタムRobolectricTestRunnerを作ってShadowクラスの登録とマッピングを行います。Shadowクラスを追加する度に更新しないといけないので面倒です。

public class MyRobolectricTestRunner extends RobolectricTestRunner {
  public MyRobolectricTestRunner(Class<?> testClass) throws InitializationError {
    super(testClass);
  }
  @Override
  protected ShadowMap createShadowMap() {
    return super.createShadowMap().newBuilder()
            //Shadowクラスを登録する
            .addShadowClass(ShadowGsonBuilder.class)
            .build();
  }
  public InstrumentationConfiguration createClassLoaderConfig() {
    return InstrumentationConfiguration.newBuilder()
            //クラスロード時に書き換え対象となるクラスのFQCNを登録する
            .addInstrumentedClass(GsonBuilder.class.getName())
            .build();
  }
}

テストでカスタムRunnerを使い、Configで利用するShadowを宣言する

Shadowクラスをテストで利用するにはShadowクラスのマッピングを行うRunnerを@RunWithで指定し、さらに@Configアノテーションで利用するShadowクラスを宣言します。

@RunWith(MyRobolectricTestRunner.class)
@Config(shadows = {ShadowGsonBuilder.class})
public class FooTest {
  //...
}

これでこのテスト内ではGsonBuilderが書き換えられます。やったね。

Java標準ライブラリ等は書き換えられないので注意

https://github.com/robolectric/robolectric/blob/master/robolectric/src/main/java/org/robolectric/internal/bytecode/InstrumentationConfiguration.java#L161などに書き換えを除外するパッケージやクラスが定義されてます。書き換わらないぞ!?という時はチェックするとよさそうです。もしかしたらこの辺も設定次第でいけるかもしれません。

とりあえずShadowクラスは最後の手段だと考えておいた方がいいでしょう。ほとんどの場合はrobolectric自身が提供するShadowクラスの利用やpowermockでの書き換えで十分対応できると思います。

Charles & Android Emulator 2015

Android EmulatorでCharles使う情報がだんだん古くなって現代に即してないので適当にメモする。

イマドキのAndroid Emulatorは-http-proxy

以前はAccess Point Namesでproxy設定していたけど5.0辺りからこの設定が利かなくなっている。emulator起動時の引数に設定を渡さないといけなくなった。

emulator -avd $avd_name -http-proxy http://$your_localhost_ip:8888

SSLサーバ証明書を入れる

プロキシ設定をして起動したあとSSLサーバ証明書をインストールする。emulatorのブラウザで以下のURLにアクセスすればOK。

http://charlesproxy.com/getssl/

avd-pecoなら楽ちん

avd-pecoでパラメータ受け取れるようにしたのでemulator起動もプロキシ設定も楽になった。

github.com

こんな感じ。

avd_peco "-http-proxy http://$your_localhost_ip:8888"

Android 6.0 の Runtime Permissions (M Permissions) に対応するためのアクティビティ図

M Permissionsをやっつけようという事でAPIやドキュメントを眺めたらそれなりにシンプルだなと思っていたけど実際手を出してみるとすごくややこしかったのでアクティビティ図にした。気をつけるべき点を後述する。

f:id:sys1yagi:20151107172329p:plain

targetSdkVersionの違いによる挙動

アプリのtargetSdkVersionによってインストール時の挙動が異なる点についてはドキュメントに書かれている。

Requesting Permissions at Run Time | Android Developers

If the device is running Android 5.1 or lower, or your app's target SDK is 22 or lower: If you list a dangerous permission in your manifest, the user has to grant the permission when they install the app; if they do not grant the permission, the system does not install the app at all.

端末が5.1以下かtargetSdkVersionが22以下ならインストール時に全権限の確認を行う。拒否された場合はインストールできない。

If the device is running Android 6.0 or higher, and your app's target SDK is 23 or higher: The app has to list the permissions in the manifest, and it must request each dangerous permission it needs while the app is running. The user can grant or deny each permission, and the app can continue to run with limited capabilities even if the user denies a permission request.

端末が6.0以上で且つtargetSdkVersionが23以上ならアプリの実行時に権限の許可を貰わなければならない。

Note: This lesson describes how you implement permissions requests on apps that target API level 23 or higher, and are running on a device that's running Android 6.0 (API level 23) or higher. If the device or the app's targetSdkVersion is 22 or lower, the system prompts the user to grant all dangerous permissions when they install or update the app.

しかしここには端末が6.0以上で且つtargetSdkVersionが22以下でインストール後に権限をOFFにしたケースが書かれていない。このケースの場合ContextCompatPermissionCheckercheckSelfPermission()の挙動が異なる。

  • ContextCompat : 常にGRANTED
  • PermissionChecker : GRANTED or PERMISSION_DENIED_APP_OP

このためパーミッションのチェックには常にPermissionCheckerを使っておいた方がよさそう。

Never ask againの対応

requestPermissions()を呼び出した時、「今後は確認しない」というチェックボックスが表示される。これにチェックがつくとrequestPermissions()を呼び出しても許可のダイアログが出ずに即座にonRequestPermissionsResult()にDENIEDが返るようになる。「今後は確認しない」がチェックされている場合はアプリ設定に飛んでユーザ自身にチェックボックスをONにしてもらうしかない。

「今後は確認しない」がチェックされているかどうかを判定するにはPermissionChecker.checkSelfPermission()shouldShowRequestPermissionRationale()を使う。

@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions,
    int[] grantResults) {
  super.onRequestPermissionsResult(requestCode, permissions, grantResults);

  switch (requestCode) {
    case REQUEST_CODE:
      if (!verifyPermissions(grantResults)) {
        if (shouldShowRequestPermissionRationale(REQUIRE_PERMISSION)) {
          // show error
        } else {
          // Never ask again.
          // show app settings guide.
        }
      } else {
        // do something.
      }
  }
}

onRequestPermissionsResult()のタイミングでshouldShowRequestPermissionRationale()を実行した時「今後確認しない」がチェックされていなければ必ずtrueになる。この値がfalseだった時はアプリ設定でONにしてもらう旨を説明する表示を行う*1。アプリ設定画面を起動するためのIntentについては後述する。

ちなみにtargetSdkVersionが22以下の場合は「今後は確認しない」のチェックボックスは出ないのでこのフローは気にしなくてよい。

実装する必要のあるアクション

アクティビティ図のアクションのうち[]で囲んだものは独自に実装する必要のあるアクションである。どういうものを実装すればいいか簡潔に説明する。

[show rationale]

一度権限を拒否された場合に表示する。これはユーザに要求する権限によって何をするか、何が出来るようになるかを説明するためのもの。ほとんどの場合ここで権限について説明しその後許可ダイアログを出すフローに遷移する。アクティビティ図でもOK,Cancelの選択を出す事を前提としている。

[show error]

権限の許可を拒否された場合に表示するもの。権限を拒否した結果機能が使えない事を説明する。Toast, SnackBar, ダイアログ、画面のどれでもいいと思う。

[check result]

onRequestPermissionsResult()の処理。ここが仕様によって変わる事はなさそう。以下のメソッドをどこかに定義しておいて使えばよいと思う。

public boolean verifyPermissions(@NonNull int... grantResults) {
  for (int result : grantResults) {
    if (result != PackageManager.PERMISSION_GRANTED) {
      return false;
    }
  }
  return true;
}

[show app settings guide]

「今後確認しない」をチェックしている場合、アプリの設定画面に飛んでもらい権限を手動でONにしてもらうしかない。以下のUIを見ればわかる通り単純に画面を起動するだけでは操作をしてもらえないだろう。どういった手順で操作するかを説明する画面をこのアクションで表示した上で設定画面を開くべきである。

[open app settings]

アプリの設定画面は以下のIntentで起動できる。残念ながら権限画面を直接起動するIntentは無いらしい。

Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
Uri uri = Uri.fromParts("package", getPackageName(), null); //Fragmentの場合はgetContext().getPackageName()
intent.setData(uri);
startActivity(intent);

startActivityForResult()を使って復帰時に再度権限チェックの処理を走らせてもよいと思うがそこはアプリの設計次第になる。

さいごに

アクティビティ図に合わせて実装すればうまく動作するものが作れると思う。targetSdkVersion 22のフローで実装した場合、将来23になった時に切り替え忘れて事故ると思うので、予め23のフローも実装しておいてtargetSdkVersionを23にするというissueでも作ってTODOリストに切り替えを忘れない旨を書いておくとよいと思う。

独自に実装する必要のあるアクションについてはアプリの仕様次第でかなり揺れると思うので参考程度に見ておいてください。

*1:いきなり設定画面に飛ばしてもいいとは思うがおそらく手順の説明なしでは適切にONにするのは難しいとおもう

TextViewが省略されたかどうかを確認する

ググってもだいたいEllipsizeの設定で自動省略の話しか出てこないのでメモる。

やりたい事

TextViewが省略表示になっているかを確認して処理したい、というモノ。

isTextTruncated()を作る

TextViewUtilsとか適当に作る。おおむねgetLatyout()とかgetLineCount()とかgetEllipsisCount()とか使うと省略かどうかわかる

public class TextViewUtils {
  public static boolean isTextTruncated(TextView textView) {
    if (textView == null) {
      return false;
    }
    Layout layout = textView.getLayout();
    if (layout == null) {
      return false;
    }
    int lines = layout.getLineCount();
    if (lines < 1) {
      return false;
    }
    int ellipsisCount = layout.getEllipsisCount(lines - 1);
    return ellipsisCount > 0;
  }
}

こういう時に使える

ほぼ存在しないとおもうけどこんなケース

f:id:sys1yagi:20150929172825j:plain

View作成直後は省略されたか分からないのでonSizeChanged()のタイミングとかでやる。

public class CustomView extends LinearLayout {
  TextView text;
  public CustomView(Context context, AttributeSet attrs) {
    super(context, attrs);
    LayoutInflater.from(context).inflate(R.layout.content_main, this);
    text = (TextView) findViewById(R.id.text);
  }
  @Override
  protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    if (TextViewUtils.isTextTruncated(text)) {
      //なんかレイアウト変える
    }
  }
}

Custom Viewじゃない場合ViewTreeObserver.OnGlobalLayoutListenerとかでやればいけそうだけど大変そう。

おわりに

  • 省略されたかどうか確認できる。
  • 使いたいケースはそんなになさそう。
  • singleLine=trueの時しか使えないです

画面を開いた時にEditTextにフォーカスさせたくないのでBlockEditTextDefaultFocusLinearLayoutというViewを作った

画面を開いた時にEditTextにフォーカスさせたくない(キーボードを開かせたくない)という事で以下のアプローチを考えたがどれも上手くいかなかった。

  • EditTextを最初focusable=falseにしておく
    • focusable=trueにするタイミングが難しい
  • EditTextをfocusable=false, focusableInTouchMode=trueする
  • InputMethodManager.hideSoftInputFromWindow(IBinder, int)
    • こちらも呼び出しタイミングが難しい

あまり美しくない方法

EditTextにフォーカスを取らせない事が目的なので、他のViewを定義しそこにrequestFocusしてやればよいがこれでは意図が伝わりにくい。

<LinearLayout
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    >
  <EditText
    android:id="@+id/message_edit"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"/>
  <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:focusable="true"
    android:focusableInTouchMode="true">
      <requestFocus />
  </TextView>

BlockEditTextDefaultFocusLinearLayout

仕方がないのでBlockEditTextDefaultFocusLinearLayoutを作った。継承するViewは必要に応じて変えていけばよい。今回はLinearLayout。

public class BlockEditTextDefaultFocusLinearLayout extends LinearLayout {

    public BlockEditTextDefaultFocusLinearLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        setFocusable(true);
        setFocusableInTouchMode(true);
        requestFocus();
    }
}

こうなる。もっと良い命名があると思うけど大体通じるんじゃないかと思う。

<com.sys1yagi.android.views.BlockEditTextDefaultFocusLinearLayout
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    >

  <EditText
    android:id="@+id/message_edit"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"/>

おわりに

もっと良い方法あったら教えてください。

※追記 2015/08/21

android:windowSoftInputMode="stateAlwaysHidden"をAndroidManifest.xmlで書くといける!!ただしこの場合requestFocus()は無視されるのでFragmentの遷移とかである時はフォーカスしたいケースなどでは使えない。そういうケースはあんまりなさそうなのでよさそう。

kvs-schemaとdagger2を同時につかう

kvs-schemaというSharedPreferenceをいい感じにクラス化出来るライブラリがあるんですが、このライブラリはJSR 269(Pluggable Annotation Processing API)を使ってコンパイル時にコード生成をしています。dagger2も同様にJSR269によってコード生成をしています。なんとなく「変な衝突の仕方しないかな?」と思ったので試してみました。案の定問題がありました。本エントリではJSR 269を使ったライブラリの衝突の問題とその回避方法について解説します。

kvs-schemaの使い方については以下のライブラリ作者が書いた解説記事を参照してください。

qiita.com

KVS Schemaを定義する

まずKVS Schemaを定義します。例として初回起動時にガイドを出すフラグをひとつだけもつSchemaを作ります。

@Table("guide")
public abstract class GuidePreferenceSchema extends PrefSchema {
  @Key("should_show_guide")
  boolean shouldShowGuide;
}

これによりGuidePreferenceが生成されます。

public final class GuidePreference extends GuidePreferenceSchema {
  public final String TABLE_NAME = "guide";
  GuidePreference(Context context) {
    init(context, TABLE_NAME);
  }
  GuidePreference(SharedPreferences prefs) {
    init(prefs);
  }
  public boolean getShouldShowGuide() {
    return getBoolean("should_show_guide", shouldShowGuide);
  }
  public void putShouldShowGuide(boolean shouldShowGuide) {
    putBoolean("should_show_guide", shouldShowGuide);
  }
  public boolean hasShouldShowGuide() {
    return has("should_show_guide");
  }
  public void removeShouldShowGuide() {
    remove("should_show_guide");
  }
}

GuidePreferenceSchemaGuidePreferenceを返却するメソッドを生やしておきます。これはGuidePreferenceコンストラクタがpackage privateなためです。

@Table("guide")
public abstract class GuidePreferenceSchema extends PrefSchema {
  @Key("should_show_guide")
  boolean shouldShowGuide;
  public static GuidePreference create(Context context) {
    return new GuidePreference(context);
  }
}

Dagger2のComponentとModuleを定義する

Dagger2のComponentを定義します。SharedPreferenceはApplicationのスコープで管理すれば良いと思うのでAppComponentおよびAppModule*1で提供する事にします。

@Singleton
@Component(modules = {AppModule.class})
public interface AppComponent {
   void inject(MainActivity target);
}

GuidePreferenceはContextが必要なのでAppModuleにContextを持たせておきます。

@Module
public class AppModule {
  Context context;
  public AppModule(Context context) {
    this.context = context;
  }
  @Singleton
  @Provides
  public GuidePreference provideGuidePreference() {
    return GuidePreferenceSchema.create(context);
  }
}

あとは使う準備です。Applicationクラスを定義してAppComponentをもたせます。

public class Application extends android.app.Application {
  static AppComponent appComponent;
  public static AppComponent getAppComponent() {
    return appComponent;
  }

  @Override
  public void onCreate() {
    super.onCreate();
    appComponent = DaggerAppComponent.builder()
            .appModule(new AppModule(this))
            .build();
  }
}

injectの対象となるMainActivityでinjectionの処理を書きます。

public class MainActivity extends AppCompatActivity {
  @Inject
  static GuidePreference guidePreference;
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    Application.getAppComponent().inject(this);
    //..

これで準備万端です。

コンパイルできない

ところがこの状態でコンパイルすると以下のエラーでコケます。

Execution failed for task ':app:compileDebugJavaWithJavac'.
> java.lang.IllegalArgumentException: GuidePreference cannot be represented as a Class<?>.

Dagger2がComponentやModuleを処理する時、kvs-schemaが生成したGuidePreferenceが見えないのが原因っぽいです。aptのオプション等色々見ましたが設定で回避はできなさそうでした。

生成されたクラスを直接参照しない

仕方ないのでkvs-schemaが生成するクラスを直接参照しない形にすることにしました。

public class GuidePreferenceProvider {
  GuidePreference guidePreference;
  public GuidePreferenceProvider(Context context) {
     this.guidePreference = GuidePreferenceSchema.create(context);
  }
  public GuidePreference get() {
    return guidePreference;
  }
}

AppModuleではGuidePreferenceProviderをprovidesする事になります。

@Module
public class AppModule {
  Context context;
  public AppModule(Context context) {
    this.context = context;
  }
  @Singleton
  @Provides
  public GuidePreferenceProvider provideGuidePreference() {
    return new GuidePreferenceProvider(context);
  }
}

若干もやっとしますがこういう風に使います。これでkvs-schemaとdagger2を共存させられます。

public class MainActivity extends AppCompatActivity {
  @Inject
  static GuidePreferenceProvider guidePreference;
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    Application.getAppComponent().inject(this);
    if(guidePreference.get().getShouldShowGuide()){
      //..
    }
    //...

コード

コード例はこちらです。

github.com

おわりに

JSR 269(Pluggable Annotation Processing API)で生成したクラスをDagger2等であつかおうとすると罠がある事がわかりました。Dagger2で触らない場合には問題にはなりません。なかなかめんどくさいですが現状は別のクラスを経由する方法をとるしかなさそうです。何か回避方法がちゃんとある気はするので知っているかた教えてください。

*1:これらのクラス名に特に意味はありません。アプリケーション全体で1つである事を表す為に用いています

ここがつらいよ realm-android 0.81.1

Realmわりといいんですが「簡単!便利!スナック!」というイメージだったのでスナックボリボリする感じで適当に書いてたらガンガンクラッシュ*1して「スナックじゃないな?ぬか漬けかな?」みたいな気持ちになりました。とりあえず一旦ぬか漬け*2部分をまとめます。将来改善予定のものもあれば、自分の設計が間違ってるんだろうなぁーというものもあります。io.realm:realm-android:0.81.1を使っての感想です。iOS版の使い心地についてはわかりません。

autoincrementがない

今のところautoincrementがないです。how to set an auto increment id? · Issue #469 · realm/realm-java · GitHubとか眺めると「まだサポートしてないぜ!」との事。将来サポートされるでしょう。とりあえず現状は以下のような仕組みをつくってしのいでます。PrimaryKeyがstringの場合は使えないです。

public class AutoIncrement {
  public static long newId(Realm realm, Class<? extends RealmObject> clazz) {
    return newIdWithIdName(realm, clazz, "id");
  }

  public static long newIdWithIdName(Realm realm, Class<? extends RealmObject> clazz, String idName) {
    return realm.where(clazz).maximumInt(idName) + 1;
  }
}

こんな感じで使ってます。

realm.beginTransaction();
Item item = realm.createObject(Item.class);
item.setId(AutoIncrement.newId(Item.class));
// 略
realm.commitTransaction();

PrimaryKeyを0で保存すると次のRealm.createObjectの時点で衝突して死ぬ

Realm.createObject(Class<E>)は指定したE型の空レコードを生成して返してくれます。この時short, int, longのフィールドは0で初期化します。すでにPrimaryKey=0のレコードが存在する場合以下のエラーを吐いて死にます。

io.realm.exceptions.RealmException: Primary key constraint broken. Value already exists: 0

まぁかならずPrimaryKeyはセットしましょうって事なんですがRealm.createObject()で死ぬので最初意味がわかりませんでした。前項のautoincrementの仕組みとか使えば問題にはならないです。

mockできない

Realmクラスはmockitoでmockできません。

Realm realm = mock(Realm.class);

Realmクラスがfinalだからです。いやーmockしたい。仕方ないのでwrapperを作ってcompositionにしました。

public class RealmWrapper {

  Realm realm;

  public RealmWrapper(Context context) {
    realm = Realm.getInstance(context);
  }

  public <E extends RealmObject> List<E> copyToRealm(Iterable<E> objects) {
    return realm.copyToRealm(objects);
  }
  //以下Realmクラスが持つpublicメソッド全てをDelegateする
}

Android StudioのGenerate-Delegate Methods...で一発でいけます。

RealmWrapper realm = mock(RealmWrapper.class);
when(realm.createObject(ItemIndex.class)).thenReturn(new ItemIndex());

やったね。まぁよく考えたらRealmに直接さわるクラスを限定してそいつをmockableにしたらいいんじゃないかな?という気もしますが。そういう意味ではActivityやFragmentやThreadで直接Realmオブジェクト触るのはあんまり良くないんじゃないかなぁという印象です。

スレッド制約

Realmはスレッドの制約がつらいです。近々マルチスレッドをサポートするそうなので期待ですね。現状なにが辛いかというと以下の点です。

  • RealmオブジェクトはgetInstance()したスレッドでしか使えない。別スレの場合そのスレッドでgetInstance()しないといけない
  • where等のRealmQueryで得たRealmResultを別のスレッドに渡せない。別スレで触ると死ぬ
  • RealmResult等から取り出したオブジェクト(いわゆるmanaged object)を別スレで触ると死ぬ

例えばItemというモデルを定義した場合以下の様なクラスが生成されます。これをmanaged objectと呼ぶらしいです。

public class ItemRealmProxy extends Item
    implements RealmObjectProxy {

Realm.createObject(Classs<E>)やクエリなどで引っ張ってきたオブジェクトの実体はこのmanaged objectになります。でこのオブジェクトは内部にRealmオブジェクトを持っていてgetter、setterを実行する際にcheckIfValid()でスレッドの検査をしている為別スレッドで触れないです。あんまり深く追っかけてないですが、スレッドの検査を通過したらnative methodを呼んでいるのでgetter、setterが呼ばれた時にはじめてDBからデータの読み書きしてるのかな?という感じです。

この制約によって以下の2つの問題と戦う事になりました。

  • 別スレで取り出したmanaged objectをUIスレッドにどうやって持ってくるか?
  • Realmオブジェクトの管理をどうするか。Dagger等でinjectionする場合スコープをどうするべきなのか?
    • ActivityにRealmをInjectすると、別スレで処理する時に死ぬ。

managed objectをUIスレッドにどうやって持ってくるか

まず問題と思ったのはmanaged objectとstandalone objectの区別が付かない点でした。

Item item = new Item(); // standalone object
Item item = realm.createObject(Item.class); //managed object

両方とも型はItemなので混乱するなぁという事でstandalone objectを持つクラスを別途用意しました。別の型である方がよいのでcompositionで作りました。最初はItemを継承したんですがこれだとコンパイルが通りませんでした*3

public class ImmutableItem {
    Item item;
    //...
}

値の初期化については最初gsonを使って以下のように書いていたんですが、

public ImmutableItem(Item item) {
  String json = gson.toJson(item);
  this.item = gson.fromJson(json, Item.class);
}

managed objectのデータアクセスはgetter、setterを通さないとダメなのでスカスカのデータにしかなりませんでした。RealmとJSONライブラリ // Speaker Deckにある通りTypeAdapterを使えばgsonでもやれるんですが意味がないので愚直に書くことにしました。

public ImmutableItem(Item item) {
  this.item = new Item();
  this.item.setId(item.getId());
  // 略
}

standalone objectである事を保証する型を作ることでAPIの返却値やObservableとかで扱いやすくなりました。

public Observable<List<ImmutableItem>> items(int page, int perPage) {
  //...
  //subscribeOn(Scheduler.io())なんかもどんとこい
}

Realmオブジェクトの管理をどうするか

RealmオブジェクトをDaggerで注入とかやってたので辛かったです。幸いRealmWrapperを作っていたのでThreadLocalを使ってゴニョゴニョする事にしました。具体的にはこんな感じ。

public class RealmWrapper {

  ThreadLocal<Realm> realms = new ThreadLocal<Realm>();

  public Realm getRealm() {
    Realm realm = realms.get();
    if (realm == null) {
      realm = Realm.getInstance(context);
      realms.set(realm);
    }
    return realm;
  }

これで触るスレッドを意識しなくてよくなるんですが思いっきりメモリリークします。この辺は後述するライフサイクル周りの話でなんとかしました。が良い方針ではないのでマネしないほうがいいと思います。

ライフサイクルがよくわからない

Realmオブジェクトってどのくらいの頻度でgetInstance()すべきなのか?ドキュメントにはonDestroy()close()しろとあるのでActivityくらいのスコープでやってたらいいのかなと思ったら当然以下のコードはダメですよね。

class AwesomeActivity exetends Activity {
  Realm realm;
  @Override
  public void onCreate(Bundle savedInstanceState) {
    realm = Realm.getInstance(this); // UI Thread
    new Thread() {
        @Override
        public void run() {
          // 異なるスレッドでrealmに触れているのでクラッシュする
          RealmResults<Item> result = realm.where(Item.class).findAll();
          List<Item> entities = toImmutableItems(result);
          //...
        }
    }.start();
  }
}

前項のThreadLocalを使ったRealmWrapperなら問題にはなりません。

class AwesomeActivity exetends Activity {
  RealmWrapper realmWrapper;
  @Override
  public void onCreate(Bundle savedInstanceState) {
    realmWrapper = RealmWrapper.getInstance(this); // UI Thread
    new Thread() {
        @Override
        public void run() {
          // 内部でThreadLocalでRealmを触るのでクラッシュしない
          RealmResults<Item> result = realmWrapper.where(Item.class).findAll();
          List<Item> entities = toImmutableItems(result);
          //...
        }
    }.start();
  }
}

しかしこの場合Threadが終了してもThreadLocalにRealmオブジェクトが保持され続けてしまいます。そこで暫定的にRealmWrapperstart(), end()ってのを生やしました。

public void start() {
  realms.set(null);
}

public void end() {
  Realm realm = getRealm();
  realm.close();
  realms.set(null);
}

とりあえずこれで動いたんですが、これは良くない設計です。確実にstart()end()を忘れるケースが出るでしょう。

//こんな感じで使う
realmWrapper.start();
RealmResults<Item> result = realmWrapper.where(Item.class).findAll();
List<Item> entities = toImmutableItems(result);
realmWrapper.end();

直接Realm.getIntance(Context)を随所に書きたくない、というのが目的なのでRealmWrapperRealmWrapper getInstanceForCurrentThread()とかを生やして使い終わったらすぐclose()ってのがまだマシな気がします。スレッド周りは制約をちゃんと加味したうえで設計していかないと辛みがあります。今回のケースは完全に失敗だったなと思います。次やるときはいい感じに設計するぞ!

json制約

「managed objectをUIスレッドにどうやって持ってくるか」の所でも書きましたがjson周りに辛みがあります。RealmとJSONライブラリ // Speaker Deckに全部書いてあります。当面はtoJson()はしない方向で頑張る方針にしました。

おわりに

いろいろ触る前にJava Docs - Realm is a mobile database: a replacement for SQLite & Core Dataを熟読するべきだったなという感じです。ゲッター/セッターメソッドは RealmObject が作るプロキシクラスによって上書きされることです。 ゲッター/セッターに書かれたロジックは実行されません。とか割と重要な事サラッと書いてある。学習した今だと色々もっと設計でカバーできそうだなぁという感じです。色々つらい目にあったけどRealm良いと思うので「私はRealmを続けるよキャンペーン」です。version見たら0.82.1が既に出ていた。差分眺めた感じ今回書いた点で変更はなさそう。

*1:使い方が間違っているとクラッシュします。良いことだと思います

*2:ぬか床の手入れって大変だそうですね

*3:A RealmClass annotated object must be derived from RealmObjectと言われます