言いたいことはそれだけか

JavaとかAndroidとか調べたことをメモします。٩( 'ω' )و

Activity#getTitle()はActivity/Applicationのresourcesを使わない

このエントリは先日参加した「まったりAndroid Framework Code Reading #4」の成果です٩( 'ω’ )و

mandroidfcr.connpass.com

知りたかったこと

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を設定しているから。

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,
// ★最後から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をタップした時に何か便利な挙動をするのを目指してみます。

具体的には下記の二つどちらかできたら便利だなぁと思って調べてみました。
どちらもできませんでした:;(∩´﹏`∩);:
[↑できる方法があったので追記してます]

  1. USB debuggingのON/OFF
  2. 「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のアプリは設定できないようになっています。

developer.android.com

この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のアプリでは設定できません。

developer.android.com

というわけでどちらも3rd partyのアプリでは書き換えられないというお話でした(´・_・`)


[2016.10.19 追記]
GoogleのNick Butcherが同じことをやっていて、彼はadb commandで手動でpermissionをgrantする方法で解決していました。
この方法で解決することは手元でも確認済みです。すげえ!٩( 'ω' )و

github.com

Quick SettingsにCustom Tileを追加する

Android N からQuick Settingsに3rd Partyが好きな物を置けるようになりました。 これを使うと簡単にデバッグツール作れるのでは?と思ってやってみました٩( 'ω' )و

f:id:muumuumuumuu:20161006203754p:plain 右下のやつ

やりたかったこと

  1. Quick SettingsにTileを追加する
  2. 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しているという仕組みでした。

Cross Reference: /frameworks/base/packages/SystemUI/src/com/android/systemui/qs/customize/TileQueryHelper.java

   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は困った。

  1. ?一覧のダイアログでスクロールするとき
  2. Foodを選択した後、Dialogが消えず端末全体がほぼ操作不能になる

何が悪かったか

1. ?一覧のスクロールが重い原因

まず、アプリ全体がどうやって集まっている?を保存しているか。 SharedPreferenceに seedname を保存しています。

▼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さんのアプリを愛用していました。

hack-it-iron.hatenablog.com

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のメモリリークの話も一緒にどうぞ。

medium.com