如何第一次寫Android Launcher Switcher就上手

這篇文件會描述如何撰寫Home切換器,並解剖相關API的用法。完整的程式碼會在重構後發表在第二部份的文章中。


Preface

這篇文件會描述如何撰寫 Home 切換器,並解剖相關 API 的用法。
程式碼懶得重構了 orz,我直接放到github,有興趣的人可以去看看,網址如下:
https://github.com/WTCho/HomeSelector


Home App and Switcher thereof

有玩 Android 系統手機的人應該都有使用過 Android Launcher 之類的應用程式,它是用於變換桌面環境或操作的應用程式,或可以說是變更使用者經驗 (UX) 的程式。在命名上我個人是比較偏好稱作 Home App,因為它是按下 Home 鍵切換或執行的程式,而且它也是跑在 home screen 的桌面程式;而 Launcher 的狹定義是來自 Android 官方預設的 Home app,即 Launcher.apk。我自己下載 Android source Froyo,裡面預設是 Launcher2.apk (com.android.launcher2),位於 {SourceDir}/packages/apps 下。

Activity 可以在 AndroidManifest.xml 中註冊啟動器,啟動器也就是 launcher,它不代表是 Launcher.apk 或任何一種 Home App,而是指應用程式進入口會顯示在 Home App (或 Launcher)上。而 Home App 一般來說都沒有註冊啟動器,因此有人就寫了偵測系統中所有 Home App 的程式,也就是 Home App Switcher。比較好的 Swicher 還會提供清除和設定 default Home App。我在 HTC Incredible S 中安裝了數種 Home App,即 Launcher7ADWZeamReginaLauncherProHome SampleHome++,當然預設的 HTC Sense 也有。其中只有 Zeam 是有註冊 launcher 分類,所以在應用程式選單上就能找得到。

Intent and Intent Filters

在 Android 系統中,Intent 是極為重要,也是最基本要知道的東西。對於系統而言,Intent 就像是應用程式的描述,也類似標籤的概念。根據 Intent 內容,系統或應用程式能夠去使用與搜尋其他應用程式與其提供的服務。Intent 資訊是註冊在程式的 AndroidManifest.xml,並包含在 Intent Filters 標籤的內容中。當搜尋系統中的應用程式時,會去比對應用程式的 Intent Filters,而搜尋條件是包裝在 Intent 物件中以找到符合某種條件程式。Intent Filters 的內容就像是應用程式的索引,內容比對成功就會回傳相對應的 ResolveInfo。有關 Intent 的詳細內容可以參考底下提供的官方網頁連結 [1]

一個 Activity 基本上要在 AndroidManifest.xml 的 intent-filter 中註冊一個 action 和 category; action預設是 intent.ACTION_MAIN,而 category 預設是 intent.CATEGORY_LAUNCHER。根據官方網站說明,ACTION_MAIN 指該 Activity 是個可開始啟動的進入點;CATEGORY_LAUNCHER 指該 Activity 在 Home App 上有啟動器,也就是在應用程式選單上會出現該 Activity 的 icon,以方便執行。註冊內容如下:


<activity android:name=".HomeSelector" android:label="@string/app_name">
        <intent-filter>
               <action android:name="android.intent.action.MAIN" />
               <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>
</activity>


一個應用程式中,可以有多個 Activity 都註冊 CATEGORY_LAUNCHER,那在安裝完後就會有多個 icon 出現在程式選單上,分別能執行各個 Activity。如果 Activity 沒有註冊 CATEGORY_LAUNCHER,那至少要註冊 CATEGORY_DEFAULT,因為它是系統使用 Intent 搜尋的預設條件之一。註冊 CATEGORY_DEFAULT 後該 Activity 不會有 icon 在應用程式選單上出現,所以一般都是給非 ACTION_MAIN 的 Activity 註冊,如 intent.ACTION_VIEW 之類的。如果 Activity 只註冊 action,一般預設 Intent 的搜尋可能會失效,那就需要使用顯形式啟動,以上面的例子來看,就是指定啟動 HomeSelector,例子如下:


Intent i = new Intent(this, HomeSelector.class);
startActivity(i);

