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

KotlinとかAndroidとかが好きです。調べたことをメモします。٩( 'ω' )و

夕飯のカレーとコミットメントの話

先に言っておきますが、ただのポエムです。

 

エンジニアとして働いていると、「hoge機能を作りたいんだけど、今月中でできますか?」という質問をよくされます。ここでいうhogeがどれくらいしっかり固まっているかで話は変わってきます。

例えば、「今晩カレー食べたい!」というリクエストが来たとします。ここでいうカレーが

 

1. レトルトカレーを温めてご飯にかけるだけ

2. 市販のカレールーを使ってカレーをつくる

3. スパイスから拘ってインドカレーをつくる

4. おうちカレーじゃなくてカレー屋さんに行きたい

 

などなど無限に選択肢があります。

カレー食べたいと言い出した人が上のうちどれを想定しているのかによって、当然工数が変わってきます。困ったことに、カレー食べたいと言い出した人がどんなカレーを食べたいかわかっていないことがよくあります。

もしもカレーコンサルタントとして働いていて、それでお金を頂いているのであれば、手厚くヒアリングしましょう。でもそうじゃない場合、どうするのがいいのかなぁとよく考えます。

たとえば、上記4つの選択肢にかかる大体の工数をそれぞれ伝えてあげるのも一つの手段ですが、実際には1のレトルトカレーの選択肢でも、コンビニで買えるのか、Amazon Nowでポチってすぐ手に入るのか、Primeで1日かかるのか、それとも地方まで出かけないと手に入らないご当地カレーなのか様々です。

上のパラグラフのような話を長々としてしまっても、相談した人は困惑を浮かべるでしょう。エンジニアって面倒くさいな…と心の声が聞こえてきます。相談してきた人はできるかできないかの2択の答えを求めているからです。

