2017/07/25

VisibleForTestingとRestrictTo

昨日, メッセージの表示頻度を簡単に調整できるライブラリdenbunをリリースしました.

Denbun

初めてのライブラリリリースなので色々と学びがありました.
本稿ではVisibleForTestingRestrictToアノテーションについて書き留めます.

VisibleForTesting

フィールドやメソッドのスコープはできるだけ狭くすることが大切ですが, テスタビリティを確保するためにやむなくスコープを広くとる場合があります.
VisibleForTestingは, スコープをテスタビリティのために広く定義していることを明示します.

例えば, Denbunライブラリでは情報の永続化先であるSharedPreferenceとのI/Oをフックできるようにしてテスタビリティを確保しています.

@VisibleForTesting(otherwise = PACKAGE_PRIVATE)
public DenbunConfig daoProvider(@NonNull Dao.Provider provider) { ... }

このメソッドはプロダクションコードではPackage Privateスコープで扱われることを想定し, テストコードではPublicスコープで扱われることを想定しています.
そのため本来あるべきスコープはPackage Privateなのですが, テスタビリティのためにPublicとしています.

メソッドが本来あるべきスコープはVisibleForTestingアノテーションのotherwiseパラメータに指定します.
こうすることで, プロダクションコードにおいてPackage Privateスコープ外からアクセスしてきた場合にインスペクションによる警告が表示されるようになります.

ただし, このアノテーションはクラスファイルに影響を及ぼすものではないので, インスペクションの警告を無視して無理やり要素にアクセスすることは可能です.

VisibleForTestingの真価は, このアノテーションで指定された要素をプロダクションコードで呼び出すとインスペクションの警告によって使い方が間違っていることを教えてくれるところにあります.
これは, javadocにコメントを残す対応よりもはるかに効果的で簡単です. また, 利用側に実装者の意図をインスペクションを通して伝えることができるので利用側にとっても嬉しい機能です. 実際のライブラリ開発では手軽に導入できてアクセス制御で悩むことも減るのでとても便利に使えます.

ただ, 実際にはアクセス制御できていないので, APIを公開することが致命的であるケースにおいてはイミュータブルインタフェースをかませるなどの対応が必要です. (そのようなケースはあまり思い浮かびませんが, セキュリティが必要なSDKなどでは該当しそうです)

RestrictTo

次にRestrictToアノテーションです. これはテストのために用意されたメソッドであることを明示するものです.
VisibleForTestingはテスタビリティのための”スコープ”に着目しているので, そのメソッド自体は想定されるスコープ内であればプロダクションコードで呼ばれることが許されています.
例えば, VisibleForTesting(otherwise = private)なメソッドであればプロダクションコードでもクラス内(privateスコープ内)からの呼び出しが想定されているということです.

一方で, RestrictToはメソッド自体の存在に着目しています.
RestrictTo(TEST)であれば, テストコードからの呼び出しのみを想定しており, プロダクションコードでの呼び出しは想定されません. RestrictTo(LIBRARY)であれば, ライブラリ内での用途に限った要素であることを明示しています.
これはライブラリを作る側としてはとても強力です. これもVisibleForTestingと同じく, 呼び出し側が想定外の呼び出しを行なった場合にインスペクションの警告を表示します.

例えば, Denbunライブラリでは, DenbunBoxの初期化は一度しか行えず, 2回目以降はno-opになるよう実装されています.
しかし, UnitTestをする際にテストケースごとにDenbunBoxを再初期化したくなる場合も想定して, DenbunBoxの状態をリセットするreset()メソッドを用意しています.

@RestrictTo(TESTS) public static void reset() { ... }

このメソッドは, ライブラリ内部および, プロダクションコードからの呼び出しも想定していません. テストに限定した利用を想定したものです.

ライブラリを作る際には, こういったアノテーションも活用して, 利用する側に作り手の意図を明示するのも大切だなと感じました.

以上です.

Denbunライブラリでメッセージの表示頻度を調整する

tl;dr

はじめに