如果是指定啟動外部程式,那可以按照下面的做法:


Intent myIntent = new Intent();
myIntent.setClassName("com.KaDaNet", "com.KaDaNet.KaDaNet_MainController");
startActivity(myIntent);

那隱含式搜尋呢?如果你有 Android SDK 有研究,那你應該看過 SDK 中的 sample code,其中的 API Demo 展示了許多使用 API 功能的 Activity,而 API Demo 是由階層式列表對這些 Activity 分類。這個階層式列表的內容是動態生成出來的,由 loadLabel 和 activityInfo.name 去分類。因為是動態生成,所以就不會把列表寫死,那所有列表上要顯示的 Activity 資訊,就是利用隱含式搜尋獲得,程式碼如下:


Intent mainIntent = new Intent(Intent.ACTION_MAIN, null);
mainIntent.addCategory(Intent.CATEGORY_SAMPLE_CODE);
PackageManager pm = getPackageManager();
List<resolveinfo> list = pm.queryIntentActivities(mainIntent, 0);

 API Demo 只顯示 category 註冊為 CATEGORY_SAMPLE_CODE 的 Activity 的資訊。把條件塞進 Intent 後利用 queryIntentActivities 搜尋,其中的一個 Activity 註冊內容如下:

<activity android:name=".app.CustomDialogActivity"
               android:label="@string/activity_custom_dialog"
               android:theme="@style/Theme.CustomDialog">
        <intent-filter>
               <action android:name="android.intent.action.MAIN" />
               <category android:name="android.intent.category.SAMPLE_CODE" />
        </intent-filter>
</activity>


Home App and Intent Filters

在 Android 中,Home App 需要告訴系統自己是一個 Home App,那它才會以 Home 的方式去執行,也就是跑在 home screen 上。同樣的,Home App 透過在 AndroidManifest.xml 上註冊 category 為 intent.CATEGORY_HOME 就可以了。以 SDK sample 中的 Home 為例,其註冊內容如下:

<activity android:name="Home" android:theme="@style/Theme"
               android:launchMode="singleInstance"
               android:stateNotNeeded="true">
       <intent-filter>
               <action android:name="android.intent.action.MAIN" />
               <category android:name="android.intent.category.HOME"/>
               <category android:name="android.intent.category.DEFAULT" />
        </intent-filter>
</activity>


搜尋 Home App 的做法與 API Demo 相同,使用 PackageManager 中的 queryIntentActivities 即可。在下面範例中,對搜尋到的結果取出我們想要的資訊包裝成 HomeInfo,並回傳 List。


private List<homeinfo> queryHomeApp() {
        Intent mainIntent = new Intent(Intent.ACTION_MAIN, null);
        mainIntent.addCategory(Intent.CATEGORY_HOME);
        PackageManager pm = getPackageManager();
        List<resolveinfo> rList = pm.queryIntentActivities(mainIntent, 0);        
        List<homeinfo> homeList = new ArrayList<homeinfo>();
       
        for(ResolveInfo r : rList) {              
                homeList.add(new HomeInfo(r, pm));
        }
        return homeList;
}

 

這裡使用 HomeInfo 類別包裝四種資訊,即類別名稱、封包名稱、Activity 標籤和 Activity icon。


public class HomeInfo {
        public String name;
        public String packageName;
        public String label;
        public Drawable icon;

        public HomeInfo(ResolveInfo rInfo, PackageManager pm) {
                name = rInfo.activityInfo.name;
                packageName = rInfo.activityInfo.packageName;
                label = rInfo.loadLabel(pm).toString();
                icon = rInfo.loadIcon(pm);
        }
}

很簡單吧!這樣就能取得系統中所有的 Home App 資訊,接下來根據這些資訊來切換 Home App。切換的方式,先前也提到過了,那就是顯形式啟動!


public void switchHome(String packageName) {
        Intent homeIntent = new Intent("android.intent.action.MAIN");
        homeIntent.addCategory("android.intent.category.HOME");
        homeIntent.addCategory("android.intent.category.DEFAULT");
        homeIntent.setPackage(packageName);
        this.startActivity(homeIntent);
        this.finish();
}

