Activity#getTitle()はActivity/Applicationのresourcesを使わない
このエントリは先日参加した「まったりAndroid Framework Code Reading #4」の成果です٩( 'ω’ )و
知りたかったこと
AppCompatActivity#setSupportActionBar()した時に、Frameworkのバグと思しき挙動を見つけてしまいました。
setするToolBarにtitleを設定していない場合、ActionBarのタイトルにアプリで設定したlocaleではなくdevice defaultのlocaleが適用されてしまうのです:;(∩´﹏`∩);:
回避策としてToolBarにapplicationのcontextからとってきたstringをsetすればいいんですけど、せっかくなので Frameworkがタイトルに表示するstringをどのように取得するのか知りたくてFrameworkのコードを読んでみました٩( 'ω’ )و
わかったこと
- setSupportActionBar()でsetするToolbarにtitleが設定されていればそれを、設定されていなかったらAppCompatDelegateImplV7のContextからgetTitle()したものが使われる
- AppCompatDelegateImplV7のContextはAppCompatActivity自身
- しかしgetTitle()の時に使われるresourcesはAppCompatActivity自身のものではなく、ActivityThreadがActivityをattachする時に使うresources
- 上記resourcesはApplicationPackageManager#getResourcesForApplication() で取得したもので、これがdevice defaultのlocaleになっている
- device defaultなlocaleになる理由は、引数のoverride configurationにapplicationのresourcesに設定されたconfigurationではなくnullを設定しているから。
1844 Resources getTopLevelResources(String resDir, String[] splitResDirs, String[] overlayDirs, 1845 String[] libDirs, int displayId, LoadedApk pkgInfo) { 1846 return mResourcesManager.getResources(null, resDir, splitResDirs, overlayDirs, libDirs, // ★最後から3番目の引数がoverride configurationだが、ここでnullを指定しているのでdevice defaultの物が設定される 1847 displayId, null, pkgInfo.getCompatibilityInfo(), pkgInfo.getClassLoader()); 1848 }
ざっくりまとめると以上ですが、コードを読んだメモを置いておきます。
ToolBarについて
AppCompatActivity.java
public void setSupportActionBar(@Nullable Toolbar toolbar) { getDelegate().setSupportActionBar(toolbar); }
AppCompatDelegateImplV7.java
189 @Override 190 public void setSupportActionBar(Toolbar toolbar) { 212 if (toolbar != null) { 213 final ToolbarActionBar tbab = new ToolbarActionBar(toolbar, 214 ((Activity) mContext).getTitle(), mAppCompatWindowCallback); 215 mActionBar = tbab;
ToolbarActionBar.java
73 public ToolbarActionBar(Toolbar toolbar, CharSequence title, Window.Callback callback) { 74 mDecorToolbar = new ToolbarWidgetWrapper(toolbar, false); 75 mWindowCallback = new ToolbarCallbackWrapper(callback); 76 mDecorToolbar.setWindowCallback(mWindowCallback); 77 toolbar.setOnMenuItemClickListener(mMenuClicker); 78 mDecorToolbar.setWindowTitle(title); 79 }
ToolbarWidgetWrapper.java
90 public ToolbarWidgetWrapper(Toolbar toolbar, boolean style) { 91 this(toolbar, style, R.string.abc_action_bar_up_description, 92 R.drawable.abc_ic_ab_back_material); 93 }
95 public ToolbarWidgetWrapper(Toolbar toolbar, boolean style, 96 int defaultNavigationContentDescription, int defaultNavigationIcon) { 97 mToolbar = toolbar; 98 mTitle = toolbar.getTitle(); 99 mSubtitle = toolbar.getSubtitle(); 100 mTitleSet = mTitle != null; 105 if (style) { 106 final CharSequence title = a.getText(R.styleable.ActionBar_title); 107 if (!TextUtils.isEmpty(title)) { 108 setTitle(title); 109 }
237 @Override 238 public void setWindowTitle(CharSequence title) { 239 // "Real" title always trumps window title. 240 if (!mTitleSet) { 241 setTitleInt(title); 242 } 243 }
AppCompatDelegateImplBase.java
67 AppCompatDelegateImplBase(Context context, Window window, AppCompatCallback callback) { 68 mContext = context;
AppCompatDelegate.java
185 private static AppCompatDelegate create(Context context, Window window, 186 AppCompatCallback callback) { 187 final int sdk = Build.VERSION.SDK_INT; 188 if (BuildCompat.isAtLeastN()) { 189 return new AppCompatDelegateImplN(context, window, callback); 190 } else if (sdk >= 23) { 191 return new AppCompatDelegateImplV23(context, window, callback); 192 } else if (sdk >= 14) { 193 return new AppCompatDelegateImplV14(context, window, callback); 194 } else if (sdk >= 11) { 195 return new AppCompatDelegateImplV11(context, window, callback); 196 } else { 197 return new AppCompatDelegateImplV7(context, window, callback); 198 } 199 }
AppCompatActivity.java
509 public AppCompatDelegate getDelegate() { 510 if (mDelegate == null) { 511 mDelegate = AppCompatDelegate.create(this, this); 512 } 513 return mDelegate; 514 }
getTitle()で取得するStringについて
Activity.java
5635 public final CharSequence getTitle() { 5636 return mTitle; 5637 }
6593 final void attach(Context context, ActivityThread aThread, 6594 Instrumentation instr, IBinder token, int ident, 6595 Application application, Intent intent, ActivityInfo info, 6596 CharSequence title, Activity parent, String id, 6597 NonConfigurationInstances lastNonConfigurationInstances, 6598 Configuration config, String referrer, IVoiceInteractor voiceInteractor, 6599 Window window) { 6626 mTitle = title;
ActivityThread.java
2514 private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) { 2567 CharSequence title = r.activityInfo.loadLabel(appContext.getPackageManager()); 2580 activity.attach(appContext, this, getInstrumentation(), r.token, 2581 r.ident, app, r.intent, r.activityInfo, title, r.parent, 2582 r.embeddedID, r.lastNonConfigurationInstances, config, 2583 r.referrer, r.voiceInteractor, window);
ComponentInfo.java
92 @Override public CharSequence loadLabel(PackageManager pm) { 93 if (nonLocalizedLabel != null) { 94 return nonLocalizedLabel; 95 } 96 ApplicationInfo ai = applicationInfo; 97 CharSequence label; 98 if (labelRes != 0) { 99 label = pm.getText(packageName, labelRes, ai); 100 if (label != null) { 101 return label; 102 } 103 } 104 if (ai.nonLocalizedLabel != null) { 105 return ai.nonLocalizedLabel; 106 } 107 if (ai.labelRes != 0) { 108 label = pm.getText(packageName, ai.labelRes, ai); 109 if (label != null) { 110 return label; 111 } 112 } 113 return name; 114 }
ApplicationPackageManager.java
1491 @Override 1492 public CharSequence getText(String packageName, @StringRes int resid, 1493 ApplicationInfo appInfo) { 1494 ResourceName name = new ResourceName(packageName, resid); 1495 CharSequence text = getCachedString(name); 1496 if (text != null) { 1497 return text; 1498 } 1499 if (appInfo == null) { 1500 try { 1501 appInfo = getApplicationInfo(packageName, sDefaultFlags); 1502 } catch (NameNotFoundException e) { 1503 return null; 1504 } 1505 } 1506 try { 1507 Resources r = getResourcesForApplication(appInfo); 1508 text = r.getText(resid); 1509 putCachedString(name, text); 1510 return text;
1241 @Override 1242 public Resources getResourcesForApplication(@NonNull ApplicationInfo app) 1243 throws NameNotFoundException { 1244 if (app.packageName.equals("system")) { 1245 return mContext.mMainThread.getSystemContext().getResources(); 1246 } 1247 final boolean sameUid = (app.uid == Process.myUid()); 1248 try { 1249 return mContext.mMainThread.getTopLevelResources( 1250 sameUid ? app.sourceDir : app.publicSourceDir, 1251 sameUid ? app.splitSourceDirs : app.splitPublicSourceDirs, 1252 app.resourceDirs, app.sharedLibraryFiles, Display.DEFAULT_DISPLAY, 1253 mContext.mPackageInfo); 1254 } catch (Resources.NotFoundException cause) { 1255 final NameNotFoundException ex = 1256 new NameNotFoundException("Unable to open " + app.publicSourceDir); 1257 ex.initCause(cause); 1258 throw ex; 1259 } 1260 }
ActivityThread.java
1844 Resources getTopLevelResources(String resDir, String[] splitResDirs, String[] overlayDirs, 1845 String[] libDirs, int displayId, LoadedApk pkgInfo) { 1846 return mResourcesManager.getResources(null, resDir, splitResDirs, overlayDirs, libDirs, 1847 displayId, null, pkgInfo.getCompatibilityInfo(), pkgInfo.getClassLoader()); 1848 }
ResourcesManager.java
625 public @NonNull Resources getResources(@Nullable IBinder activityToken, 626 @Nullable String resDir, 627 @Nullable String[] splitResDirs, 628 @Nullable String[] overlayDirs, 629 @Nullable String[] libDirs, 630 int displayId, 631 @Nullable Configuration overrideConfig, 632 @NonNull CompatibilityInfo compatInfo, 633 @Nullable ClassLoader classLoader) { 634 try { 635 Trace.traceBegin(Trace.TRACE_TAG_RESOURCES, "ResourcesManager#getResources"); 636 final ResourcesKey key = new ResourcesKey( 637 resDir, 638 splitResDirs, 639 overlayDirs, 640 libDirs, 641 displayId, 642 overrideConfig != null ? new Configuration(overrideConfig) : null, // Copy 643 compatInfo); 644 classLoader = classLoader != null ? classLoader : ClassLoader.getSystemClassLoader(); 645 return getOrCreateResources(activityToken, key, classLoader); 646 } finally { 647 Trace.traceEnd(Trace.TRACE_TAG_RESOURCES); 648 } 649 }
517 private @NonNull Resources getOrCreateResources(@Nullable IBinder activityToken, 518 @NonNull ResourcesKey key, @NonNull ClassLoader classLoader) { 519 synchronized (this) { 553 } else { 554 // Clean up any dead references so they don't pile up. 555 ArrayUtils.unstableRemoveIf(mResourceReferences, sEmptyReferencePredicate); 556 557 // Not tied to an Activity, find a shared Resources that has the right ResourcesImpl 558 ResourcesImpl resourcesImpl = findResourcesImplForKeyLocked(key); 559 if (resourcesImpl != null) { 560 if (DEBUG) { 561 Slog.d(TAG, "- using existing impl=" + resourcesImpl); 562 } 563 return getOrCreateResourcesLocked(classLoader, resourcesImpl); 564 } 565 566 // We will create the ResourcesImpl object outside of holding this lock. 567 } 568 } 569 570 // If we're here, we didn't find a suitable ResourcesImpl to use, so create one now. 571 ResourcesImpl resourcesImpl = createResourcesImpl(key);
開発者オプションメニューの値は3rd partyのアプリから書き換えるのが難しい
[2016.10.19 追記しました]
前回の続きです。
Quick Settings にTileを表示するところまで実装したので、次はそのTileをタップした時に何か便利な挙動をするのを目指してみます。
具体的には下記の二つどちらかできたら便利だなぁと思って調べてみました。
どちらもできませんでした:;(∩´﹏`∩);:
[↑できる方法があったので追記してます]
- USB debuggingのON/OFF
- 「Activityを保持しない」の設定のON/OFF
それではそれぞれなぜダメだったのか、調べた結果を書き残します。
USB debuggingのON/OFF
まず、開発者オプションの画面のコードを追っていきます。
それぞれの項目のlabelのstring resourceとかから適当にOpen Grokを検索すると、 DevelopmentSettings.java
にたどり着きます。
で、ここのON/OFFをどうやってとっているかというと、こんな感じで Settings.Global.ADB_ENABLED
の値を見ています。
642 private void updateAllOptions() { 643 final Context context = getActivity(); 644 final ContentResolver cr = context.getContentResolver(); (省略) 646 updateSwitchPreference(mEnableAdb, Settings.Global.getInt(cr, 647 Settings.Global.ADB_ENABLED, 0) != 0);
長くなるので省略しますが、この Settings.Global.getInt()
は content://settings/global
のcontent providerから値を取得しようとします。
1428 public static final String AUTHORITY = "settings"; 6524 public static final class Global extends NameValueTable { 6525 /** 6526 * The content:// style URL for global secure settings items. Not public. 6527 */ 6528 public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/global");
SettingsProvider.java
の中で、getの方は問題ないのですが、updateの方はpermission checkが入っています。
754 private Setting getGlobalSetting(String name) { 755 if (DEBUG) { 756 Slog.v(LOG_TAG, "getGlobalSetting(" + name + ")"); 757 } 758 759 // Get the value. 760 synchronized (mLock) { 761 return mSettingsRegistry.getSettingLocked(SETTINGS_TYPE_GLOBAL, 762 UserHandle.USER_SYSTEM, name); 763 } 764 } 766 private boolean updateGlobalSetting(String name, String value, int requestingUserId, 767 boolean forceNotify) { 768 if (DEBUG) { 769 Slog.v(LOG_TAG, "updateGlobalSetting(" + name + ", " + value + ")"); 770 } 771 return mutateGlobalSetting(name, value, requestingUserId, MUTATION_OPERATION_UPDATE, 772 forceNotify); 773 } 792 private boolean mutateGlobalSetting(String name, String value, int requestingUserId, 793 int operation, boolean forceNotify) { 794 // Make sure the caller can change the settings - treated as secure. 795 enforceWritePermission(Manifest.permission.WRITE_SECURE_SETTINGS);
書き込みを行うにはWRITE_SECURE_SETTINGS
が必要なのですが、こちらは残念ながら3rd partyのアプリは設定できないようになっています。
このpermissionがない場合、Security Exceptionで落ちます。。。
1305 private void enforceWritePermission(String permission) { 1306 if (getContext().checkCallingOrSelfPermission(permission) 1307 != PackageManager.PERMISSION_GRANTED) { 1308 throw new SecurityException("Permission denial: writing to settings requires:" 1309 + permission); 1310 } 1311 }
「Activityを保持しない」の設定のON/OFF
USB debuggingと同様にDevelopmentSettings.java
を見ていきましょう。
「Activityを保持しない」のpreference keyは IMMEDIATELY_DESTROY_ACTIVITIES_KEY
です。ちょっとアグレッシブな名前です。
読み込みの方はUSB debuggingと同様に Settings.Global
に定義してある値を読みに行きます。
1578 private void updateImmediatelyDestroyActivitiesOptions() { 1579 updateSwitchPreference(mImmediatelyDestroyActivities, Settings.Global.getInt( 1580 getActivity().getContentResolver(), Settings.Global.ALWAYS_FINISH_ACTIVITIES, 0) != 0); 1581 }
書き込みの方はちょっと挙動が変わります。
1570 private void writeImmediatelyDestroyActivitiesOptions() { 1571 try { 1572 ActivityManagerNative.getDefault().setAlwaysFinish( 1573 mImmediatelyDestroyActivities.isChecked()); 1574 } catch (RemoteException ex) { 1575 } 1576 }
こんな感じでActivityManagerNative
を操作しています。ActivityManagerNative
自体は@hideなAPIなので残念ながら3rd partyのアプリでは使えません。
ActivityManagerService経由で操作する方法もありますが、こちらを経由するにしてもpermissionが必要です。
11940 @Override 11941 public void setAlwaysFinish(boolean enabled) { 11942 enforceCallingPermission(android.Manifest.permission.SET_ALWAYS_FINISH, 11943 "setAlwaysFinish()"); 11944 11945 long ident = Binder.clearCallingIdentity(); 11946 try { 11947 Settings.Global.putInt( 11948 mContext.getContentResolver(), 11949 Settings.Global.ALWAYS_FINISH_ACTIVITIES, enabled ? 1 : 0); 11950 11951 synchronized (this) { 11952 mAlwaysFinishActivities = enabled; 11953 } 11954 } finally { 11955 Binder.restoreCallingIdentity(ident); 11956 } 11957 }
SET_ALWAYS_FINISH
のpermissionもWRITE_SECURE_SETTINGS
と同様に3rd partyのアプリでは設定できません。
というわけでどちらも3rd partyのアプリでは書き換えられないというお話でした(´・_・`)
[2016.10.19 追記]
GoogleのNick Butcherが同じことをやっていて、彼はadb commandで手動でpermissionをgrantする方法で解決していました。
この方法で解決することは手元でも確認済みです。すげえ!٩( 'ω' )و
Quick SettingsにCustom Tileを追加する
Android N からQuick Settingsに3rd Partyが好きな物を置けるようになりました。 これを使うと簡単にデバッグツール作れるのでは?と思ってやってみました٩( 'ω' )و
右下のやつ
やりたかったこと
- Quick SettingsにTileを追加する
- Tileをタップした時に何か便利な挙動をする
長くなりそうなので、今回は1のみ扱います。
Quick SettingsにTileを追加する
TileServiceクラスを拡張したサービスを作ったら瞬殺です。 まずはAndroidManifestにサービスを追加してpermissionとintent filterを設定しましょう
<service android:name=".DebugTileService" android:label="@string/app_name" android:icon="@drawable/ic_star_black_24dp" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE" > <intent-filter> <action android:name="android.service.quicksettings.action.QS_TILE" /> </intent-filter> </service>
あとはこのサービス内でclick eventのハンドリングをしましょう。
class DebugTileService : TileService() { override fun onClick() { super.onClick() when (qsTile.state) { Tile.STATE_ACTIVE -> { // do something qsTile.state = Tile.STATE_INACTIVE } Tile.STATE_INACTIVE -> { // do something qsTile.state = Tile.STATE_ACTIVE } } qsTile.updateTile() }
注意しなければならないのは、 Tile#setState()
しただけでは表示は切り替わらず、
Tile#updateTile()
をコールしてやる必要があります。
Kotlinで書くとproperty accessなのでちょっと意味がわかりにくいかもですが お察しください。
Quick SettingsにTileが追加できるようになる仕組み
せっかくなので上で登録したサービスがどのような仕組みでQuick Settingsに追加できるようになるのかFrameworkのコードを読んでみました。
まず、Quick Settingsのviewが QSCustomizer
というLinearLayoutを拡張したクラスを持っています。
(何をQuick Settingsに置くか選べる全画面のview部分です)
このviewの表示時にQSCustomizer#show() がコールされますが、この時 TileQueryHelper
クラスがnewされます。
TileQueryHelper
のコンストラクタで呼ばれる addSystemTiles()
の中で AsyncTask
の拡張である QueryTilesTask
をHandlerにpostします。そのタスクのbackground処理の中でマニフェストにintent filter(TileService.ACTION_QS_TILE
) を登録してあるサービス一覧を撮ってきてtileにaddしているという仕組みでした。
140 private class QueryTilesTask extends 141 AsyncTask<Collection<QSTile<?>>, Void, Collection<TileInfo>> { 142 @Override 143 protected Collection<TileInfo> doInBackground(Collection<QSTile<?>>... params) { 144 List<TileInfo> tiles = new ArrayList<>(); 145 PackageManager pm = mContext.getPackageManager(); 146 List<ResolveInfo> services = pm.queryIntentServicesAsUser( 147 new Intent(TileService.ACTION_QS_TILE), 0, ActivityManager.getCurrentUser()); 148 for (ResolveInfo info : services) { 149 String packageName = info.serviceInfo.packageName; 150 ComponentName componentName = new ComponentName(packageName, info.serviceInfo.name); 151 final CharSequence appLabel = info.serviceInfo.applicationInfo.loadLabel(pm); 152 String spec = CustomTile.toSpec(componentName); 153 State state = getState(params[0], spec); 154 if (state != null) { 155 addTile(spec, appLabel, state, false); 156 continue; 157 }
queryIntentServicesAsUser()
、便利そうですが @hideなAPIですね。残念。
それにしてもAsyncTaskとかMessageとHandlerの組み合わせで非同期処理を行うパターン、AndroidのFrameworkでよく見かけます。
個人的にこのパターン結構好きです。
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のメモリリークの話も一緒にどうぞ。