2012/06/30

Android:なぜShareActionProviderの共有対象Activityが入れ替わるのか?


前回の投稿で少し触れた下記について原因を探ります。
共有アイテムの中に同じパッケージ名をもつアイテムが複数ある場合は注意が必要です。
例えば、"android.intent.action.VIEW"には下記2つのActivityが反応します。
 - com.android.development/com.android.development.AppHwPref
 - com.android.development/com.android.development.ProcessInfo
"よく使う共有アイテム"にAppHwPrefが登録されていると仮定します。
あなたはこれを選択してAppHwPrefを起動します。これは正しく起動できるはずです。
しかし、もう一度これを選択した場合は"ProcessInfo"が起動されます。
ユーザの意図に反する動作のように見えますが、、、
ShareActionProviderは"よく使う共有アイテム"に属するコンポーネントのパッケージが
複数ある場合は、選択する都度入れ替わる(スイッチ)するような動作になっています。

この現象、不具合なのかは不明ですがちょっと気持ちわるい動作ですね。
# なにか、こうしないといけない深い理由があるのかな?

なお、本稿では"よく使う共有アイテム"を以降"デフォルトアクティビティ"と表記します。
# デフォルトアクティビティはコードの変数名から命名しました。

●原因箇所

結論から言うと、Intentに反応できるActivityリストのソート部分が原因です。

●共有アイテムのソート

共有アイテムリストに同じパッケージ名が存在する場合、後に出現するアイテムが上
(index0寄り)にくるようソートされます。
・ソート前
 [0] com.android.development/com.android.development.AppHwPref
 [1] com.android.development/com.android.development.ProcessInfo

・ソート後
 [0] com.android.development/com.android.development.ProcessInfo
 [1] com.android.development/com.android.development.AppHwPref

共有アイテムリストでは、リスト上位のアイテムほど優先度が高く扱われます。
つまり、パッケージ名が同じアイテムは優先度の低いアイテムを優先度が高くなるように
ソートされます。

●デフォルトアクティビティの選定

デフォルトアクティビティにはActivityリストの先頭が指定されます。
つまり、下記ActivityリストではAppHwPrefがデフォルトアクティビティとなります。
 [0] com.android.development/com.android.development.AppHwPref    // 重要度:高
 [1] com.android.development/com.android.development.ProcessInfo  // 重要度:低

しかし、これがソートされると、、、
 [0] com.android.development/com.android.development.ProcessInfo  // 重要度:低
 [1] com.android.development/com.android.development.AppHwPref    // 重要度:高

デフォルトアクティビティにはProcessInfoが選ばれます。
ソートをする度に先頭が入れ替わるので、デフォルトアクティビティもスイッチするよう
な動作になってしまいます。

●ソートされるタイミング

ソートは共有アイテム選択履歴が保存されるタイミングで行われます。
つまり、デフォルトアクティビティを選択した直後にソートが開始されます。

以下もう少し深く。。。
原因箇所のコードを追ってみます。

●デフォルトアクティビティを表示しているのは誰?

デフォルトアクティビティのUIはandroid.widget.ActivityChooserViewです。
何を表示するかを決定するロジックはandroid.widget.ActivityChooserModelの責務です。

デフォルトアクティビティを取得するスタックトレースは下記
ActivityChooserModel.getDefaultActivity()
ActivityChooserView$ActivityChooserViewAdapter.getDefaultActivity()
public ResolveInfo getDefaultActivity() {
    ...
    return mActivites.get(0).resolveInfo;
    ...
}
ということで、mActivitesインスタンスの並び順が重要になってきます。

●mActivitiesのソート