在程式界面上顯示所有已安裝的 Home App 資訊,最簡單的方式是利用 ListView,再新建一個 list adapter 進行配接並設定 list item 按下後要處理的事,以上面的程式碼來看就是我們要處理的事,即切換到另一個 Home App。界面設定的程式碼如下,顯示畫面如 Fig. 1


ListView lvHome = (ListView) findViewById(R.id.lvHome);
final HomeListAdapter hAdapter = new HomeListAdapter(this);
hAdapter.addItemList(queryHomeApp());
lvHome.setAdapter(hAdapter);
lvHome.setOnItemClickListener(new AdapterView.OnItemClickListener() {
        @Override
        public void onItemClick(AdapterView parent, View view, int position, long id) {
                HomeListItem item = (HomeListItem) hAdapter.getItem(position);
                switchTo(item.getHomeInfo().packageName);
        }             
});


Fig.1. Home App list

Home App Management

一個較好的 Home App 切換器都會提供相關管理的功能。一般來說,基本的管理功能就是顯示資訊。除了找出系統中的所有 Home App 之外,也要能夠顯示這些 Home App 相關的資訊,當然資訊的種類要依需求而定。以 Home App 來說,可以分成未執行和執行時的資訊,也就是說使用者或許希望能看到哪些 Home App 是正在執行;如果正在執行,還有額外的資訊可以看到,像是程序相關的資料。根據這些資訊可以讓使用者決定是否要切換或是關掉 Home App。在這節中,我將介紹如何獲知該 Home App 是否正在執行,與執行時所佔用記憶體的多寡,當然也包括移除 Home App 安裝和顯示應用程式細節。

Android 中的 ActivityManager 類別 [2] 是用來與系統裡正在執行的程序進行互動的 API;使用這個類別,我們能拿到所有正在執行的 App,並能以特定的 package 名稱去過濾某個 App 是否正在執行,範例如下:


public boolean isAppRunning() {
        for(RunningAppProcessInfo info : mAM.getRunningAppProcesses()) {
                if (info.processName.equals(packageName)) return true;                  
                for (String pn : info.pkgList) {
                        if (pn.equals(packageName)) return true;
                }             
        }
        return false;
}

函式中的 mAM 即 ActivityManager,利用 getRunningAppProcesses 方法取得所有的存活 App,因為 Android 並沒有提供過濾的功能,需要自行處理;其中先比對程序名稱是因為在一般的情況下,很多 App 的程序名稱會與 package 名稱相同,所以用這種方式加速比對。如果該 Home App 的 package 名稱與程序名稱不相同,只好比對這程序中所有被載進的 package 其名稱。

接下來是取得程序所佔用的記憶體,利用 ActivityManager 中的 getProcessMemoryInfo 並餵入程序 ID 即可得到 Debug.MemoryInfo。RunningAppProcessInfo 的欄位包含程序 ID,可以透過剛剛取得的執行中 App 資料獲得。不過實際上,程序所佔用的記憶體情況挺複雜的,可以參考 [3-6]。RSS 指包含整個分享函式庫。PSS 是以等比例去切割分享函式庫大小,比例分母為所有使用到該函式庫的程序數。這邊我僅以程序全部的 PSS 來估計記憶體佔用,程式碼如下:


public int getMemoryUsed(ActivityManager am) {                               
        Debug.MemoryInfo mInfo = am.getProcessMemoryInfo(new int[]{PID})[0];
        return mInfo.getTotalPss();
}

使用者可能會根據 Home App 佔用記憶體的多寡來決定是否要停止該程序,以移出更多的空間給其他比較需要資源的軟體使用。移除程序的 API 為用 ActivityManager 類別中的 killBackgroundProcesses 方法,使用它需要跟系統註冊 KILL_BACKGROUND_PROCESSES 權限,使用方法如下,只消 package 名稱:


mAM.killBackgroundProcesses(pkgName);

