DroidPlugin手札——home鍵強殺處理

GitLqr發表於2019-01-21

DroidPlugin手札——home鍵強殺處理

DroidPlugin是360開源的外掛化框架,github地址為:github.com/DroidPlugin…。 因公司業務及專案歷史原因,來公司的這段時間一直在使用DroidPlugin進行業務開發,期間遇到的一些問題在此進行總結記錄。

一、背景

為了方便訪客知道本章在解決什麼問題,這裡先把需求背景說明清楚。

  1. 公司業務需求,需要在產品App中以外掛的方式安裝遊戲apk,之前的Android開發團隊選用了360的DroidPlugin來實現這個需求。
  2. 需求方(金主)要求當使用者在按下home鍵後,我們的app不得駐留程式,也就是說,這個使用了DroidPlugin開發的產品app,需要在接收到home事件時,將與該app相關的所有程式全部殺死。

這裡的所有程式指的是產品app本身的【宿主程式】,與作為外掛安裝的遊戲【外掛程式】。

二、home事件與程式自殺處理

1、怎麼監聽home事件

在我們每次點選Home按鍵時系統會發出action為Intent.ACTION_CLOSE_SYSTEM_DIALOGS的廣播,用於關閉系統Dialog,此廣播可以來監聽Home按鍵,這種方式是我目前用過的最好的。

/**
 * @建立者 LQR
 * @時間 2019/1/7
 * @描述 home鍵監聽
 */
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;
    }

    /**
     * 註冊事件監聽(在onCreate()中執行)
     */
    public HomeEventWatcher register() {
        if (mHomeClickListener != null && mContext != null) {
            IntentFilter filter = new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS);
            mContext.registerReceiver(this, filter);
        }
        return this;
    }

    /**
     * 反註冊事件監聽(在onDestroy()中執行)
     */
    public void unRegister() {
        mContext.unregisterReceiver(this);
    }

    /*------------------ 點選事件監聽 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();
                }
            }
        }
        // 其他按鍵事件
        // ...
    }

    /*------------------ 點選事件監聽 end ------------------*/
    public interface OnHomeClickListener {
        void onHomeClick();
    }
}
複製程式碼

2、強殺程式

以下方法二選一:

android.os.Process.killProcess(android.os.Process.myPid());
System.exit(0);
複製程式碼

注意,最好在確保app程式處於後臺程式時再執行,因為部分裝置會自動重啟那些被強殺的前臺程式。或者,想辦法關閉所有的Activity,然後直接執行強殺,至於如何關閉所有Activity,下面會提供一種簡單粗暴的方法。

3、adb指令

這裡提供2個adb指令,方便檢視程式狀況、強制結束程式。

adb shell " procrank | grep com.xxx.yyy "	// 檢視程式狀況(若程式不存在,則終端不顯示任何資訊)
adb shell am force-stop com.xxx.yyy        // 強制結束程式
複製程式碼

注意: 1)com.xxx.yyy不是包名,而是applicationId,通常情況下,包名與applicationId一致。 2)使用DroidPlugin執行的外掛,會多出來一個外掛程式,程式名一般為 宿主程式名+PluginP07。

三、DroidPlugin強殺躺坑

下面正式進入本章核心內容,情景前提:產品app在接收到home事件時,會執行程式自殺邏輯,殺死與當前app相關的所有程式。

1、殺不死的宿主程式

1)現象

啟動產品app,然後直接按home鍵,使用AndroidStudio觀察程式並檢視日誌輸出,看到控制檯輸出了強殺日誌,而app程式在殺死後重啟了。

2)分析

通過日誌可以確定強殺程式碼有被執行到,並且程式也被殺死過,這個程式重啟不是專案程式碼觸發的,應該是DroidPlugin設定了類似保活機制的東西,導致Android系統拉起被強殺的產品app。通過查閱DroidPlugin原始碼,可以知道DroidPlugin會啟動一個Service,用來管理外掛(安裝、解除安裝等),這個Service使用了start和bind方式啟動,並且設定前臺程式保活,程式碼如下:

// =================== 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); // 設定為前臺服務避免kill,Android4.3及以上需要設定id為0時通知欄才不顯示該通知;
    } catch (Throwable e) {
        e.printStackTrace();
    }
}

複製程式碼

3)方案

應該大致可以確定,宿主程式殺不死的原因,就是這個PluginManagerService導致的,處理方式有2種。

  • 宿主自殺前先關閉PluginManagerService
/**
 * 停止外掛服務
 */
private void stopPluginServer() {
    Intent intent = new Intent();
    intent.setClass(PluginManager.getInstance().getHostContext(), PluginManagerService.class);
    CONTEXT.getApplicationContext().stopService(intent);
}
複製程式碼
  • 取消PluginManagerService保活,並且不使用start方式啟動。因為bind方式啟動的Service,其生命週期與app一致,按home鍵時會觸發強殺程式,不需要手動關閉。
// =================== 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、啟動App直接進入強殺前執行的外掛

1)現象

遊戲執行中,按下home鍵強殺app,點選App icon再次啟動App,直接進入剛剛的遊戲。

2)分析

在外掛遊戲執行過程中,開啟終端或cmd,使用adb檢視當前棧資訊:

adb shell dumpsys activity activities top  
複製程式碼

DroidPlugin手札——home鍵強殺處理

