Android N Easter Egg (neko) に見るアンチパターン
🐱が70匹を超えたあたりから挙動がめちゃめちゃ重くなってきたAndroid N Easter Egg (neko) アプリ。 コードを読んで何が悪そうか調べてみました。
あくまで静的解析の結果なので検証してません。ごめんなさい。「ふーん」くらいに思ってください。ツッコミ大歓迎。
調査対象
下記2パターンのタイミングで重くなります。特に2は困った。
- 🐱一覧のダイアログでスクロールするとき
- Foodを選択した後、Dialogが消えず端末全体がほぼ操作不能になる
何が悪かったか
1. 🐱一覧のスクロールが重い原因
まず、アプリ全体がどうやって集まっている🐱を保存しているか。
SharedPreferenceに seed
と name
を保存しています。
▼PrefState.java
// Can also be used for renaming. public void addCat(Cat cat) { mPrefs.edit() .putString(CAT_KEY_PREFIX + String.valueOf(cat.getSeed()), cat.getName()) .commit(); }
seed
は🐱の各パーツの色をretrieveするために使います。
(デフォルトで🐱の名前はseed
になっていた気がしますが、🐱の名前はユーザが変えられるので、名前をvalueとして保持しています。)
🐱一覧のActivityの onCreate()
でSharedPreferenceから全ての値をとってきて seed
から各パーツの色を計算し、Drawableを描画するという処理を🐱の匹数分メインスレッドでやります。ちなみにパーツは全部で28か所くらいあります。そりゃ重くもなりますね。
2. Foodを置いた直後が重い原因
Foodが選択された後の処理ですが、🐱と同じくSharedPreferenceに書き込みます。
▼PrefState.java
public void setFoodState(int foodState) { mPrefs.edit().putInt(FOOD_STATE, foodState).commit(); }
これは別にいいんですけど、問題(の原因の一つ)は🐱と同じPreferenceに書いてしまっていること。 このSharedPreferenceを監視しているのは下記の2クラス。
A) NekoTile.java (QuickSettingsにあるFood用のタイル)
B) NekoLand.java (🐱一覧画面のActivity)
Aについて、Foodが選択された後にそのFoodの画像とテキストをタイルに表示するために監視しています。ここは別に問題なし。
問題はB。Bの方は🐱が増えたタイミングを取得するために監視しているようです。同じSharedPreferenceを監視しているせいでFoodの値が変わったタイミングでもここが動いてしまいます。
▼NekoLand.java
@Override public void onPrefsChanged() { updateCats(); } private void updateCats() { Cat[] cats; if (CAT_GEN) { cats = new Cat[50]; for (int i = 0; i < cats.length; i++) { cats[i] = Cat.create(this); } } else { cats = mPrefs.getCats().toArray(new Cat[0]); } mAdapter.setCats(cats); }
▼PrefState.java
public List<Cat> getCats() { ArrayList<Cat> cats = new ArrayList<>(); Map<String, ?> map = mPrefs.getAll(); for (String key : map.keySet()) { if (key.startsWith(CAT_KEY_PREFIX)) { long seed = Long.parseLong(key.substring(CAT_KEY_PREFIX.length())); Cat cat = new Cat(mContext, seed); cat.setName(String.valueOf(map.get(key))); cats.add(cat); } } return cats; }
ちなみにCatのコンストラクタで各パーツの色の計算とDrawableへのtintが行われています。差分チェック?知らない子ですね…
さらにAdapterのsetCats()
でnotifyDataSetChanged()
を呼び出しちゃっています。おそらく全ての🐱に対して再描画が走るんじゃないかな…
さらに悪いのが、SharedPreferenceの監視リスナーをonCreate()
でつけています。(当然removeするタイミングは対となるonDestroy()
です。)つまり、アプリがバックグラウンドにいる間も上記処理が走ってしまいます。画面に🐱一覧が表示されていないのに、Foodを置いただけで重くなってたのはそのためっぽい。
そもそもFoodの値が変更されたタイミングで🐱の再描画を走らせる必要がないのだから同じSharedPreferenceを利用しお互いそこを監視するのよくないですね。
どうしたらよかったか
ざっと思いついたのでこんな感じ。
- まずはcatとfoodの保存場所を分離し、それぞれ必要な範囲のみ監視すること。Foodに関してはおそらくこれだけで問題ない。
- cat側の監視タイミングを見直す。バックグラウンドにいる間に更新が必要なければ監視を
onResume()
/onPause()
の間だけにする - 🐱の描画はできるだけ非同期で別スレッドで行う。実際にviewを触らないといけない部分だけUI Threadで。AOSPだと気軽にKotlinで書いたり外部ライブラリ導入したりできないからちょっと非同期処理面倒くさそう。
まぁEaster Egg ですもんね。作った人も100匹くらい集めるような使われ方するとも思ってなかったのかもしれない…
Android N Easter Egg (neko) のコード読んでわかったことまとめ
Android N のEaster Eggはねこあつめ的なアプリ。
コード読んでわかったことまとめます。実装観点とかFrameworkのコードの読み方とかはまたいつか別に書きたい。 以下、Easter eggのアプリ名は便宜上nekoとします。
nekoアプリの基本的な遊び方
- いつものように設定のAndroidバージョン連打で"N" のロゴが表示される画面に行く。Nアイコン連打でToastで🐱の絵文字が表示されればnekoが使えるモードON, 🚫の絵文字が表示されればnekoが使える
- nekoが使えるモードの時にNotificationのQuick Settings(Wi-FiとかBluetoothとかの簡易設定があるところ)の編集にneko用の"Empty dish" というアイコン(以下、🐱タイル)が追加されているのでドラッグしてQuick Settingsエリアに移動する
- "Empty dish" をタップするとお皿におけるFoodが選べます。Foodを置いて一定時間経つとNotificationで🐱が来たことが通知される
- 本家ねこあつめのように一度集めた🐱はいなくなったりしない
- Notificationをタップすると集めた🐱の一覧が見られる
Foodについて
- 選べるFoodの種類はBits(カリカリ) / Fish / Chicken / Treat(お菓子)の4種類
- ↑の四つのうち左のものほどすぐ🐱が来てくれる
- 来るまでのインターバルはそれぞれ 15 / 30 / 60 / 120 分
- ただし左のものほど以前来た🐱がまた来る確率が高い
- 新規🐱が来るのとインターバルの長さはバーター
🐱について
- 🐱の形はみんな一緒。コードから規定の確率に従って各部分を着色している
- 🐱の体の色は黒と白が一番確率が高い
- 次いで茶色とか灰色とか
- レアなところだと青、ピンク、紫、緑も居る
- 上記の色もdarkとlightがある
- 最強にレアなのは透明(α値が0)
- あとは首輪とか足の色とか色々決まっている
その他
- 🐱一覧画面で🐱を長押しするとその🐱を削除 or シェアできる
- シェアする場合は対象の🐱のbitmapを生成しファイルに吐き出してシェアしてくれる
- Notificationが表示されていない時に🐱一覧を見たい場合はQuick Settingsの🐱タイルを長押しするといつでも見られる*1
*1:しばらくこれに気がつかず id:operandoさんのアプリを愛用していました。
FragmentManager#getFragments()で取得するListの要素がnullになっていたのでFrameworkのソースコードを読んでみた
FragmentManager#getFragments() の挙動がなんか思っていたのと違ったので調べてみたメモ。
具体例を挙げると、Fragment A
がattachされている状態で、Fragment B
をFragmentTransaction#add() -> remove() した時に、
FragmentManager#getFragments() で得られるリストの要素数は1だと思ったけど、残念2でした〜〜という話。
0番目には予想どおり Fragment A
が、1番目にはnullが入っていた。removeしたって言ったじゃないか。解せぬ。
というわけでいつも通りFrameworkのコードを読んでいきます。まずはgetFragments() で何が返ってきているのか確認。
ArrayList<Fragment> mActive; @Override public List<Fragment> getFragments() { return mActive; }
この mActive
って名前のArrayListにactiveな状態のfragmentが管理されていそうなもんなのに、なんでactiveでなくなったfragmentを保持していたであろう要素がremoveされずにnullになっているのか?
activeじゃなくなる時のコードを見てみましょう。
void makeInactive(Fragment f) { if (f.mIndex < 0) { return; } if (DEBUG) Log.v(TAG, "Freeing fragment index " + f); mActive.set(f.mIndex, null); if (mAvailIndices == null) { mAvailIndices = new ArrayList<Integer>(); } mAvailIndices.add(f.mIndex); mHost.inactivateFragment(f.mWho); f.initState(); }
ここでnullがセットされています。
気になるのはその下、 mAvailIndices
にinactiveになったfragmentのindexがaddされているところですね。
mAvailIndices
はIntergerのArrayListで、
ArrayList<Integer> mAvailIndices;
使われるのはFragmentがactiveになった時。
void makeActive(Fragment f) { if (f.mIndex >= 0) { return; } if (mAvailIndices == null || mAvailIndices.size() <= 0) { if (mActive == null) { mActive = new ArrayList<Fragment>(); } f.setIndex(mActive.size(), mParent); mActive.add(f); } else { f.setIndex(mAvailIndices.remove(mAvailIndices.size()-1), mParent); mActive.set(f.mIndex, f); } if (DEBUG) Log.v(TAG, "Allocated fragment index " + f); }
mAvailIndices
Listの最後の要素をactiveにしたいfragmentのindexにセットしています。
つまり、 removeしたfragmentのindexを mAvailIndices
に退避しておいて、
次にaddなりしてactiveになったfragmentにそのindexを割り当てるということをしています。
例えば、 3つのFragmentがActiveな場合、
index | Fragment |
---|---|
0 | A |
1 | B |
2 | C |
ここで Fragment B
をremoveすると、index 1 がnullになって、mAvailIndices
にindex 1 が退避されます。
index | Fragment |
---|---|
0 | A |
1 | null |
2 | C |
この状態で Fragment D
をaddすると、退避されていたindex 1 が使用される。
index | Fragment |
---|---|
0 | A |
1 | D |
2 | C |
indexの効率化が目的だったのかなー。
AsyncTaskのStatusを理解したくてFrameworkのコードを読んだメモ
RX全盛期のいま、こんなことに需要があるのか…ということは気にしない。
AsyncTaskのライフサイクルのstatusがぼんやりとしか理解できずググっても出てこなかったので調べたメモ。
具体的な疑問としては、taskの実行が終わったあと、初期状態(PENDING
)に戻るのか?それともFINISH
のまま?という点。
APIにもこのように書いてあるんだけどなんとなく釈然としない。taskのlifetimeってどういうことなんだろう。
Each status will be set only once during the lifetime of a task.
それではFrameworkのコードを読んでいきます。 読んだコードは6.0のもの。
初期値
当然ながら初期値は PENDING
.
synchronizeよりコストの低いvolatileを使ってるんですね。へぇぇ。
private volatile Status mStatus = Status.PENDING;
タスクの実行
タスクの実行命令がくるとまずは状態チェック。
PENDING
以外は許容しません。
チェックが終わるとRUNNING
状態に遷移して、onPreExecute()
が呼ばれます。
@MainThread public final AsyncTask<Params, Progress, Result> executeOnExecutor(Executor exec, Params... params) { if (mStatus != Status.PENDING) { switch (mStatus) { case RUNNING: throw new IllegalStateException("Cannot execute task:" + " the task is already running."); case FINISHED: throw new IllegalStateException("Cannot execute task:" + " the task has already been executed " + "(a task can be executed only once)"); } } mStatus = Status.RUNNING; onPreExecute(); mWorker.mParams = params; exec.execute(mFuture); return this; }
タスクの完了・キャンセル
AsyncTaskは内部でhandlerを持っておき、タスクが完了もくしはキャンセルされた時に終了処理を実行するためのMessage(MESSAGE_POST_RESULT
)を投げます。
private static class InternalHandler extends Handler { public InternalHandler() { super(Looper.getMainLooper()); } @SuppressWarnings({"unchecked", "RawUseOfParameterizedType"}) @Override public void handleMessage(Message msg) { AsyncTaskResult<?> result = (AsyncTaskResult<?>) msg.obj; switch (msg.what) { case MESSAGE_POST_RESULT: // There is only one result result.mTask.finish(result.mData[0]); break; case MESSAGE_POST_PROGRESS: result.mTask.onProgressUpdate(result.mData); break; } } }
ここで呼ばれるfinish()
の中で状態が FINISHED
になります。
このあとはもう状態操作を行わないので、FINISH
のまま。
private void finish(Result result) { if (isCancelled()) { onCancelled(result); } else { onPostExecute(result); } mStatus = Status.FINISHED; }
ということでわかったのは
onPreExecute()
の直前にRUNNING
になって、onCancelled()
かonPostExecute()
が完了したらFINISHED
になる- statusは循環するわけではなく、一方通行。一度
FINISH
になったらそこから遷移はしない - メンバ変数として持っておいて何度もそのままタスクを使い回すのも無理そう
まぁActivityのonPause()とかでcancelするためにメンバで参照保持しないとなんですけどね。
executeする前に状態チェックして FINISH
だったらnewしなおしてやるということで。
最近読んだAsyncTaskのメモリリークの話も一緒にどうぞ。
meta-dataで数値だけのStringを渡したい
前回manifestPlaceholders最高!٩( 'ω' )و みたいな記事かいといてアレですが、ハマりどころがあったので記事に残しておきます。 某SDKを使うためにAndroidManifestのmeta-dataにidを記載する必要があり、ここにmanifestPlaceholdersを使っていました。
<meta-data android:name="hoge" android:value="${fuga}"/>
しかしこれだとなぜかnull扱い…
ただし、 .foo
を足すとなぜかfugaの中身も .foo
も取れます。
<meta-data android:name="hoge" android:value="${fuga}.foo"/>
何が起こっていたかというと、あくまで予想なのですが、
おそらくSDKの中身が ActivityInfoオブジェクトの metaData
に対して getString()
してるんじゃないかなぁと思います。
だから .foo
をつけると文字列になるので問題なくfugaの中身も取れたっぽい。
※予想コード
ActivityInfo info = getPackageManager().getActivityInfo(getComponentName(), PackageManager.GET_META_DATA); String fuga = info.metaData.getString("hoge");
型が違うものをgetしようとするとこんなエラーが出ます。
Bundle : Key hoge expected String but value was a java.lang.Integer. The default value <null> was returned.
自分でかいたコードなら getInt()
に変えれば解決するけれどSDKの中身なので値を渡す側でどうにかするしかない。
ちょっとググってみたらバックスラッシュを一つ入れれば何故か解決するらしい。
回答つけた人もこんなこと言っちゃってます。
I have no idea quite how this works, if I'm honest.
そんなわけでこれでうまく動きました٩( 'ω' )و
<meta-data android:name="hoge" android:value="\ ${fuga}"/>
Build typeによってアプリアイコンを分ける
はじめに
この記事は諸事情によりRibbonizerライブラリが使えない人たち向けに書いたものです。 Ribbonizerが使える人はそちらを使ったほうが良いです。
やりたいこと
Debug buildの時とRelease buildの時にapp idやapp nameを変えて一つの端末の中に両者を共存させていたのですが、 アイコンが同じだと分かりづらいのでアイコンも分けることに。
どうするか
Ribbonizerという素敵なライブラリがあります。これをつかえば2行ほどbuild.gradleに書き足すだけでやりたいこと達成です。
どうするか その2
以下、諸事情により↑が使えない人向けです。 自分の場合だと、ローカルでbuildする場合は何の問題もないのですが、 Circle CIでbuildするときにCircle CIの4G制限に引っかかってしまいdied unexpectedly…(´・_・`)
とりあえずライブラリをつかわず自前でどうにかする方向で考えます。 こちらの記事がとても参考になりました。
typeの縛りが激しいresValueと違って、placeholderはdrawableも扱えます。
まずはこんな感じでdefaultConfigに追記。
defaultConfig { manifestPlaceholders = [appIcon:"@drawable/your_app_icon"] }
debug版のみアイコンを変えたいのでbuildtypeがdebugの時はdebug用のアイコンを指定します。 (自分の場合はRibbonizerが生成したdebug用のアイコンをbuild/ディレクトリ下から拾ってリネームして指定しました笑)
buildTypes { debug { manifestPlaceholders = [appIcon:"@drawable/your_debug_app_icon"] } }
最後にManifestでこれを指定すればOK.
<application android:icon="${appIcon}"
この方法だとお手軽に動くのですが、debug用のアイコンリソースがapkパッケージの中に含まれてしまいます。 それが耐えられない人はやっぱりRibbonizerでどうにかする方法で頑張ってください。
何番目のRadioButtonがチェックされているかうっかり取得されてしまう話
RadioGroup#getCheckedRadioButtonId() が、チェックが入ったviewのidではなく、何番目にチェックが入っているかを取得できると勘違いして、実際その勘違い通りの振る舞いをするように見えるケースが存在した調査ログです。
結論から言ってしまうと、RadioButtonにidを振らないとView#generateViewId() が1から順に勝手に新規idを振ってしまい、それを返してしまうという話です。
現象
RadioButtonを3つ配置したRadioGroupが存在しており、そのうち1番目にチェックをつけた状態でRadioGroup#getCheckedRadioButtonId()が1を返すケースがある。 この振る舞いになる条件は、
- RadioButtonにidをふらない
- 初回起動時のみ(n回目に対象Activityを起動した際には3n-2が返ってくる)
具体的にはこんなレイアウト
<RadioGroup android:layout_width="match_parent" android:layout_height="wrap_content" android:id="@+id/radiogroup_sample"> <RadioButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/sample1"/> <RadioButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/sample2"/> <RadioButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/sample3"/> </RadioGroup>
何がおこったか
RadioButtonがviewのhierarchyに追加される時点で、idが存在するかチェックし 存在しない場合は新規に追加します。
RadioGroup.java
private class PassThroughHierarchyChangeListener implements ViewGroup.OnHierarchyChangeListener { private ViewGroup.OnHierarchyChangeListener mOnHierarchyChangeListener; /** * {@inheritDoc} */ public void onChildViewAdded(View parent, View child) { if (parent == RadioGroup.this && child instanceof RadioButton) { int id = child.getId(); // generates an id if it's missing if (id == View.NO_ID) { id = View.generateViewId(); child.setId(id); }
View.java
private static final AtomicInteger sNextGeneratedId = new AtomicInteger(1); public static int generateViewId() { for (;;) { final int result = sNextGeneratedId.get(); // aapt-generated IDs have the high byte nonzero; clamp to the range under that. int newValue = result + 1; if (newValue > 0x00FFFFFF) newValue = 1; // Roll over to 1, not 0. if (sNextGeneratedId.compareAndSet(result, newValue)) { return result; } } }
generateViewId() は1で初期化したAtomicIntegerから順にインクリメントした値を返すので、それぞれn番目がチェックされているかのように振舞っていたのです。(returnするのがnewValueではなくresultなので初回は1が返ってきます。) ただし、例えばActivityを再起動するなどして再度viewのhierarchyに追加されるときは1からではなく続きからインクリメントされるので、初回起動時のみn番目がチェックされているかのように見えていたというオチでした。
余談
何番目がチェックされているか返すmethodはRadioGroupを拡張したクラスでaddView() をoverrideしてやれば作れそう。 ただし、「何番目がチェックされているか」ではなく「何がチェックされているか」の方がどう考えても重要なのであまり使い道はないかもしれない。
public class CustomRadioGroup extends RadioGroup { private List<RadioButton> mChildren = new ArrayList<>(); public CustomRadioGroup(Context context) { super(context); } public CustomRadioGroup(Context context, AttributeSet attrs) { super(context, attrs); } @Override public void addView(View child, int index, ViewGroup.LayoutParams params) { if (child instanceof RadioButton) { mChildren.add((RadioButton) child); } super.addView(child, index, params); } public int getSelectedPosition() { int selectedViewId = getCheckedRadioButtonId(); if (selectedViewId == -1 || mChildren.isEmpty()) { return -1; } for (int i = 0; i < mChildren.size() -1; i++) { if (mChildren.get(i).getId() == selectedViewId) { return i + 1; } } return 0; } }