停止某 Home App 之後,Home Selector 需要更新畫面上的 ListView,這只要通知 ListView 的資料源更新即可,Android 有提供了 notifyDataSetChanged 這個方便的函式。不過要注意的是這個函式只是去通知更新,也就是說 Adapter 的 getView 會重新執行一遍,如果資料綁定處理不是寫在 getView 中就不會發生資料更新,而需要在更新通知前先處理資料綁定。


public void refresh() {
    hAdapter.notifyDataSetChanged();
}

剩下的移除安裝和檢視應用程式細節功能也很簡單,都是透過 Intent 去啟動系統的 Activity,特別要注意的是設定 Intent 的 flag 和目標 URI,程式碼如下:


public void startHomeInfo(Context cont, String pkgName) {
        Uri homeURI = getHomeUri(pkgName);
        Intent i = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, homeURI);
        i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        cont.startActivity(i);
}

public void unInstallHome(Context cont, String pkgName) {
        Uri homeURI = getHomeUri(pkgName);
        Intent i = new Intent(Intent.ACTION_DELETE, homeURI);
        i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        cont.startActivity(i);
}

增加這節所描述到的功能,程式畫面設計修改如 Fig.2 Fig.3


Fig.2. Home App list with memory Info


Fig.3. the functionality of Home management

Default Home

當 Android 手機開機後會啟動開機預設的 Home App,想要自行修改原始碼固定住 home 可參考 [11]。如果手機沒有預設 Home,則會在啟動時跳出選單給使用者選擇,而這個部份的執行流程其實就跟按下 home 鍵的效果一樣,home 鍵就是啟動預設 Home App,在程式上只是利用 Intent 去來完成,範例如下:


Intent homeIntent = new Intent(Intent.ACTION_MAIN);
homeIntent.addCategory(Intent.CATEGORY_HOME);
homeIntent.addCategory(Intent.CATEGORY_DEFAULT);
context.startActivity(homeIntent);


Fig.4. default home list

跳出的 Home 選單如 Fig.4。除了直接啟動預設 Home 之外,還能直接跳出 Home 選單來執行,這是因為如果手機有 Home 的預設值,直接啟動預設 Home 就不會出現選單,方法如下:


Intent homeIntent = new Intent(Intent.ACTION_MAIN);
homeIntent.addCategory(Intent.CATEGORY_HOME);
homeIntent.addCategory(Intent.CATEGORY_DEFAULT);
Intent chooser = Intent.createChooser(homeIntent, "Home");
context.startActivity(chooser);

createChooser 是種簡便的用法,事實上一般的寫法是:


Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER);
chooserIntent.putExtra(Intent.EXTRA_INTENT, homeIntent);             
context.startActivity(chooser);

Home App 的選單如 Fig.5,與 Fig.4 的差別在於最下方沒有預設值項可以勾選。


Fig.5. home chooser

default home 可以用來幫助使用者來管理預設啟動的 Home App,這節會講解 default home 的搜尋、清除和設定;這邊也是比較進階的部份。

在搜尋預設的 Home App 的部份,直接使用 PackageManager 中的 getPreferredActivities 方法;這個 API 會丟出之前使用 addPreferredActivity 加進的 Activity,使用方式是餵入 IntentFilter和ComponentName list 即可,也能指定 package 名稱。傳回的 IntentFilter 可用於之後的過濾動作,而傳回的 ComponentName 可拿到該預設 Component 的詳細資料,看看下面是怎麼寫的就能了解:


public ComponentName queryDefaultHome() {
        List<intentfilter> intentList = new ArrayList<intentfilter>();
        List<componentname> cnList = new ArrayList<componentname>();
        mPM.getPreferredActivities(intentList, cnList, null);
       IntentFilter dhIF;
       for(int i = 0; i < cnList.size(); i++) {
               dhIF = intentList.get(i);
               if(dhIF.hasAction(Intent.ACTION_MAIN) &&
                 dhIF.hasCategory(Intent.CATEGORY_HOME) &&
                 dhIF.hasCategory(Intent.CATEGORY_DEFAULT)) {
                       return cnList.get(i);
               }
       }
        return null;
}