可以看到,遊戲程式(外掛程式)與產品app程式(宿主程式)共用一個Activity棧,由此可以推測,因為宿主App在被強殺的時候,系統儲存了宿主程式的Activity棧資訊,所以,在產品app下次啟動時,系統會恢復棧記錄。

3)方案

根據前面的推測,針對目前的問題,方案無非就2個,要麼讓宿主程式在被強殺時不要被系統儲存棧記錄,要麼讓宿主程式與外掛程式不要共用一個棧。要注意,方案一才是關鍵,但這個與第3個坑有關聯,所以,這裡就只說下方案二吧。很簡單,修改產品app(宿主)入口Activity的啟動模式即可,如把 launchMode 修改為 singleInstance,這樣的話,下次通過icon啟動產品app時,系統會單獨使用一個棧來存放這個入口Activity,從而避免與外掛共用一個棧的問題。修改完成後,啟動產品app,再啟動遊戲外掛,這時,通過adb命令檢視當前棧資訊:

adb shell dumpsys activity activities top  
複製程式碼

DroidPlugin手札——home鍵強殺處理

可以看到產品app與遊戲外掛不在一個棧內,這時,按home鍵,再啟動就不會再進入遊戲介面了。但是,方案二並不是正確的解決辦法,方案一才是,因為程式強殺前的棧資訊還是會被保留下來的,如果專案採用的是Activity + Fragment架構,這時,效果會很"神奇",這絕對不是產品希望看到的。那要怎樣才能讓程式在被強殺時不要被系統儲存棧記錄呢?請繼續往下看。

3、啟動外掛B時直接啟動外掛A

1)現象

進入產品app,啟動遊戲A,按home鍵,再進入產品app,啟動遊戲B,這時,直接啟動了遊戲A。

2)分析

這就是前面問題2說到的,狀態儲存問題,外掛程式在按下home時被強殺,這時,系統認為該遊戲外掛是意外退出,會儲存當前遊戲的狀態,以便下次啟動時恢復。要知道,DroidPlugin使用元件預先佔坑的方式,預先在宿主清單檔案中宣告好多個Activity、Service等,並且會對元件進行復用,所以,當下次啟動另一個遊戲時,剛好複用了前一個遊戲使用過的元件(Activity),於是在恢復狀態的時候,就把前一個遊戲恢復回來了。

以上分析個人猜測,不知說法是否正確,如有問題請不吝賜教~

3)方案

遊戲(外掛)退出時,銷燬遊戲所有的Activity,銷燬當前程式所有Activity的方法如下:

/**
 * 關閉當前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);
        }
    }
}

/**
 * 獲取當前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;
}
複製程式碼

注意:這個關閉所有Activity的方法可以用來解決問題2最後遺留的問題。

要注意,DroidPlugin會為每個外掛單獨建立程式,也就是說,如果你專案中使用了DroidPlugin,就會涉及到多程式,在啟動外掛時,宿主的Application內的邏輯會執行多次(宿主、外掛程式一建立就會執行),所以,建議在專案的自定義Application中對程式進行區分,根據不同程式分別處理(如:第三方面SDK只需要在產品app宿主程式中初始化),判斷當前程式是否為外掛程式的方法如下:

/**
 * 判斷當前程式是否為外掛程式
 *
 * @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. 找到當前程式
            if (info.pid == Process.myPid()) {
                // Log.e("lqr", "info.processName = " + info.processName);
                // Step 2. 判斷當前程式是否為外掛程式(依據)
                return !info.processName.equals(hostAppId);
            }
        }
    }
    return false;
}
複製程式碼

Q:為什麼要傳入宿主的appid? A:這裡說的appid指的就是applicationId。因為appid不等同於包名,我們常說的一個裝置上不能安裝相同包名的app這種說法是不嚴謹的,應該是不能安裝相同appid的app,此外,一個專案在多渠道的情況下,是可以通過gradle來指定修改appid的,如果你的專案中有使用過多渠道打包,相信應該能夠明白,綜上,包名不能作為判斷宿主程式的依據,所以只能使用appid來判斷。 Q:為什麼不以程式名是否帶有 "PluginP" 字樣來判斷是否為外掛程式? A:親測這種方式不準確,在有些裝置上,外掛程式的程式名是這樣的規則,但有些裝置不是,直接是外掛原本的applicationId。

通過上面的程式碼,根據專案的具體情況,分別處理宿主程式與外掛程式吧,建議2個程式在監聽到home事件時,都關閉所有Activity,這樣系統就不會儲存棧狀態了(一定要先關閉外掛的,再關閉宿主的!!)。

4、部分4.x裝置安裝外掛失敗-500

公司是做盒子應用開發的,在部分4.x的盒子上確實出現了使用DroidPlugin無法正常安裝外掛的情況,但舊版的DroidPlugin就不會,我比對了2個版本的DroidPlugin,最終定位到在com.morgoo.droidplugin.pm包下的PluginManager,其中有這麼一個方法:

新版的DroidPlugin適配了高版本的Android系統(如: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);
        }

    }
}
複製程式碼

正是因為這部分多出來的程式碼,導致新版的DroidPlugin無法在個別4.x裝置上正常安裝外掛,所以,我們可以對原始碼進行修改,區分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);
		}

	}
}
複製程式碼

四、最後

以上,就是本人在實際開發中,使用DroidPlugin的專案在強殺時的踩坑記錄分享,如果有什麼更好的解決方案,希望可以一起交流,如文章中說明有問題歡迎指出交流,不喜勿噴。

相關文章