DroidPlugin手札——home鍵強(qiáng)殺處理
DroidPlugin是360開源的插件化框架,github地址為:https://github.com/DroidPluginTeam/DroidPlugin。
因公司業(yè)務(wù)及項(xiàng)目歷史原因,來公司的這段時(shí)間一直在使用DroidPlugin進(jìn)行業(yè)務(wù)開發(fā),期間遇到的一些問題在此進(jìn)行總結(jié)記錄。
一、背景
為了方便訪客知道本章在解決什么問題,這里先把需求背景說明清楚。
- 公司業(yè)務(wù)需求,需要在產(chǎn)品App中以插件的方式安裝游戲apk,之前的Android開發(fā)團(tuán)隊(duì)選用了360的DroidPlugin來實(shí)現(xiàn)這個(gè)需求。
- 需求方(金主)要求當(dāng)用戶在按下home鍵后,我們的app不得駐留進(jìn)程,也就是說,這個(gè)使用了DroidPlugin開發(fā)的產(chǎn)品app,需要在接收到home事件時(shí),將與該app相關(guān)的所有進(jìn)程全部殺死。
這里的所有進(jìn)程指的是產(chǎn)品app本身的【宿主進(jìn)程】,與作為插件安裝的游戲【插件進(jìn)程】。
二、home事件與進(jìn)程自殺處理
1、怎么監(jiān)聽home事件
在我們每次點(diǎn)擊Home按鍵時(shí)系統(tǒng)會(huì)發(fā)出action為Intent.ACTION_CLOSE_SYSTEM_DIALOGS的廣播,用于關(guān)閉系統(tǒng)Dialog,此廣播可以來監(jiān)聽Home按鍵,這種方式是我目前用過的最好的。
/**
* @創(chuàng)建者 LQR
* @時(shí)間 2019/1/7
* @描述 home鍵監(jiān)聽
*/
public class HomeEventWatcher extends BroadcastReceiver {
private Context mContext;
private HomeEventWatcher(Context context) {
mContext = context;
}
private static HomeEventWatcher INSTATNCE;
public static final HomeEventWatcher get(Context context) {
if (INSTATNCE == null) {
synchronized (HomeEventWatcher.class) {
if (INSTATNCE == null) {
INSTATNCE = new HomeEventWatcher(context.getApplicationContext());
}
}
}
return INSTATNCE;
}
/**
* 注冊(cè)事件監(jiān)聽(在onCreate()中執(zhí)行)
*/
public HomeEventWatcher register() {
if (mHomeClickListener != null && mContext != null) {
IntentFilter filter = new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS);
mContext.registerReceiver(this, filter);
}
return this;
}
/**
* 反注冊(cè)事件監(jiān)聽(在onDestroy()中執(zhí)行)
*/
public void unRegister() {
mContext.unregisterReceiver(this);
}
/*------------------ 點(diǎn)擊事件監(jiān)聽 begin ------------------*/
private static final class Home {
private static final String SYSTEM_DIALOG_REASON_KEY = "reason";
private static final String SYSTEM_DIALOG_REASON_HOME_KEY = "homekey";
}
private OnHomeClickListener mHomeClickListener;
public HomeEventWatcher setHomeClickListener(OnHomeClickListener homeClickListener) {
mHomeClickListener = homeClickListener;
return this;
}
@Override
public void onReceive(Context context, Intent intent) {
String intentAction = intent.getAction();
// Log.i("MyAPP", "intentAction =" + intentAction);
// 按下home鍵事件
if (TextUtils.equals(intentAction, Intent.ACTION_CLOSE_SYSTEM_DIALOGS)) {
String reason = intent.getStringExtra(Home.SYSTEM_DIALOG_REASON_KEY);
// Log.i("MyAPP", "reason =" + reason);
if (TextUtils.equals(Home.SYSTEM_DIALOG_REASON_HOME_KEY, reason)) {
if (mHomeClickListener != null) {
mHomeClickListener.onHomeClick();
}
}
}
// 其他按鍵事件
// ...
}
/*------------------ 點(diǎn)擊事件監(jiān)聽 end ------------------*/
public interface OnHomeClickListener {
void onHomeClick();
}
}
2、強(qiáng)殺進(jìn)程
以下方法二選一:
android.os.Process.killProcess(android.os.Process.myPid());
System.exit(0);
注意,最好在確保app進(jìn)程處于后臺(tái)進(jìn)程時(shí)再執(zhí)行,因?yàn)椴糠衷O(shè)備會(huì)自動(dòng)重啟那些被強(qiáng)殺的前臺(tái)進(jìn)程?;蛘?,想辦法關(guān)閉所有的Activity,然后直接執(zhí)行強(qiáng)殺,至于如何關(guān)閉所有Activity,下面會(huì)提供一種簡(jiǎn)單粗暴的方法。
3、adb指令
這里提供2個(gè)adb指令,方便查看進(jìn)程狀況、強(qiáng)制結(jié)束進(jìn)程。
adb shell " procrank | grep com.xxx.yyy " // 查看進(jìn)程狀況(若進(jìn)程不存在,則終端不顯示任何信息)
adb shell am force-stop com.xxx.yyy // 強(qiáng)制結(jié)束進(jìn)程
注意:
1)com.xxx.yyy不是包名,而是applicationId,通常情況下,包名與applicationId一致。
2)使用DroidPlugin運(yùn)行的插件,會(huì)多出來一個(gè)插件進(jìn)程,進(jìn)程名一般為 宿主進(jìn)程名+PluginP07。
三、DroidPlugin強(qiáng)殺躺坑
下面正式進(jìn)入本章核心內(nèi)容,情景前提:產(chǎn)品app在接收到home事件時(shí),會(huì)執(zhí)行進(jìn)程自殺邏輯,殺死與當(dāng)前app相關(guān)的所有進(jìn)程。
1、殺不死的宿主進(jìn)程
1)現(xiàn)象
啟動(dòng)產(chǎn)品app,然后直接按home鍵,使用AndroidStudio觀察進(jìn)程并查看日志輸出,看到控制臺(tái)輸出了強(qiáng)殺日志,而app進(jìn)程在殺死后重啟了。
2)分析
通過日志可以確定強(qiáng)殺代碼有被執(zhí)行到,并且進(jìn)程也被殺死過,這個(gè)進(jìn)程重啟不是項(xiàng)目代碼觸發(fā)的,應(yīng)該是DroidPlugin設(shè)置了類似?;顧C(jī)制的東西,導(dǎo)致Android系統(tǒng)拉起被強(qiáng)殺的產(chǎn)品app。通過查閱DroidPlugin源碼,可以知道DroidPlugin會(huì)啟動(dòng)一個(gè)Service,用來管理插件(安裝、卸載等),這個(gè)Service使用了start和bind方式啟動(dòng),并且設(shè)置前臺(tái)進(jìn)程?;?,代碼如下:
// =================== com.morgoo.droidplugin.pm.PluginManager ===================
public void connectToService() {
if (mPluginManager == null) {
try {
Intent intent = new Intent(mHostContext, PluginManagerService.class);
intent.setPackage(mHostContext.getPackageName());
mHostContext.startService(intent);
String auth = mHostContext.getPackageName() + ".plugin.servicemanager";
Uri uri = Uri.parse("content://" + auth);
Bundle args = new Bundle();
args.putString(PluginServiceProvider.URI_VALUE, "content://" + auth);
Bundle res = ContentProviderCompat.call(mHostContext, uri,
PluginServiceProvider.Method_GetManager,
null, args);
if (res != null) {
IBinder clientBinder = BundleCompat.getBinder(res, PluginServiceProvider.Arg_Binder);
onServiceConnected(intent.getComponent(), clientBinder);
} else {
mHostContext.bindService(intent, this, Context.BIND_AUTO_CREATE);
}
} catch (Exception e) {
Log.e(TAG, "connectToService", e);
}
}
}
// =================== com.morgoo.droidplugin.PluginManagerService ===================
@Override
public void onCreate() {
super.onCreate();
keepAlive();
getPluginPackageManager(this);
}
private void keepAlive() {
try {
Notification notification = new Notification();
notification.flags |= Notification.FLAG_NO_CLEAR;
notification.flags |= Notification.FLAG_ONGOING_EVENT;
startForeground(0, notification); // 設(shè)置為前臺(tái)服務(wù)避免kill,Android4.3及以上需要設(shè)置id為0時(shí)通知欄才不顯示該通知;
} catch (Throwable e) {
e.printStackTrace();
}
}
3)方案
應(yīng)該大致可以確定,宿主進(jìn)程殺不死的原因,就是這個(gè)PluginManagerService導(dǎo)致的,處理方式有2種。
- 宿主自殺前先關(guān)閉PluginManagerService
/**
* 停止插件服務(wù)
*/
private void stopPluginServer() {
Intent intent = new Intent();
intent.setClass(PluginManager.getInstance().getHostContext(), PluginManagerService.class);
CONTEXT.getApplicationContext().stopService(intent);
}
- 取消PluginManagerService?;?,并且不使用start方式啟動(dòng)。因?yàn)閎ind方式啟動(dòng)的Service,其生命周期與app一致,按home鍵時(shí)會(huì)觸發(fā)強(qiáng)殺進(jìn)程,不需要手動(dòng)關(guān)閉。
// =================== com.morgoo.droidplugin.pm.PluginManager ===================
public void connectToService() {
if (mPluginManager == null) {
try {
Intent intent = new Intent(mHostContext, PluginManagerService.class);
intent.setPackage(mHostContext.getPackageName());
// mHostContext.startService(intent);
...
mHostContext.bindService(intent, this, Context.BIND_AUTO_CREATE);
} catch (Exception e) {
Log.e(TAG, "connectToService", e);
}
}
}
// =================== com.morgoo.droidplugin.PluginManagerService ===================
@Override
public void onCreate() {
super.onCreate();
// keepAlive();
getPluginPackageManager(this);
}
2、啟動(dòng)App直接進(jìn)入強(qiáng)殺前運(yùn)行的插件
1)現(xiàn)象
游戲運(yùn)行中,按下home鍵強(qiáng)殺app,點(diǎn)擊App icon再次啟動(dòng)App,直接進(jìn)入剛剛的游戲。
2)分析
在插件游戲運(yùn)行過程中,打開終端或cmd,使用adb查看當(dāng)前棧信息:
adb shell dumpsys activity activities top