かと言って適当に「ハイハイできます!」というのも違うなぁと思います。コミットメントした以上は約束を守る責任があるからです。(ここでいうコミットメントは、意識の高い人がよく使う「フルコミット」的な使い方ではなくて、「完遂すると約束すること」として使っています*1

付き合いが長い相手だと、「カレー食べたい!」って言われて、普通にお家カレーなんだろうなと予想がつくので「いいですね。今日のお夕飯はカレーにしますね」と言えます(=今晩の夕飯までにカレーを作るとコミットメントすることができる)が、ソフトウェア開発は斜め上のカレーをリクエストされる可能性が割と高い気がしています。

 

最近は一番可能性が高そうなケースを狙って、そこを一つの基準としておくのがいいのかなぁという気がしています。「どんなカレーを食べたいかによりますが、一旦週末目標で進めましょう。どんなカレーか具体的に決まったらもう一度話し合いましょうね。」って感じで。

何も決まってない以上、具体的なスケジュールを立てるのはもはや困難なので、相手の人を納得というか安心させてあげるだけでいいのかなという感じです。相談してきた人との信頼貯金を貯めていくというか、すぐに相談に来てもらえる関係性を作る点で有効かなと思っています。

 

「カレー」という文字を打ちすぎてカレー食べたくなってきたのでやめます。

本日のポエムは以上です。

 

 

*1:私がアンクルボブ信者なので。Clean Coderを読むとコミットメントの話題が出てきますね。

SnackbarでCustom Content を表示する

Support library revision25.1.0からSnackBarにCustom Contentを表示できるようになったらしいので試してみました٩( 'ω' )و

今回の変更概要

さて、release noteをよく読んでみましょう。

Snackbar has been refactored to allow apps to display custom content. BaseTransientBottomBar is the new base class that exposes the general sliding and animations behavior.

custom contentを表示するためにリファクタしたよって言ってますね。BaseTransientBottomBarが新しい基底クラスになるよって言ってます。誰よそれって感じなのでリファレンスやコードを読んでみましょう。

BaseTransientBottomBarって何よ

まずはSnackbarのクラス定義。確かにBaseTransientBottomBarをextendsしています。

public final class Snackbar extends BaseTransientBottomBar<Snackbar> {

このBaseTransientBottomBarが何をしてくれるクラスなのか、リファレンスを読んでみましょう。 主にやってくれそうなのは、この二つ。

  • 画面下部から現れるViewの表示制御
  • 表示・非表示のタイミングを取得するためのCallbackの提供

もともとSnackbarがになっていた機能の一部ですね。

Snackbarがどう変わったか

これらを踏まえて今度はSnackbarのコードを見てみましょう*1。Viewの表示制御をBaseTransientBottomBarへ移したからか、300行ほどの随分とスッキリしたクラスになりました。(以前は確か850行ほどありました。)
中身を読むと分かりますが、このクラスでやっていることは二つ。

  • Snackbarのコンテンツ制御
  • 表示・非表示のタイミングを取得するためのCallbackの提供

この通り、Viewの表示制御やアニメーションなどはやっていません。(Snackbar#make()の第3引数でdurationをとるので、ある意味ここだけアニメーションと言えなくはない。)

このようにコンテンツ制御というかSnackbar独自のviewの制御に特化したクラスになっています。 さらに言えばSnackbarのカスタムレイアウトはSnackbarContentLayout としてhideな別クラスに切り出されています。 なのでSnackbar#setText()の中身も結構無理矢理な感じ。

    /**
     * Update the text in this {@link Snackbar}.
     *
     * @param message The new text for this {@link BaseTransientBottomBar}.
     */
    @NonNull
    public Snackbar setText(@NonNull CharSequence message) {
        final SnackbarContentLayout contentLayout = (SnackbarContentLayout) mView.getChildAt(0);
        final TextView tv = contentLayout.getMessageView();
        tv.setText(message);
        return this;
    }

このように、VIewの表示制御とコンテンツ制御を切り離して実装することができるようになりました。 ということは、独自デザインのViewをSnackbarの用に表示・非表示するのが簡単にできるようになったってことです。

試してみた

今回は独自デザインの簡単なサンプルとして背景色が黒じゃないCustomSnackbarを作っていきます٩( 'ω' )و

まずはsupport libraryのrevisionを25.0.1にあげます。対象のlibraryをinstallしてbuild.gradleをアップデートします。

    compile 'com.android.support:design:25.1.0'

それではCustomSnackbarクラスを作っていきます。 BaseTransientBottomBarをextendsしてやって、

public class CustomSnackBar extends BaseTransientBottomBar<CustomSnackBar>  {

Snackbarを参考に make() メソッドを実装します。findSuitableParent() の中身はSnackbarのそれのコピペです。 ちなみに、BaseTransientBottomBarのコンストラクタはprotectedなので、自前でコンストラクタを作ってやらないと"There is no default constructor available"って怒られます。

  @NonNull
  public static CustomSnackBar make(@NonNull View view, @NonNull CharSequence text,
                              int duration) {
    LayoutInflater inflater = LayoutInflater.from(view.getContext());
    View content = inflater.inflate(R.layout.custom_snackbar, findSuitableParent(view), false);
    ((TextView) content.findViewById(R.id.custom_snackbar_textview)).setText(text);
    CustomSnackBar customSnackBar = new CustomSnackBar(findSuitableParent(view), content, new ViewCallBack());
    return customSnackBar;
  }

  private CustomSnackBar(ViewGroup parent, View content, ContentViewCallback contentViewCallback) {
    super(parent, content, contentViewCallback);
  }

ここでinflateするレイアウトですが、下記のような適当な背景色をつけたTextViewを持つLinearLayoutを読み込みます。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="horizontal"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:layout_margin="10dp">
    <TextView android:layout_width="match_parent" android:layout_height="wrap_content"
              android:id="@+id/custom_snackbar_textview"
              android:background="@color/colorAccent"
              android:textColor="@android:color/white"/>

</LinearLayout>

show()メソッドはBaseTransientBottomBarクラスの方ですでに実装されているので、たったこれだけで使うときは普通のSnackbarの様に使えます。

CustomSnackBar.make(view, "This is custom snackbar sample", Snackbar.LENGTH_LONG)
        .show();

こんな感じで表示されます٩( 'ω' )و

f:id:muumuumuumuu:20161225174903p:plain

*1:Web上にソースが見つからずリンク貼れませんでした…。手元のlibraryをデコンパイルしてみています。

Android/Kotlin関連の情報収集とか。

最近Android周りの情報をどうやって追っているか?について質問されることが何度かあったのでまとめました。自分向けに2016年末時点でのスナップショットも兼ねて。

Twitter

知り合いのエンジニアさんや勉強会であった方とTwitterで繋がる機会が多いせいか、Android関連の情報が流れてくるのが多いのは自分にとって圧倒的にTwitterです。みなさんいつも有用な情報をつぶやいてくれたりニュースをretweetしてくれたり本当に助かっています。ありがとうございます。
また、Google公式アカウントも幾つかフォローしています。(@AndroidDevとか。)
Googleの有名なエンジニアの方とかSquareのJakeさんとかフォローしてる方も多いと思いますが、私は海外のエンジニアさんはフォローしていません。そこまで追い始めるとキリがなさそうなので。あと話題になるtweetは誰かがretweetしてくれるからどのみち目にとまるからいいかなと思ったりします。
blogの中の人や更新情報用アカウントをフォローしておりRSS代わりのような用途にも使っていたりします。

メルマガ

Android WeeklyKotlin Weeklyをsubscribeしています。毎週月曜に一週間のトピックをまとめてくれるので便利です。英語のblogやVideoの情報はだいたいここから入ってきています。

Podcast

Podcastdex.fmを聞いています。Androidの技術的な話だけでなくチーム開発とかサービスグロースの観点の話題も出てきて非常に参考になります。
AndroidPodcastと言えばFragmentedなども有名かと思いますが、こちらは全然追えていません。

Advent Calendar

今の時期だとAdvent Calendarも楽しく読んでいます。今年はAndroidのAdvent Calendarが3つもできてていいですね!
Android Advent Calendar 2016, Androidその2 Advent Calendar 2016, Android その3 Advent Calendar 2016

社内Slack

社内はTwitterではなくFacebook文化なので社内のメンバーとTwitterで全く繋がっていません。そのためTwitterで情報を共有することはないのですが、代わりに社内Slackに専用channelを作ってそこで情報を流しています。
うちは分報文化*1が根付いているのですが、そのノリで#times_androidや#times_kotlinを作って興味がある人たちがそこを覗いて情報を流したりそこにコメントして議論に発展したりします。AndroidとKotlinを分けている理由は、サーバサイドの一部でKotlinを採用していおり、Androidを書かないエンジニアもKotlinの情報を必要としているからです。

そういえばpublicなAndroidのslack channelに怖くてjoinできていません。あそこって誰でも勝手に入っていいのかわからない…

というわけで2016年はこんな感じでした。来年の年末に振り返ってみると何か変わってるかなぁ。

*1:こちらの記事を見て導入しました。分報はいいぞ。ただし気をつけないと時間泥棒にもなります。 c16e.com

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匹くらい集めるような使われ方するとも思ってなかったのかもしれない…