因為要檢查是否有預設 Home,所以需要做過濾的處理,也就是檢查 IntentFilter 內容。再來是設定預設 Home,這個有新舊版之分。在舊版可以直接使用 addPreferredActivity 完成,不過這個方法已經更改成 deprecated,在新版本的手機使用的話,會完全沒有效果。這個部份我有在 2.3.3 版測試過,真的是沒效果,一些網路上的文章是說從 Froyo 開始失效,但這個我就不確定了,可參考 [8-9] 的內容。依官方網頁說明 [7]

This is a protected API that should not have been available to third party applications. It is the platform's responsibility for assigning preferred activities and this can not be directly modified. Add a new preferred activity mapping to the system. This will be used to automatically select the given activity component when Context.startActivity() finds multiple matching activities and also matches the given filter.

在新版中想要處理預設值,需要直接啟動該Activity,這就是說直接啟動預設 Home App,這和執行按下 Home 鍵會做的事相同。直接執行 Default Home 的方式之前就提到過,可參考上文。我在 market 下載了一些的 Home App 管理程式,的確都是以這種方式進行設定;不過像是 Launcher選擇器 採用舊的作法,它在新版本手機中的預設 Home 功能就會失效。

依前文所描述,要設定 Default Home App,需要在原本就沒有預設值的情況下啟動預設 Home App 才會出現選單!所以清除 Default Home 就相對的重要許多。一般清除預設 Home 的方式是開啟 Home App 的應用程式細節設定,去裡面手動清除預設值,如 Fig.6 中最下方按鈕。



Fig.6. dropping default home

如果使用程式化的方法,直覺就是用 clearPackagePreferredActivities 方法;一樣是清除之前使用 addPreferredActivity 設定過的 Activity。但實際執行過發現根本不可行 [10],回到官方網頁的說明,才知道僅能清除自己擁有的 package!

Remove all preferred activity mappings, previously added with addPreferredActivity(IntentFilter, int, ComponentName[], ComponentName), from the system whose activities are implemented in the given package name. An application can only clear its own package(s).

那其他的管理程式到底怎麼做到清除預設 Home 的功能?其實我也不知道,只好去反編譯別人的 apk,我整理後的程式碼如下:


private void removeDefaultHome(Context cont) {
        String pn = HomeSelectorAct.class.getPackage().getName();
        String hn = MockHome.class.getName();
        ComponentName mhCN = new ComponentName(pn, hn);
       
        PackageManager pm = getPackageManager();    
        pm.setComponentEnabledSetting(mhCN, 1, 1);
        mHManager.switchHome(cont);
        pm.setComponentEnabledSetting(mhCN, 0, 1);
        cont.startActivity(new Intent(cont, HomeSelectorAct.class));
}

這段程式碼的重點在於 setComponentEnabledSetting 這個方法,但我敢肯定你就算看完官方網頁解釋也不得其解。後來經過我自己的操作經驗,才發現當手機安裝新的 Home App 或移除某個 Home App 後再啟動預設 Home App,系統就會自動把 Home 的預設值清除,跳出 Home App 選單,而上面這段程式就是在做相同的事。

setComponentEnabledSetting 能以程式化的方式去設定 Component 的 enable,根據官方網頁說明,這個東西是用來指定該 Component 是否能讓系統初始化,它的第一個參數要餵進 ComponentName,第二個是 enable 的設定值,數值 1 代表 COMPONENT_ENABLED_STATE_ENABLED。事實上,如果在 AndroidManifest.xml 設定某個元件的 enable 為 false 時,就等於它根本不能被使用。


為了完成清除預設值功能,必須在 Home App 中新增一個假的 Home App,並且讓他的 enable 值為 false。當要進行清除預設值功能時,把假 Home 的 enable 值設為 true,這就像模擬安裝了一個新的 Home App,再啟動預設 Home,此時系統就幫我們移除Home 的預設值,而在 removeDefaultHome 函式中就是做這件事。因為假 Home 並沒有實際的功能,也不希望它真的出現在 Home 選單上,所以再把它的 enable 值設為 false。最後,因為在沒有預設值的情況下啟動預設 Home,那就會跑出 Home 選單,所以必須再重新開啟 Home App Selector;要注意切換器要設定 singleTask,才不會啟動多個切換器覆蓋。設定 enable 為 false 和重新啟動選擇器的步驟可以互換也沒關係,因為都不影響清除預設值。