モバイルプラットフォームでは, ユーザ向けに何かしらのメッセージを表示することがよくあります.
それは, イベントの発生を知らせるものであったり, ユーザのアクションが完了したことを知らせるものであったり, エラーの発生を知らせるものであったりと様々です.
これらのメッセージは重要なものですが, 中には退屈と思われてしまうものもあります.

  • ユーザがアプリケーションの振る舞いを学習するのに重要なメッセージが, アプリケーションを使い慣れた後になっては, ただのお節介なメッセージになってしまうケース
  • 毎回閉じるだけの”お知らせダイアログ”といった類のもの
  • コンテンツの削除確認といった誤操作防止目的のもの
  • Backキーを押した際の「アプリケーションを終了しますか?」なもの

ユーザを退屈させないためにも, メッセージの表示頻度を調節することが重要です.

Denbun

メッセージの表示頻度を調整するためのアプローチはいくつかあります.

  • ダイアログに「今後表示しない」チェックボックスをつけてユーザ主動でダイアログ表示をやめさせる方法
  • 一度しか表示しないような回数限定メッセージ
  • 一週間のうち決まった曜日にだけ表示する定期的なメッセージ など…

これらのアプローチをとるためには, 表示設定や表示回数といった内容を永続化して都度, 表示頻度を調整する必要があります.
そこで, メッセージの前回表示時間や表示回数といった情報を保存し, 表示頻度の調整をサポートするDenbunライブラリをリリースしました.



このライブラリは, 次のようなメッセージ通知を実現したい場合に有効です.

  • 「今後表示しない」 オプション付きメッセージ
  • N回だけ表示するメッセージ
  • 定期的に表示するメッセージ(1週間に1回の頻度で表示. 月曜日に1回だけ表示. etc.)
  • N回表示した後は, n時間経過するまで表示しないメッセージ

メッセージの表現系(Dialog, Toast, Snackbar, etc.)は問いません.
このライブラリは, メッセージの前回表示時間や表示回数をSharedPreferenceに保存しており, これらの情報を駆使して”今, メッセージを表示すべきかどうか” を判断することで, メッセージの表示頻度を調整します.

使い方

まず初めに, Application.onCreateなどで, DenbunBoxを初期化します.
DenbunBoxはこのライブラリの起点となる重要なクラスです.

DenbunBox.init(new DenbunConfig(this));

DenbunBoxの初期化が終わったら, メッセージを表現するDenbunインスタンスを取得します.
メッセージの表示頻度の調節はこのDenbunインスタンスを通して行います.

Denbun msg = DenbunBox.get(ID);

Denbunインスタンスのshow()を呼び出すことで, 表示時間や表示回数の情報が更新され永続化されます.

Denbun msg = DenbunBox.get(ID);
msg.shown();

メッセージの最適な表示頻度はメッセージ毎に異なりますので, Denbunインスタンスを取得する際に最適な表示頻度を算出できるFrequency Adjusterを指定します.
例えば, 下記の例は1回限りのメッセージ通知を実現する例です.

// This message is displayed only once.
Denbun msg = DenbunBox.get(ID, new CountAdjuster(1));
...
msg.isShowable(); // true
msg.shown();
msg.isShowable(); // false

あるいは, メッセージを直接的に今後表示しなくすることも可能です.

Denbun msg = DenbunBox.get(ID);
msg.suppress(true);

メッセージによっては表示頻度の計算が複雑になるものもあるでしょうから, Frequency Adjusterは自前のものを実装してDenbunBox.getに指定することもできます.

実際にDialogやToastを表示する際には, DenbunインスタンスのisShowable()の値を確認してから表示すると決められた頻度でメッセージを表示することができます.

テスタビリティ

Denbunライブラリを使ったコードをテストしたい場合は下記が参考になります.
DenbunConfigにはDenbunライブラリとSharedPreferenceのI/Oを取り持つDAOのgetter/setterが用意されています(このメソッドは@VisibleForTestingです)

DenbunConfig conf = new DenbunConfig(app);

