visible true

技術的なメモを書く

JSR 269の勉強がてらFragment生成と引数周りを楽にするfragment-creatorというライブラリを作った

aptしてますか。 正確にはPluggable Annotation Processing API(JSR 269)なので以降はJSR 269と書きます。

Dagger2やRealmなどJSR 269でコード生成を行っているライブラリ群の理解を深め、あわよくばcontributionするためJSR 269を勉強しつつチョットしたライブラリを作りることにしました。

完成したものが以下。

sys1yagi/fragment-creator - Java - GitHub

FragmentのnewInstanceメソッドとArgumentsの取扱いにまつわる部分を生成するライブラリです。

使い方

使い方はREADME.mdを見てもらえばわかるとは思いますが一応簡単に書いておきます。

以下のようにFragmentにアノテーションを付与するとMainFragmentCreatorというクラスが生成されます。

@FragmentCreator
public class MainFragment extends Fragment {
    @Args
    String keyword;
    @Args(require = false)
    String userId;
}

あとは使うだけ。

※0.6.0以降ではBuilder周りの使い方が変わっています。fragment-creator 0.6.0 releasedを参照してください。

MainFragment fragment = MainFragmentCreator.Builder
  .newInstance("keyword") // requiredな値はここで渡す
  .setUserId("userId")    // optionalな値はsetterで渡す
  .build();

FragmentのonCreate()でArgumentsの読み込みを行います。

@Args
String keyword;
@Args(require = false)
String userId;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  MainFragmentCreator.read(this);
  //keyword, userIdが初期化される
}

らくちんだ。

参考にしたもの

今後

とりあえず使えるようにはなったもののまだ機能が足りないのでvalidatorとかconverterとか設定できるようにしようかなと思います。あとKotlin化してkaptでハマった上で修正をKotlinに出せたらいいかなとか。

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つである事を表す為に用いています