DroidPlugin手札——home键强杀处理

DroidPlugin手札——home键强杀处理

DroidPlugin是360开源的插件化框架,github地址为:https://github.com/DroidPluginTeam/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  

可以看到,游戏进程(插件进程)与产品app进程(宿主进程)共用一个Activity栈,由此可以推测,因为宿主App在被强杀的时候,系统保存了宿主进程的Activity栈信息,所以,在产品app下次启动时,系统会恢复栈记录。

3)方案

根据前面的推测,针对目前的问题,方案无非就2个,要么让宿主进程在被强杀时不要被系统保存栈记录,要么让宿主进程与插件进程不要共用一个栈。要注意,方案一才是关键,但这个与第3个坑有关联,所以,这里就只说下方案二吧。很简单,修改产品app(宿主)入口Activity的启动模式即可,如把 launchMode 修改为 singleInstance,这样的话,下次通过icon启动产品app时,系统会单独使用一个栈来存放这个入口Activity,从而避免与插件共用一个栈的问题。修改完成后,启动产品app,再启动游戏插件,这时,通过adb命令查看当前栈信息:

adb shell dumpsys activity activities top  

可以看到产品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的项目在强杀时的踩坑记录分享,如果有什么更好的解决方案,希望可以一起交流,如文章中说明有问题欢迎指出交流,不喜勿喷。

欢迎关注微信公众号:全栈行动
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容