可以看到,游戲進(jìn)程(插件進(jìn)程)與產(chǎn)品app進(jìn)程(宿主進(jìn)程)共用一個(gè)Activity棧,由此可以推測(cè),因?yàn)樗拗鰽pp在被強(qiáng)殺的時(shí)候,系統(tǒng)保存了宿主進(jìn)程的Activity棧信息,所以,在產(chǎn)品app下次啟動(dòng)時(shí),系統(tǒng)會(huì)恢復(fù)棧記錄。
3)方案
根據(jù)前面的推測(cè),針對(duì)目前的問題,方案無非就2個(gè),要么讓宿主進(jìn)程在被強(qiáng)殺時(shí)不要被系統(tǒng)保存棧記錄,要么讓宿主進(jìn)程與插件進(jìn)程不要共用一個(gè)棧。要注意,方案一才是關(guān)鍵,但這個(gè)與第3個(gè)坑有關(guān)聯(lián),所以,這里就只說下方案二吧。很簡(jiǎn)單,修改產(chǎn)品app(宿主)入口Activity的啟動(dòng)模式即可,如把 launchMode 修改為 singleInstance,這樣的話,下次通過icon啟動(dòng)產(chǎn)品app時(shí),系統(tǒng)會(huì)單獨(dú)使用一個(gè)棧來存放這個(gè)入口Activity,從而避免與插件共用一個(gè)棧的問題。修改完成后,啟動(dòng)產(chǎn)品app,再啟動(dòng)游戲插件,這時(shí),通過adb命令查看當(dāng)前棧信息:
adb shell dumpsys activity activities top