那你可能會覺得還要自己做一個假 Home Activity 也太麻煩,為什麼不把系統中某個 Home App 的 enable 先設定為 false,清除預設值後再恢復為 true。其實這個方法我也有試過,但後來才發現 setComponentEnabledSetting 僅能用於同一個應用程式之內,即使增加了相關的權限也沒有效果 (CHANGE_COMPONENT_ENABLED_STATE)。這個問題也挺多人遇到過,因為這個函式他不僅會去檢查權限,還會檢查 User ID,這可以參考 [12-13]。在 [13] 中有提到取得 PackageManager 等於是獲得 PackageManagerService 的服務,在進行設定 enable 時會同時檢查權限與 uid,這在 setEnabledSetting 中完成,原始碼如下:


final int permission = mContext.checkCallingPermission(android.Manifest.permission.CHANGE_COMPONENT_ENABLED_STATE);

final boolean allowedByPermission = (permission == PackageManager.PERMISSION_GRANTED);

if (!allowedByPermission&& (uid != pkgSetting.userId)) {
        throw new SecurityException(
                "Permission Denial: attempt to change component state from pid="
                        + Binder.getCallingPid() + ", uid="
                        + uid + ", package uid="
                        + pkgSetting.userId);
}

完整的Android原始碼可參考 PackageManagerService。另外,根據官方網頁對 User ID 的說明,Android 系統視每個 package 為不同的 Linux User ID:

At install time, Android gives each package a distinct Linux user ID. The identity remains constant for the duration of the package's life on that device. On a different device, the same package may have a different UID; what matters is that each package has a distinct UID on a given device.

Because security enforcement happens at the process level, the code of any two packages can not normally run in the same process, since they need to run as different Linux users. You can use the
sharedUserId attribute in the AndroidManifest.xml's manifest tag of each package to have them assigned the same user ID. By doing this, for purposes of security the two packages are then treated as being the same application, with the same user ID and file permissions. Note that in order to retain security, only two applications signed with the same signature (and requesting the same sharedUserId) will be given the same user ID.

如果想要多個 package 之間互相操作,就需要使用 shared user id,這可參考 [14-15]。完成了清除的功能後,程式還可以在預設值設定方面加強操作。當想要進行預設值設定時,要先確定目前有沒有預設值,如果有就要清除,再去執行啟動預設 Home 以打開 Home 選單,流程如下:


if(mHManager.queryDefaultHome() != null) {
        mHManager.removeDefaultHome(thisCont);
}
mHManager.switchHome(thisCont);


Conclusion

在這篇文章中描述了一個 Home App 整體上該有的功能,像是顯示執行訊息和預設的管理,相信內容應該能讓你容易地建立起自己的 Home 切換器。當然 market 上很多 Home 切換器還有提供 Preference Setting 和 Notification 的功能,或是在應用程式界面上有下功夫,但這些都不是與 Home 本身有關的處理,所以也不多加描述。你可以試試 market 上的各種切換器,我自己是比較偏好 Home Manager 和 HomeSmack,Home Control 也不錯。如果你都覺得不好,那你看完這篇後,可以好好考慮寫一個自己專屬的切換器!最後,來看看 Fig.7,這是程式加入預設功能的樣子。


Fig.7. the complete HomeSelector

Ref.

[1]. Intents and Intent Filters
[2]. ActivityManager
[3]. How to discover memory usage of my application in Android
[4]. Android Memory Usage
[5]. Android/Linux 内存监视
[6]. Analyzing Memory Usage
[7]. PackageManager.addPreferredActivity
[8]. How do I use PackageManager.addPreferredActivity()?
[9]. Android: change default Home Application
[10]. Set default Home app dynamically
[11]. Android的Launcher成为系统中第一个启动的,也是唯一的
[12]. TIP: How To Remove An App Icon From Launcher
[13]. android中如何禁掉组件或package——PackageManager使用
[14]. Android 多个APK共享数据(Shared User ID)
[15]. 多個android application 分享數據- Share User ID