// spy original DaoProvider
Dao.Provider origin = conf.daoProvider();
conf.daoProvider(pref -> (spyDao = spy(origin.create(pref))));
DenbunBox.init(conf);

DenbunBox.find(ID).shown();
verify(spyDao, times(1)).update(any());

おわりに

Denbunライブラリを使い始めるには次の一文をbuild.gradleに追記するだけです.
<latest version>には最新のライブラリバージョンを指定してください.

compile 'com.yuki312:denbun:<latest version>'

近々v1.0.0をリリース予定です.
PRやIssueがあればGitHubの方に登録していただけると幸いです.

以上です.

2017/07/20

Intentの共有先一覧から自アプリを除外する

他アプリ起動周りでちょっとハマったのでメモ.

テキストやURIを暗黙Intentで共有する場合, 自アプリがそれに反応するintent-filterを持っていると, ActivityChooserに表示候補として含まれてしまう場合があります.
自アプリで捌きたくないから他アプリに共有しているのに, そのリストに自アプリが載っているのはよろしくない.
ということで, Intentは投げるけれどActivityChooserに自アプリを含めない方法を探りました.

TL;DR

  • createChooser, ChooserActivityまわりの挙動がOSバージョンで異なっている
  • API LV.23 前後でPackageManager.MATCH_DEFAULT_ONLYの振る舞いが変わる
  • API LV.23 前後でActivity選択ダイアログのレイアウトが変わる
  • 結論queryIntentActivitiesからの自前ダイアログ生成のが楽そう

シンプルにqueryIntentActivitiesIntent.createChooserを組み合わせればできるだろうと思っていたのですが, 古いOSで確認したところ意図した通りに動きませんでした.
で, 古いOSでの動作もサポートすべく, 色々検討した結果を残しておきます.

createChooser

Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
int flag = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PackageManager.MATCH_ALL
    : PackageManager.MATCH_DEFAULT_ONLY;
List<ResolveInfo> launchers 
  = context.getPackageManager().queryIntentActivities(intent, flag); // *a

// 自アプリを起動対象から除外する
List<Intent> intents = new ArrayList<>();
for (ResolveInfo app : launchers) {
  if (context.getPackageName().equals(app.activityInfo.packageName)) {
    continue;
  }
  Intent target = new Intent(intent);
  target.setPackage(app.activityInfo.packageName);
  intents.add(target);
}

if (intents.isEmpty()) {
  // 起動対象のアプリが見つからなかった
} else {
  // createChooserの第一引数のIntentに反応できるアプリが存在しない場合は EXTRA_INITIAL_INTENTS
  // の指定が無視されるため, 必ず反応できるIntentを設定する目的でremove(0)を指定する.
  Intent chooser = Intent.createChooser(intents.remove(0), title); // *1
  chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, intents.toArray(new Parcelable[0])); // *1
  context.startActivity(chooser);
}

ポイントは *1 の部分で, 下記のコードではAPI Lv.23未満だとうまく動作しませんでした.

  Intent chooser = Intent.createChooser(new Intent(), title); // *1
  chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, intents.toArray(new Parcelable[0])); // *2

EXTRA_INITIAL_INTENTSに目的のIntentを設定すればうまくいきそうなものですが, API Lv.23未満だと *1 の第一引数Intentに反応できるActivityの数が0であった場合に EXTRA_INITIAL_INTENTS が無視される挙動になります(つまりActivityNotFound)
API Lv.23以上ではEXTRA_INITIAL_INTENTSが評価されます.

API Lv.23未満でcreateChooserの第一引数に渡すIntentは, 少なくとも1つ以上のActivityが反応できる必要があるので下記のようなコードになりました.

Intent chooser = Intent.createChooser(intents.remove(0), title); // *1
chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, intents.toArray(new Parcelable[0])); // *2

MATCH_ALL