ソートは共有アイテム選択履歴が保存されるタイミングでしたね。
共有選択履歴が保存されるコードはActivityChooserModel.addHisoricalRecord(...)です。
private boolean addHisoricalRecord(HistoricalRecord historicalRecord) {
    synchronized (mInstanceLock) {
        final boolean added = mHistoricalRecords.add(historicalRecord);
        if (added) {
            mHistoricalRecordsChanged = true;
            pruneExcessiveHistoricalRecordsLocked();
            persistHistoricalData();
            sortActivities();
        }
        return added;
    }
}
選択履歴レコードを追加した後、sortActivities()でソートしてますね。
名は体を表す。mActivitiesのソートがここで実行されます。
private void sortActivities() {
    synchronized (mInstanceLock) {
        if (mActivitySorter != null && !mActivites.isEmpty()) {
            mActivitySorter.sort(mIntent, mActivites,
                    Collections.unmodifiableList(mHistoricalRecords));
            notifyChanged();
        }
    }
}
sortActivitiesの前後でmActivitiesの変化を見てみましょう。
・ソート前
[0]  com.android.development.AppHwPref p=0 o=0 m=0x108000}; weight:1]
[1]  com.android.calendar.EventInfoActivity p=0 o=0 m=0x108000}; weight:0]
[2]  com.android.development.ProcessInfo p=0 o=0 m=0x108000}; weight:0]
・ソート後
[0]★com.android.development.ProcessInfo p=0 o=0 m=0x108000}; weight:1.9500000476837158203125]
[1]  com.android.development.AppHwPref p=0 o=0 m=0x108000}; weight:0]
[2]  com.android.calendar.EventInfoActivity p=0 o=0 m=0x108000}; weight:0]
ソートの結果、ProcessInfoが先頭にきました。
前述の通り、ソート前では先頭だった"com.android.development"パッケージの
AppHwPrefより同パッケージのProcessInfoが優先された結果です。

つまり、ここでデフォルトアクティビティはAppHwPrefからProcessInfoに切り替わるとい
うことです。
# getDefaultActivityが要素0番目を取得することを思い出してください。


●ソートのアルゴリズム

もうちょっと踏み込んでソートアルゴリズムを見てみましょう。
ソートアルゴリズムはsortActivitiesメソッドにあるmActivitySorter.sortがキモです。

mActivitySorterの実体はDefaultSorterクラスです。
DefaultSorterはActivityChooserModelの内部クラスとして内包されています。
/**
 * The sorter for ordering activities based on intent and past choices.
 */
private ActivitySorter mActivitySorter = new DefaultSorter();
DefaultSorterは非常にシンプルです。DefaultSorter.sortで引数のactivitiesをソート
します。(activitiesにはmActivitesが渡されていましたね。)
/**
 * Default activity sorter implementation.
 */
private final class DefaultSorter implements ActivitySorter {
    private static final float WEIGHT_DECAY_COEFFICIENT = 0.95f;

    private final Map<String, ActivityResolveInfo> mPackageNameToActivityMap =
        new HashMap<String, ActivityResolveInfo>();

    public void sort(Intent intent, List<ActivityResolveInfo> activities,
            List<HistoricalRecord> historicalRecords) {
        Map<String, ActivityResolveInfo> packageNameToActivityMap =
            mPackageNameToActivityMap;
        packageNameToActivityMap.clear();

        final int activityCount = activities.size();
        for (int i = 0; i < activityCount; i++) {
            ActivityResolveInfo activity = activities.get(i);
            activity.weight = 0.0f;
            String packageName = activity.resolveInfo.activityInfo.packageName;
            packageNameToActivityMap.put(packageName, activity);
        }

        final int lastShareIndex = historicalRecords.size() - 1;
        float nextRecordWeight = 1;
        for (int i = lastShareIndex; i >= 0; i--) {
            HistoricalRecord historicalRecord = historicalRecords.get(i);
            String packageName = historicalRecord.activity.getPackageName();
            ActivityResolveInfo activity = packageNameToActivityMap.get(packageName);
            if (activity != null) {
                activity.weight += historicalRecord.weight * nextRecordWeight;
                nextRecordWeight = nextRecordWeight * WEIGHT_DECAY_COEFFICIENT;
            }
        }

        Collections.sort(activities);

        if (DEBUG) {
            for (int i = 0; i < activityCount; i++) {
                Log.i(LOG_TAG, "Sorted: " + activities.get(i));
            }
        }
    }
}
17行目~22行目。
今回のポイントとなるpackageNameToActivityMapです。
パッケージ名をキーにActivityインスタンスを保持するHashMapです。
最終的には、このMapに格納されたactivityが「優先するかどうか?」の対象となります。
つまり、このMapに格納されなかったActivityはデフォルトアクティビティになれません。

