安卓插件化shadow实践

背景:项目app里需要嵌入云游戏,然而云游戏的发版次数频繁,直接嵌入原生app里就会频繁走合规检测,才可以提交审核发布,流程长,效率低。

说明:涉及的安卓知识多而杂,还是最好先把原理过一遍,不需要完全理解,至少有个大体的运行流程结构。https://github.com/Tencent/Shadow/tree/master/projects/sample#%E8%BF%90%E8%A1%8C%E6%96%B9%E6%B3%95地址对shadow做了整体结构的大概描述。

1:下载shadow的demo

首先clone下shadow项目,地址:https://github.com/Tencent/Shadow.git

看下结构:
image.png

buildScripts:shadow源码上传到maven脚本。

projects/sample:demo事例

projects/sample/host-project:宿主app

projects/sample/manager-project:插件管理工具

projects/sample/plugin-project:插件app

projects/sdk:shadow源码

projects/test:测试代码

其中projects/sample/下的maven是依赖远程shadow的源码,就是类似我们实际开发的代码。

projects/sample/下的source是依赖本地SDK的事例,可以debug调试查看shadow源码,修改本地shadow的源码可以直接运行生效。

2:宿主

话不多说,直接进主题,宿主的跟目录build.gradle里引用shadow,先设置shadow_version版本号:

ext {
         buildToolsVersion = "29.0.2"
         minSdkVersion = 21
         compileSdkVersion = 29
         targetSdkVersion = 29
         reactNative = "0.63.4"  // From node_modules
         shadow_version = '2.2.1'
         COMPILE_SDK_VERSION = 29
         MIN_SDK_VERSION = 21
         TARGET_SDK_VERSION = 29
         VERSION_CODE = 1
         VERSION_NAME = "local"
    }

repositories里添加下载配置,参考shadow的demo:

      
        maven {
            name = "GitHubPackages"
            url "https://maven.pkg.github.com/tencent/shadow"
            //一个只读账号兼容Github Packages暂时不支持匿名下载
            //https://github.community/t/download-from-github-package-registry-without-authentication/14407
            credentials {
                username = 'readonlypat'
                password = '\u0067hp_s3VOOZnLf1bTyvHWblPfaessrVYyEU4JdNbs'
            }
        }

如果将shadow的源码发布到了自己的maven仓库,记得更改下版本号和下载信息。

在app的build.gradle引入:

    //如果introduce-shadow-lib发布到Maven,在pom中写明此依赖,宿主就不用写这个依赖了。
    implementation "com.tencent.shadow.dynamic:host:$shadow_version"

宿主app里引入introduce-shadow-lib(可以直接从demo的宿主里拷贝过来),在app的build.gradle引入

implementation project(':introduce-shadow-lib')

宿主app里引入sample-host-lib(参考demo里的sample-host-lib),用于宿主传参给插件在app的build.gradle引入

implementation project(':sample-host-lib')
image.png

在setting.gradle里添加2个project配置:

include ':introduce-shadow-lib'
project(':introduce-shadow-lib').projectDir = new File('introduce-shadow-lib')
include ':sample-host-lib'
project(':sample-host-lib').projectDir = new File('sample-host-lib')

这里根据我自己的项目,是唤起云游戏,不过这里不涉及云游戏的代码,宿主app里点击某个按钮触发:

public void enterShadow(String openId, String accessToken, String gameServer, String zoneId, String gameId, boolean debug,String pluginVersion,String pluginUrl,String managerVersion,String managerUrl) {
//        /data/user/0/ 应用包名/files
        HostUiLayerProvider.setParams(openId,accessToken,gameServer,zoneId,gameId,debug);
        SharedPreferences share = reactContext.getSharedPreferences("startCloudVersion", Context.MODE_PRIVATE);
        //plugin
        String start_pluginVersion = share.getString("start_pluginVersion","");// 得到sp数据中的值
        String pluginName = "xxx.zip";
        String pluginDir = reactContext.getFilesDir()+"/"+pluginName;
        File pluginfile = new File(pluginDir);
        if (TextUtils.isEmpty(start_pluginVersion)) {//本地不存在云游戏包
            if (pluginfile.exists()) {//避免下载一半关闭app
                pluginfile.delete();
            }
            checkPluginFiles(pluginVersion,pluginUrl);
        }else {
            if (start_pluginVersion.equals(pluginVersion)) {//本地存在云游戏包且不需更新
                checkPluginFiles(pluginVersion,pluginUrl);
            } else {//本地存在云游戏包但需要更新
                if (pluginfile.exists()) {
                    pluginfile.delete();
                }
                checkPluginFiles(pluginVersion,pluginUrl);
            }
        }

        //manager
        String managerName = "xxx.apk";
        String managerDir = reactContext.getFilesDir()+"/"+managerName;
        File managerfile = new File(managerDir);
        String start_managerVersion = share.getString("start_managerVersion","");// 得到sp数据中的值
        if (TextUtils.isEmpty(start_managerVersion)) {//本地不存在云游戏包
            if (managerfile.exists()) {
                managerfile.delete();
            }
            checkManagerFiles(managerVersion,managerUrl);
        }else {
            if (start_managerVersion.equals(managerVersion)) {//本地存在云游戏包且不需更新
                checkManagerFiles(managerVersion,managerUrl);
            } else {//本地存在云游戏包但需要更新
                if (managerfile.exists()) {
                    managerfile.delete();
                }
                checkManagerFiles(managerVersion,managerUrl);
            }
        }
    }

因为shadow只是单纯的插件化功能,并没有做到版本更新机制,所以这块是需要我们自己去写判断逻辑的。

然后判断版本号,下载plugin插件到本地内部目录(不要放在公共目录,会有篡改风险),这里涉及到了包的下载和存储代码:

private void checkPluginFiles(String version,String downloadUrl){
        String pluginUrl = downloadUrl;
        String pluginName = "xxx.zip";
        String pluginDir = reactContext.getFilesDir()+"/"+pluginName;
        File pluginfile = new File(pluginDir);
        if (!pluginfile.exists()) {
            WritableMap map = Arguments.createMap();
            map.putString("downloadStatus", "downloading");
            getReactApplicationContext()
                    .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
                    .emit("shadowDownloadEmit", map);
            OkHttpClient.Builder builder = new OkHttpClient.Builder().connectTimeout(20, TimeUnit.SECONDS)
                    .writeTimeout(5, TimeUnit.SECONDS)
                    .readTimeout(5, TimeUnit.SECONDS);
            Request request = new Request.Builder().url(pluginUrl).build();

            builder.build().newCall(request).enqueue(new okhttp3.Callback() {
                @Override
                public void onFailure(Call call, IOException e) {
                 
                    if (pluginfile.exists()) {
                        pluginfile.delete();
                    }
                }
                @Override
                public void onResponse(Call call, Response response) throws IOException {
                    InputStream is = null;
                    byte[] buf = new byte[4096];
                    int len = 0;
                    FileOutputStream fos = null;
                    // 储存下载文件的目录
                    String savePath = reactContext.getFilesDir().getAbsolutePath();
                    try {

                        is = response.body().byteStream();
                        long total = response.body().contentLength();
                        File file = new File(savePath, pluginName);
                        fos = new FileOutputStream(file);
                        long sum = 0;
                        int lastprogress = 0;
                        while ((len = is.read(buf)) != -1) {
                            fos.write(buf, 0, len);
                            sum += len;
                            int progress = (int) (sum * 1.0f / total * 100);
                            // 下载中
                     
//                            Log.d("enterShadowPluginprog","进度11:"+progress);
                        }
                        fos.flush();
                    } catch (Exception e) {
                        
                        if (pluginfile.exists()) {
                            pluginfile.delete();
                        }
                        e.printStackTrace();
                    } finally {
                        try {
                            if (is != null)
                                is.close();
                        } catch (IOException e) {
                        }
                        try {
                            if (fos != null)
                                fos.close();
                        } catch (IOException e) {
                        }
                    }
                    
                    pluginExist = true;
                    loadCloudGame();
                }
            });
        } else {
            pluginExist = true;
        }
        if (managerExist&&pluginExist) {
            loadCloudGame();
        }
    }

manager工具的下载同理,就不贴代码了。

然后执行loadCoundGame唤起插件:

private void loadCloudGame() {
        if (managerExist&&pluginExist) {
            PluginManager pluginManager = InitApplication.getPluginManager();
            final LinearLayout linearLayout = new LinearLayout(getReactApplicationContext());
            final int FROM_ID_START_ACTIVITY = 1001;
            final int FROM_ID_CALL_SERVICE = 1002;
//        Activity activity = reactContext.getCurrentActivity();
            linearLayout.setOrientation(LinearLayout.VERTICAL);
            pluginManager.enter(reactContext, FROM_ID_START_ACTIVITY, new Bundle(), new EnterCallback() {
                @Override
                public void onShowLoadingView(View view) {
//                activity.setContentView(view);//显示Manager传来的Loading页面
                }

                @Override
                public void onCloseLoadingView() {
//                activity.setContentView(linearLayout);
                }

                @Override
                public void onEnterComplete() {

                }
            });
    }

修改introduce-shadow-lib里的InitApplication代码,主要是修改本地加载路径:

public static void onApplicationCreate(Application application) {
        //Log接口Manager也需要使用,所以主进程也初始化。
        LoggerFactory.setILoggerFactory(new AndroidLoggerFactory());

        if (isProcess(application, ":plugin")) {
            //在全动态架构中,Activity组件没有打包在宿主而是位于被动态加载的runtime,
            //为了防止插件crash后,系统自动恢复crash前的Activity组件,此时由于没有加载runtime而发生classNotFound异常,导致二次crash
            //因此这里恢复加载上一次的runtime
            DynamicRuntime.recoveryRuntime(application);
        }

        FixedPathPmUpdater fixedPathPmUpdater
                = new FixedPathPmUpdater(new File(application.getFilesDir()+"/xxx.apk"));
//                = new FixedPathPmUpdater(new File("/data/local/tmp/xxx.apk"));
        boolean needWaitingUpdate
                = fixedPathPmUpdater.wasUpdating()//之前正在更新中,暗示更新出错了,应该放弃之前的缓存
                || fixedPathPmUpdater.getLatest() == null;//没有本地缓存
        Future<File> update = fixedPathPmUpdater.update();
        if (needWaitingUpdate) {
            try {
                update.get();//这里是阻塞的,需要业务自行保证更新Manager足够快。
            } catch (Exception e) {
                throw new RuntimeException("Sample程序不容错", e);
            }
        }
        sPluginManager = new DynamicPluginManager(fixedPathPmUpdater);
    }

修改sample-host-lib里的HostUiLayerProvider类,主要是用于传参给插件,因为宿主和插件是不同进程,所以涉及到IPC进程间的通信,可以使用AIDL或者SharedPreferences,根据自身需要,因为我们传参数少,都是基本数据类型,因此使用SharedPreferences。添加setParams和getParams2个方法:

public static void setParams(String openId) {

        SharedPreferences sharedPreferences =  mHostApplicationContext.getSharedPreferences("startCloudData", Context.MODE_MULTI_PROCESS);//向sp中传值
        SharedPreferences.Editor editor = sharedPreferences.edit();//获取编辑器
        //存储数据时选用对应类型的方法
        editor.putString("start_openId",openId);
        
        //提交保存数据
        editor.commit();

    }

    public static Bundle getParams() {
        final Bundle params = new Bundle();
        SharedPreferences share = mHostApplicationContext.getSharedPreferences("startCloudData", Context.MODE_MULTI_PROCESS);
        String openId = share.getString("start_openId","");// 得到sp数据中的值
        return params;
    }

至此宿主里的配置就完成了。

3:manager项目

依旧参考shadowdemo里的manager-project,这里改动量很小,只是下载的插件地址修改,修改SamplePluginManager类:

@Override
    public void enter(final Context context, long fromId, Bundle bundle, final EnterCallback callback) {
        String pluginName = context.getFilesDir()+"/xxx.zip";
        if (fromId == Constant.FROM_ID_START_ACTIVITY) {
            bundle.putString(Constant.KEY_PLUGIN_ZIP_PATH, pluginName);
            bundle.putString(Constant.KEY_PLUGIN_PART_KEY, "sample-plugin");
            bundle.putString(Constant.KEY_ACTIVITY_CLASSNAME, "com.tencent.shadow.sample.plugin.MainActivity");
            onStartActivity(context, bundle, callback);
        } else if (fromId == Constant.FROM_ID_CALL_SERVICE) {
            callPluginService(context);
        } else {
            throw new IllegalArgumentException("不认识的fromId==" + fromId);
        }
    }

如果是使用的server,同样修改callPluginService里的下载地址。

依赖配置参考demo和宿主里的就行,没有特殊的地方。

踩坑1:如果打包运行后遇到so文件找不到,可能是你本地项目的abi配置不对。不同手机有不同的处理器,宿主app里如果没有32位so文件,插件化manage-project跟随手机系统默认为64位abi,会从arm64-v8a目录里读取so文件,但宿主app只配置了armeabiv-v7a,使用的三方SDK里的so文件只会存储在armeabiv-v7a目录,导致manager找不到so文件,解决方案:在manage-project里重写getAbi方法,返回armeabiv-v7a,告诉系统读取armeabiv-v7a目录下的so文件。在SamplePluginManager里重写getAbi:

    @Override
    public String getAbi() {
        return "armeabi-v7a";
    }

通过./gradlew assembleRelease构建manager包,放到远程服务上,至此manager完成。

4:插件plugin项目

同样参考shadowdemo里的-project,首先依赖参考demo和宿主,无特殊。然后修改plugin-app里build.gradle里的applicationId和宿主的一样。配置sample-host-lib项目,除了下面的2块,其他的都一样:

引用时一定要用pluginCompileOnly:

    //注意sample-host-lib要用compileOnly编译而不打包在插件中。在packagePlugin任务中配置hostWhiteList允许插件访问宿主的类。
    pluginCompileOnly project(":sample-host-lib")
    normalImplementation project(":sample-host-lib")

在打包脚本里添加sample.host.lib白名单:

          release {
                loaderApkConfig = new Tuple2('sample-loader-release.apk', ':sample-loader:assembleRelease')
                runtimeApkConfig = new Tuple2('sample-runtime-release.apk', ':sample-runtime:assembleRelease')
                 pluginApks {
                     pluginApk1 {
                         businessName = 'demo'
                         partKey = 'sample-plugin'
                         buildTask = 'assemblePluginRelease'
                         apkName = 'plugin-app-plugin-release.apk'
                         apkPath = 'plugin-app/build/outputs/apk/plugin/release/plugin-app-plugin-release.apk'
                         hostWhiteList = ["com.tencent.shadow.sample.host.lib"]
                     }
                 }
            }

在MainActivity里就和正常开发app一样,但是如何获取宿主的传参呢,见代码:

protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        HostUiLayerProvider.init(this);
        Bundle paramsBundle = HostUiLayerProvider.getParams();
        final LinearLayout linearLayout = new LinearLayout(this);

        final String openId = paramsBundle.getString("openId");// 得到sp数据中的值
   
        final View view = new View(this);
        if (savedInstanceState == null) {
            view.post(new Runnable() {
                @Override
                public void run() {
                   
                }
            });
        }
        linearLayout.addView(view);
        setContentView(linearLayout);

    }

