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を呼び出すたびにソート結果が変わっちゃうと思うけど、、、
# やっぱり不具合かな?

以上です。

2012/06/27

Android:ShareActionProvider

ShareActionProviderは、共有アクションを提供するクラスです。

共有アクションに応答できるActivityを管理し、ユーザの選択履歴を記録することで
"よく使う共有アイテム"のような機能を提供します。

●動作イメージ

初回は「共有アクション」のみ表示。


一度でも共有アイテムを選択すると、選択履歴として蓄積され"よく使う共有アイテム"
には専用のUIが用意されます。


メニューアイテムがoverflowの場合は、共有可能なアイテムをサブメニューとして表示
します。



共有可能なアイテムは"よく使う順"にソートされた状態でリスト表示されます。


●使い方

ShareActionProviderはメニューアイテムと関連付けされます。
メニューアイテムがActionItemとして表示されているか、OverflowItemとして表示されて
いるかによって若干振る舞いが変化します。
しかし、開発者はメニューアイテムがActionItem状態かOverflowItem状態かを意識する
必要はありません。
状態の違いからくる動作差異はShareActionProviderによってカプセル化されています。

ShareActionProviderを使う手順はシンプルです。
 1. メニューアイテムにShareActionProviderを関連付ける
 2. メニュー生成時にShareActionProviderインスタンスを取得する
 3. 共有IntentをShareActionProviderに設定する

1はメニューリソースで指定可能です。
関連付けにはitem要素のandroid:actionProviderClass属性で行います。
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:id="@+id/menu_item_share_action"
        android:showAsAction="always"
        android:title="@string/menu_share1"
        android:actionProviderClass="android.widget.ShareActionProvider" />
</menu>
2はメニュー生成時のコールバックメソッドonCreateOptionsMenuで可能です。
@Override
public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.menu_share_action, menu);
    MenuItem shareAction = menu.findItem(R.id.menu_item_share_action);
    mShareActionProvider
        = (ShareActionProvider)shareAction.getActionProvider();

    return true;
}
3はShareActionProvider.setShareIntent(intent)メソッドで可能です。
下記は0-9が押されたら、キーラベルを共有するコードです。
Cキーが押された場合は共有インテントをクリアします。
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
    if (KeyEvent.KEYCODE_9 >= keyCode && keyCode >= KeyEvent.KEYCODE_0) {
        Intent sharedIntent = new Intent(Intent.ACTION_SEND);
        sharedIntent.setType("text/plain");
        sharedIntent.putExtra(
                Intent.EXTRA_TEXT, String.valueOf(event.getDisplayLabel()));

        doShare(sharedIntent);
    } else if (KeyEvent.KEYCODE_C == keyCode) {
        doShare(null);
    }

    return super.onKeyDown(keyCode, event);
}

private void doShare(Intent intent) {
    mShareActionProvider.setShareIntent(intent);
}
ShareActionProviderはsetShareIntentを契機に共有アイテムのリストを更新します。
setShareIntentにnullが渡された場合は、共有アイテム無しとして扱われます。



この状態ではActionItemを押下しても無反応。



setShareIntentに共有Intentを設定すると共有アイテムリストが自動更新される。



setShareIntentにnullを設定すると共有アイテム無しとなる。


ちなみに、"よく使う共有アイテム"を長押下すると共有アイテムリストが表示される機能
も搭載されています。



●堅牢性

ShareActionProviderは堅牢性も備えています。
例えば、"よく使う共有アイテム"の対象となっているパッケージが無効化されていた場合、
別のよく使う共有アイテムを表示するようになっています。

しかし、共有アイテムリスト表示中にバックグラウンドへ一時退避して、リストに含まれ
るパッケージを無効化した後にアプリを復帰させると、共有アイテムリストに含まれるパ
ッケージは表示され続け、これを選択するとActivityNotFound例外で強制終了します。
ただし、これは特異な手順となるため対応の必要性はほぼ無いでしょう。


●注意

通常、ActionItemが選択されるとonOptionsItemSelectedが呼ばれます。
しかし、ActionItemのShareActionProviderのアイテムを選択しても、onOptionsItemSelected
は呼ばれません。※メニューがoverflow状態の場合は呼ばれます。

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


●その他

ShareActionProviderはどのようにして、共有アクション履歴を管理しているのでしょうか
これは、アプリローカルに選択履歴をxmlファイルとして保存することで実現しています。
デフォルトでは、/data/data/<ApplicationPackageName>/files配下にshare_history.xml
の名前で保存されます。

share_history.xmlは下記のような内容になっています。
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<historical-records>
    <historical-record activity="com.android.calendar/com.android.calendar.EventInfoActivity" time="1340782021420" weight="1.0" />
    <historical-record activity="com.android.development/com.android.development.ProcessInfo" time="1340783164738" weight="1.0" />
    <historical-record activity="com.android.development/com.android.development.ProcessInfo" time="1340783171474" weight="1.0" />
    <historical-record activity="com.android.development/com.android.development.AppHwPref" time="1340783189589" weight="1.0" />
    <historical-record activity="com.android.development/com.android.development.ProcessInfo" time="1340783191079" weight="1.0" />
    <historical-record activity="com.android.development/com.android.development.AppHwPref" time="1340783195569" weight="1.0" />
    <historical-record activity="com.android.development/com.android.development.ProcessInfo" time="1340783197179" weight="1.0" />
</historical-records>
共有アイテムが選択されるとhistorical-record要素が末尾に追加されていきます。

選択履歴のxmlファイルはsetShareHistoryFileNameメソッドで指定可能です。


共有アクションを実装したい場合、ShareActionProviderは使いやすさも確保できる便利
なクラスといえそうです。

以上です。