*a で, PackageManager.MATCH_DEFAULT_ONLY はAPI Lv.23から挙動が変わっています.
API Lv.23未満だと, Category.DEFAULTに反応するActivityを抽出するものでしたが,
API Lv.23以上だと, 「既定で開く」設定されたActivityがある場合はそのActivityしか返却されなくなりました. API Lv.23以上でAPI Lv.23未満と同じ挙動にするためにはAPI LV.23から追加されたPackageManager.MATCH_ALLを指定する必要があります.

int flag = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PackageManager.MATCH_ALL
    : PackageManager.MATCH_DEFAULT_ONLY;
List<ResolveInfo> launchers 
  = context.getPackageManager().queryIntentActivities(intent, flag); // *a

より便利にいくなら, API Lv.23以上でもMATCH_DEFAULT_ONLYResolveInfoを拾って, 「既定で開く」設定が自アプリになっていなければそのまま起動, 自アプリであれば上記の処理を実行するとすればいけそうです.

この処理でうまくいきましたが, デバイスによってはシェアダイアログのレイアウトが下記のように残念な結果に :(

動作をみる限りでは, createChooserに渡したIntentが1行目に並び, EXTRA_INITIAL_INTENTSに渡したIntentが2行目に並んでいる様子.
これを解決するならシェアダイアログを自前で組む必要がありそうです.
(あるいはAPI Lv.23ではcreateChooserの第一引数にどのActivityにもマッチしないnew Intent()といったIntentを指定するなど…)

API Lv.24からEXTRA_EXCLUDE_COMPONENTSなる定数も追加されているので, API Lv.24以上はこれを使えということかもしれませんが, こんなことにOSバージョン分岐させるのも面倒なので, 手っ取り早くやるならqueryIntentActivitiesからの自前ダイアログ作成が安定しているという結論に落ち着きました.

以上です.

2017/07/10

Replace Dialog to BottomSheet

従来はコンテンツを他アプリへ共有する際などにダイアログUIが使われていましたが,
昨今では, マテリアルデザインのModal bottom sheetsで説明されているように, ボトムシートUIにするのが一般的です.

ボトムシートを実装するにはいくつか方法がありますが, 既存のダイアログをボトムシートに変更したいだけであれば, AppCompatDialogを継承したBottomSheetDialogFragment/BottomSheetDialogを使うだけで比較的容易に対応できます.

// 継承元をDialogFragmentからBottomSheetDialogFragmentに変更
// public class MyDialogFragment extends DialogFragment
public class MyDialogFragment extends BottomSheetDialogFragment {

...

  @Override public Dialog onCreateDialog(Bundle savedInstanceState) {
    ...
    View view = binding.getRoot();
    MyBottomSheetDialog bottomSheet = new MyBottomSheetDialog(getContext());
    bottomSheet.setContentView(view);

    // ボトムシートダイアログを返却する
    return bottomSheet;
  }
}

ボトムシートの幅はスクリーンサイズに合わせて最大幅を調節することが推奨されています.

Screen width Minimum distance from screen edge (in increments) Minimum sheet width (in increments)
960dp 1 increment 6 increments
1280dp 2 increments 8 increments
1440dp 3 increments 9 increments

BottomSheetDialogの横幅を決めるためには, ダイアログの場合と同じくウィンドウの幅を調整する必要があります.
ウィンドウの幅はBottomSheetDialogのコンストラクタで指定することができます.

private static class ShareBottomSheetDialog extends BottomSheetDialog {
  @Override protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    // 横画面などでボトムシートが間延びしないように最大幅を設ける
    Optional.ofNullable(getWindow())
      .ifPresent(window -> window.setLayout(
        Math.min(displayWidth, maxWidth), 
        ViewGroup.LayoutParams.MATCH_PARENT);    

また, ボトムシート自体をどこまで引き出した状態で表示するかをpeekHeightを使って指定できます. peekHeightBottomSheetBehaviorで指定することができます.

bottomSheet.setContentView(view);

// 横画面などでもシェアアイコンが表示されるようにダイアログの高さ(peek)を確保する
BottomSheetBehavior behavior 
  = BottomSheetBehavior.from((View) view.getParent());
behavior.setPeekHeight(height);

以上です.