踩坑2:通过日志发现,onCreate会执行多次,目前不清楚是我集成的云游戏导致还是shadow导致,所以加了一层判断: if (savedInstanceState == null) {}

踩坑3:我们项目因为集成的是云游戏,里面有多个activity,并且会有某个activity需要销毁的问题,但是shadow的plugin默认activity是公用的同一个,销毁一个,整个会销毁,解决方案,在sample-loader里的SampleComponentManager添加自定义activity:

@Override
    public ComponentName onBindContainerActivity(ComponentName pluginActivity) {
        switch (pluginActivity.getClassName()) {
            /**
             * 这里配置对应的对应关系
             */
            case "com.tencent.start.uicomponent.activity.StartCloudGameActivity":
                return new ComponentName(context, SINGLE_INSTANCE_ACTIVITY);
            case "com.tencent.start.uicomponent.activity.StartCloudGameLaunchActivity":
                return new ComponentName(context, SINGLE_TASK_ACTIVITY);
            case "com.tencent.start.uicomponent.activity.StartCloudGamePlayActivity":
                return new ComponentName(context, SINGLE_TASK_STARTCLOUNDGAMEPLAY_ACTIVITY);
        }
        return new ComponentName(context, DEFAULT_ACTIVITY);
    }

同时需要在sample-runtime里添加这些activity的空实现,参考demo里的PluginDefaultProxyActivity。同时每添加一个activity,都需要在宿主里introduce-shadow-lib的manifest里添加activity配置。

最后./gradlew packageReleasePlugin进行构建将整个zip包放到远程服务上,至此plugin完成。

以上是本人的shadow实践,技术有限,有不对的地方还请指教,谢谢。

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