MapにはmActivitesの中身を走査した結果が格納されるので、同じパッケージ名が出現し
た場合は、後勝ち(上書き)となります。
ソート前のmActivitiesの状態は下記でしたね。
・ソート前mActivitiesの状態
[0]  com.android.development.AppHwPref p=0 o=0 m=0x108000}; weight:0]
[1]  com.android.calendar.EventInfoActivity p=0 o=0 m=0x108000}; weight:0]
[2]  com.android.development.ProcessInfo p=0 o=0 m=0x108000}; weight:0]
Mapのキーとなるパッケージ名"com.android.development"が2つ出現していますね。
先ほどのルールがあるので、同じパッケージ名が出現した場合は後勝ちです。
packageNameToActivityMapは最終的に下記の状態になります。
 Key=com.google.android.calendar Value=[resolveInfo:ResolveInfo{com.android.calendar.EventInfoActivity}; weight:0]
 Key=com.android.development Value=[resolveInfo:ResolveInfo{com.android.development.ProcessInfo}; weight:0]
後勝ちのため、ProcessInfoが選ばれましたね。

packageNameToActivityMapの初期化処理はこれで終了。
前準備も整ったので、いよいよソートを開始します。

24行目~34行目。
ここでは、共有アイテム選択履歴を参照して各Activityの"重み付け"を行います。
ここでの重み付けはソート順序に影響します。

historicalRecordsは共有アイテム選択履歴のレコードリストです。
historicalRecordは共有アイテム選択履歴の各レコードを表します。
ここではレコード分ぐるぐる回してますね。

historicalRecord.activity.getPackageName();をみるとわかる通り、ここでも重要なの
はパッケージ名です。

先ほど初期化したpackageNameToActivityMapからhistoricalRecordと同じパッケージ名を
持つactivityを抽出します。
抽出されたActivityはactivity.weight変数に重みが加算されていきます。
つまり、共有アイテム選択履歴にレコードが多ければ多いほど加算されて、重みが増すよ
うになっています。

一通り重み付けが終わったmActivitiesの状態が下記です。
[0]  com.android.development.AppHwPref p=0 o=0 m=0x108000}; weight:0]
[1]  com.android.calendar.EventInfoActivity p=0 o=0 m=0x108000}; weight:0]
[2]  com.android.development.ProcessInfo p=0 o=0 m=0x108000}; weight:1.9500000476837158203125]
ProcessInfoのweight値に変化がありますね。

36行目。
重み付けもおわったので、mActivitiesのコレクションをソートします。
比較対象はmActivitiesのインスタンス要素。重み付けを行った
android.widget.ActivityChooserModel.ActivityResolveInfoです。
ソートで必要な比較メソッドcompareToを見てみましょう。
public int compareTo(ActivityResolveInfo another) {
     return  Float.floatToIntBits(another.weight) - Float.floatToIntBits(weight);
}
単純にweightの大きさを見ていますね。weightの大きいほうが上(index0寄り)となります。
weightはProcessInfoが最も大きかったので、ソートの結果は下記になります。
[0]  com.android.development.ProcessInfo p=0 o=0 m=0x108000}; weight:1.9500000476837158203125]
[1]  com.android.development.AppHwPref p=0 o=0 m=0x108000}; weight:0]
[2]  com.android.calendar.EventInfoActivity p=0 o=0 m=0x108000}; weight:0]
やっと、ProcessInfoが先頭にきましたね。

mActivitiesの0番目はデフォルトアクティビティとして扱われるのでしたね。
共有アイテム選択履歴の保存を契機にソートが走り、その都度上記のような処理が行われ
るので「デフォルトアクティビティを選択する度に遷移先が変化する」といった現象になります。


# DefaultSorter.sortを呼び出すたびにソート結果が変わっちゃうと思うけど、、、
# やっぱり不具合かな?

以上です。