可以看到產(chǎn)品app與游戲插件不在一個(gè)棧內(nèi),這時(shí),按home鍵,再啟動(dòng)就不會(huì)再進(jìn)入游戲界面了。但是,方案二并不是正確的解決辦法,方案一才是,因?yàn)檫M(jìn)程強(qiáng)殺前的棧信息還是會(huì)被保留下來的,如果項(xiàng)目采用的是Activity + Fragment架構(gòu),這時(shí),效果會(huì)很"神奇",這絕對(duì)不是產(chǎn)品希望看到的。那要怎樣才能讓進(jìn)程在被強(qiáng)殺時(shí)不要被系統(tǒng)保存棧記錄呢?請(qǐng)繼續(xù)往下看。
3、啟動(dòng)插件B時(shí)直接啟動(dòng)插件A
1)現(xiàn)象
進(jìn)入產(chǎn)品app,啟動(dòng)游戲A,按home鍵,再進(jìn)入產(chǎn)品app,啟動(dòng)游戲B,這時(shí),直接啟動(dòng)了游戲A。
2)分析
這就是前面問題2說到的,狀態(tài)保存問題,插件進(jìn)程在按下home時(shí)被強(qiáng)殺,這時(shí),系統(tǒng)認(rèn)為該游戲插件是意外退出,會(huì)保存當(dāng)前游戲的狀態(tài),以便下次啟動(dòng)時(shí)恢復(fù)。要知道,DroidPlugin使用組件預(yù)先占坑的方式,預(yù)先在宿主清單文件中聲明好多個(gè)Activity、Service等,并且會(huì)對(duì)組件進(jìn)行復(fù)用,所以,當(dāng)下次啟動(dòng)另一個(gè)游戲時(shí),剛好復(fù)用了前一個(gè)游戲使用過的組件(Activity),于是在恢復(fù)狀態(tài)的時(shí)候,就把前一個(gè)游戲恢復(fù)回來了。
以上分析個(gè)人猜測(cè),不知說法是否正確,如有問題請(qǐng)不吝賜教~
3)方案
游戲(插件)退出時(shí),銷毀游戲所有的Activity,銷毀當(dāng)前進(jìn)程所有Activity的方法如下:
/**
* 關(guān)閉當(dāng)前App所有Activity
*/
public void finishAllActivities(Application application) {
List<Activity> activities = getActivitiesByApplication(application);
if (activities != null && activities.size() > 0) {
for (int i = activities.size() - 1; i >= 0; i--) {
Activity activity = activities.get(i);
activity.finish();
Log.e("lqr", "finish activity : " + activity);
}
}
}
/**
* 獲取當(dāng)前App中所有Activity
*/
public List<Activity> getActivitiesByApplication(Application application) {
List<Activity> list = new ArrayList<>();
try {
Class<Application> applicationClass = Application.class;
Field mLoadedApkField = applicationClass.getDeclaredField("mLoadedApk");
mLoadedApkField.setAccessible(true);
Object mLoadedApk = mLoadedApkField.get(application);
Class<?> mLoadedApkClass = mLoadedApk.getClass();
Field mActivityThreadField = mLoadedApkClass.getDeclaredField("mActivityThread");
mActivityThreadField.setAccessible(true);
Object mActivityThread = mActivityThreadField.get(mLoadedApk);
Class<?> mActivityThreadClass = mActivityThread.getClass();
Field mActivitiesField = mActivityThreadClass.getDeclaredField("mActivities");
mActivitiesField.setAccessible(true);
Object mActivities = mActivitiesField.get(mActivityThread);
// 注意這里一定寫成Map,低版本這里用的是HashMap,高版本用的是ArrayMap
if (mActivities instanceof Map) {
@SuppressWarnings("unchecked")
Map<Object, Object> arrayMap = (Map<Object, Object>) mActivities;
for (Map.Entry<Object, Object> entry : arrayMap.entrySet()) {
Object value = entry.getValue();
Class<?> activityClientRecordClass = value.getClass();
Field activityField = activityClientRecordClass.getDeclaredField("activity");
activityField.setAccessible(true);
Object o = activityField.get(value);
list.add((Activity) o);
}
}
} catch (Exception e) {
e.printStackTrace();
list = null;
}
return list;
}
注意:這個(gè)關(guān)閉所有Activity的方法可以用來解決問題2最后遺留的問題。
要注意,DroidPlugin會(huì)為每個(gè)插件單獨(dú)創(chuàng)建進(jìn)程,也就是說,如果你項(xiàng)目中使用了DroidPlugin,就會(huì)涉及到多進(jìn)程,在啟動(dòng)插件時(shí),宿主的Application內(nèi)的邏輯會(huì)執(zhí)行多次(宿主、插件進(jìn)程一創(chuàng)建就會(huì)執(zhí)行),所以,建議在項(xiàng)目的自定義Application中對(duì)進(jìn)程進(jìn)行區(qū)分,根據(jù)不同進(jìn)程分別處理(如:第三方面SDK只需要在產(chǎn)品app宿主進(jìn)程中初始化),判斷當(dāng)前進(jìn)程是否為插件進(jìn)程的方法如下:
/**
* 判斷當(dāng)前進(jìn)程是否為插件進(jìn)程
*
* @param context 上下文
* @param hostAppId 宿主appid
* @return
*/
public boolean adjustPluginProcess(Context context, String hostAppId) {
ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
List<ActivityManager.RunningAppProcessInfo> runningAppProcesses = am.getRunningAppProcesses();
if (runningAppProcesses != null && runningAppProcesses.size() > 0) {
for (ActivityManager.RunningAppProcessInfo info : runningAppProcesses) {
// Step 1. 找到當(dāng)前進(jìn)程
if (info.pid == Process.myPid()) {
// Log.e("lqr", "info.processName = " + info.processName);
// Step 2. 判斷當(dāng)前進(jìn)程是否為插件進(jìn)程(依據(jù))
return !info.processName.equals(hostAppId);
}
}
}
return false;
}
Q:為什么要傳入宿主的appid?
A:這里說的appid指的就是applicationId。因?yàn)閍ppid不等同于包名,我們常說的一個(gè)設(shè)備上不能安裝相同包名的app這種說法是不嚴(yán)謹(jǐn)?shù)?,?yīng)該是不能安裝相同appid的app,此外,一個(gè)項(xiàng)目在多渠道的情況下,是可以通過gradle來指定修改appid的,如果你的項(xiàng)目中有使用過多渠道打包,相信應(yīng)該能夠明白,綜上,包名不能作為判斷宿主進(jìn)程的依據(jù),所以只能使用appid來判斷。
Q:為什么不以進(jìn)程名是否帶有 "PluginP" 字樣來判斷是否為插件進(jìn)程?
A:親測(cè)這種方式不準(zhǔn)確,在有些設(shè)備上,插件進(jìn)程的進(jìn)程名是這樣的規(guī)則,但有些設(shè)備不是,直接是插件原本的applicationId。
通過上面的代碼,根據(jù)項(xiàng)目的具體情況,分別處理宿主進(jìn)程與插件進(jìn)程吧,建議2個(gè)進(jìn)程在監(jiān)聽到home事件時(shí),都關(guān)閉所有Activity,這樣系統(tǒng)就不會(huì)保存棧狀態(tài)了(一定要先關(guān)閉插件的,再關(guān)閉宿主的?。。?。
4、部分4.x設(shè)備安裝插件失敗-500
公司是做盒子應(yīng)用開發(fā)的,在部分4.x的盒子上確實(shí)出現(xiàn)了使用DroidPlugin無法正常安裝插件的情況,但舊版的DroidPlugin就不會(huì),我比對(duì)了2個(gè)版本的DroidPlugin,最終定位到在com.morgoo.droidplugin.pm包下的PluginManager,其中有這么一個(gè)方法:
新版的DroidPlugin適配了高版本的Android系統(tǒng)(如:Android8.0)
// =================== 舊版DroidPlugin ===================
public void connectToService() {
if (mPluginManager == null) {
try {
Intent intent = new Intent(mHostContext, PluginManagerService.class);
intent.setPackage(mHostContext.getPackageName());
mHostContext.startService(intent);
mHostContext.bindService(intent, this, Context.BIND_AUTO_CREATE);
} catch (Exception e) {
Log.e(TAG, "connectToService", e);
}
}
}
// =================== 新版DroidPlugin ===================
public void connectToService() {
if (mPluginManager == null) {
try {
Intent intent = new Intent(mHostContext, PluginManagerService.class);
intent.setPackage(mHostContext.getPackageName());
mHostContext.startService(intent);
String auth = mHostContext.getPackageName() + ".plugin.servicemanager";
Uri uri = Uri.parse("content://" + auth);
Bundle args = new Bundle();
args.putString(PluginServiceProvider.URI_VALUE, "content://" + auth);
Bundle res = ContentProviderCompat.call(mHostContext, uri,
PluginServiceProvider.Method_GetManager,
null, args);
if (res != null) {
IBinder clientBinder = BundleCompat.getBinder(res, PluginServiceProvider.Arg_Binder);
onServiceConnected(intent.getComponent(), clientBinder);
} else {
mHostContext.bindService(intent, this, Context.BIND_AUTO_CREATE);
}
} catch (Exception e) {
Log.e(TAG, "connectToService", e);
}
}
}
正是因?yàn)檫@部分多出來的代碼,導(dǎo)致新版的DroidPlugin無法在個(gè)別4.x設(shè)備上正常安裝插件,所以,我們可以對(duì)源碼進(jìn)行修改,區(qū)分4.x以下及高版本的代碼邏輯即可,如:
public void connectToService() {
if (mPluginManager == null) {
try {
Intent intent = new Intent(mHostContext, PluginManagerService.class);
intent.setPackage(mHostContext.getPackageName());
// mHostContext.startService(intent);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
mHostContext.bindService(intent, this, Context.BIND_AUTO_CREATE);
} else {
String auth = mHostContext.getPackageName() + ".plugin.servicemanager";
Uri uri = Uri.parse("content://" + auth);
Bundle args = new Bundle();
args.putString(PluginServiceProvider.URI_VALUE, "content://" + auth);
Bundle res = ContentProviderCompat.call(mHostContext, uri,
PluginServiceProvider.Method_GetManager,
null, args);
if (res != null) {
IBinder clientBinder = BundleCompat.getBinder(res, PluginServiceProvider.Arg_Binder);
onServiceConnected(intent.getComponent(), clientBinder);
} else {
mHostContext.bindService(intent, this, Context.BIND_AUTO_CREATE);
}
}
} catch (Exception e) {
Log.e(TAG, "connectToService", e);
}
}
}
四、最后
以上,就是本人在實(shí)際開發(fā)中,使用DroidPlugin的項(xiàng)目在強(qiáng)殺時(shí)的踩坑記錄分享,如果有什么更好的解決方案,希望可以一起交流,如文章中說明有問題歡迎指出交流,不喜勿噴。
