安卓插件化shadow實(shí)踐

背景:項(xiàng)目app里需要嵌入云游戲,然而云游戲的發(fā)版次數(shù)頻繁,直接嵌入原生app里就會(huì)頻繁走合規(guī)檢測(cè),才可以提交審核發(fā)布,流程長(zhǎng),效率低。

說(shuō)明:涉及的安卓知識(shí)多而雜,還是最好先把原理過(guò)一遍,不需要完全理解,至少有個(gè)大體的運(yùn)行流程結(jié)構(gòu)。https://github.com/Tencent/Shadow/tree/master/projects/sample#%E8%BF%90%E8%A1%8C%E6%96%B9%E6%B3%95地址對(duì)shadow做了整體結(jié)構(gòu)的大概描述。

1:下載shadow的demo

首先clone下shadow項(xiàng)目,地址:https://github.com/Tencent/Shadow.git

看下結(jié)構(gòu):
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:測(cè)試代碼

其中projects/sample/下的maven是依賴(lài)遠(yuǎn)程shadow的源碼,就是類(lèi)似我們實(shí)際開(kāi)發(fā)的代碼。

projects/sample/下的source是依賴(lài)本地SDK的事例,可以debug調(diào)試查看shadow源碼,修改本地shadow的源碼可以直接運(yùn)行生效。

2:宿主

話不多說(shuō),直接進(jìn)主題,宿主的跟目錄build.gradle里引用shadow,先設(shè)置shadow_version版本號(hào):

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"
            //一個(gè)只讀賬號(hào)兼容Github Packages暫時(shí)不支持匿名下載
            //https://github.community/t/download-from-github-package-registry-without-authentication/14407
            credentials {
                username = 'readonlypat'
                password = '\u0067hp_s3VOOZnLf1bTyvHWblPfaessrVYyEU4JdNbs'
            }
        }

如果將shadow的源碼發(fā)布到了自己的maven倉(cāng)庫(kù),記得更改下版本號(hào)和下載信息。

在app的build.gradle引入:

    //如果introduce-shadow-lib發(fā)布到Maven,在pom中寫(xiě)明此依賴(lài),宿主就不用寫(xiě)這個(gè)依賴(lài)了。
    implementation "com.tencent.shadow.dynamic:host:$shadow_version"

宿主app里引入introduce-shadow-lib(可以直接從demo的宿主里拷貝過(guò)來(lái)),在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個(gè)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')

這里根據(jù)我自己的項(xiàng)目,是喚起云游戲,不過(guò)這里不涉及云游戲的代碼,宿主app里點(diǎn)擊某個(gè)按鈕觸發(fā):

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/ 應(yīng)用包名/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數(shù)據(jù)中的值
        String pluginName = "xxx.zip";
        String pluginDir = reactContext.getFilesDir()+"/"+pluginName;
        File pluginfile = new File(pluginDir);
        if (TextUtils.isEmpty(start_pluginVersion)) {//本地不存在云游戲包
            if (pluginfile.exists()) {//避免下載一半關(guān)閉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數(shù)據(jù)中的值
        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);
            }
        }
    }

因?yàn)閟hadow只是單純的插件化功能,并沒(méi)有做到版本更新機(jī)制,所以這塊是需要我們自己去寫(xiě)判斷邏輯的。

然后判斷版本號(hào),下載plugin插件到本地內(nèi)部目錄(不要放在公共目錄,會(huì)有篡改風(fēng)險(xiǎn)),這里涉及到了包的下載和存儲(chǔ)代碼:

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;
                    // 儲(chǔ)存下載文件的目錄
                    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","進(jìn)度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工具的下載同理,就不貼代碼了。

然后執(zhí)行l(wèi)oadCoundGame喚起插件:

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傳來(lái)的Loading頁(yè)面
                }

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

                @Override
                public void onEnterComplete() {

                }
            });
    }

修改introduce-shadow-lib里的InitApplication代碼,主要是修改本地加載路徑:

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

        if (isProcess(application, ":plugin")) {
            //在全動(dòng)態(tài)架構(gòu)中,Activity組件沒(méi)有打包在宿主而是位于被動(dòng)態(tài)加載的runtime,
            //為了防止插件crash后,系統(tǒng)自動(dòng)恢復(fù)crash前的Activity組件,此時(shí)由于沒(méi)有加載runtime而發(fā)生classNotFound異常,導(dǎo)致二次crash
            //因此這里恢復(fù)加載上一次的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()//之前正在更新中,暗示更新出錯(cuò)了,應(yīng)該放棄之前的緩存
                || fixedPathPmUpdater.getLatest() == null;//沒(méi)有本地緩存
        Future<File> update = fixedPathPmUpdater.update();
        if (needWaitingUpdate) {
            try {
                update.get();//這里是阻塞的,需要業(yè)務(wù)自行保證更新Manager足夠快。
            } catch (Exception e) {
                throw new RuntimeException("Sample程序不容錯(cuò)", e);
            }
        }
        sPluginManager = new DynamicPluginManager(fixedPathPmUpdater);
    }

修改sample-host-lib里的HostUiLayerProvider類(lèi),主要是用于傳參給插件,因?yàn)樗拗骱筒寮遣煌M(jìn)程,所以涉及到IPC進(jìn)程間的通信,可以使用AIDL或者SharedPreferences,根據(jù)自身需要,因?yàn)槲覀儌鲄?shù)少,都是基本數(shù)據(jù)類(lèi)型,因此使用SharedPreferences。添加setParams和getParams2個(gè)方法:

public static void setParams(String openId) {

        SharedPreferences sharedPreferences =  mHostApplicationContext.getSharedPreferences("startCloudData", Context.MODE_MULTI_PROCESS);//向sp中傳值
        SharedPreferences.Editor editor = sharedPreferences.edit();//獲取編輯器
        //存儲(chǔ)數(shù)據(jù)時(shí)選用對(duì)應(yīng)類(lèi)型的方法
        editor.putString("start_openId",openId);
        
        //提交保存數(shù)據(jù)
        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數(shù)據(jù)中的值
        return params;
    }

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

3:manager項(xiàng)目

依舊參考shadowdemo里的manager-project,這里改動(dòng)量很小,只是下載的插件地址修改,修改SamplePluginManager類(lèi):

@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("不認(rèn)識(shí)的fromId==" + fromId);
        }
    }

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

依賴(lài)配置參考demo和宿主里的就行,沒(méi)有特殊的地方。

踩坑1:如果打包運(yùn)行后遇到so文件找不到,可能是你本地項(xiàng)目的abi配置不對(duì)。不同手機(jī)有不同的處理器,宿主app里如果沒(méi)有32位so文件,插件化manage-project跟隨手機(jī)系統(tǒng)默認(rèn)為64位abi,會(huì)從arm64-v8a目錄里讀取so文件,但宿主app只配置了armeabiv-v7a,使用的三方SDK里的so文件只會(huì)存儲(chǔ)在armeabiv-v7a目錄,導(dǎo)致manager找不到so文件,解決方案:在manage-project里重寫(xiě)getAbi方法,返回armeabiv-v7a,告訴系統(tǒng)讀取armeabiv-v7a目錄下的so文件。在SamplePluginManager里重寫(xiě)getAbi:

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

通過(guò)./gradlew assembleRelease構(gòu)建manager包,放到遠(yuǎn)程服務(wù)上,至此manager完成。

4:插件plugin項(xiàng)目

同樣參考shadowdemo里的-project,首先依賴(lài)參考demo和宿主,無(wú)特殊。然后修改plugin-app里build.gradle里的applicationId和宿主的一樣。配置sample-host-lib項(xiàng)目,除了下面的2塊,其他的都一樣:

引用時(shí)一定要用pluginCompileOnly:

    //注意sample-host-lib要用compileOnly編譯而不打包在插件中。在packagePlugin任務(wù)中配置hostWhiteList允許插件訪問(wèn)宿主的類(lèi)。
    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里就和正常開(kāi)發(fā)app一樣,但是如何獲取宿主的傳參呢,見(jiàn)代碼:

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數(shù)據(jù)中的值
   
        final View view = new View(this);
        if (savedInstanceState == null) {
            view.post(new Runnable() {
                @Override
                public void run() {
                   
                }
            });
        }
        linearLayout.addView(view);
        setContentView(linearLayout);

    }

踩坑2:通過(guò)日志發(fā)現(xiàn),onCreate會(huì)執(zhí)行多次,目前不清楚是我集成的云游戲?qū)е逻€是shadow導(dǎo)致,所以加了一層判斷: if (savedInstanceState == null) {}

踩坑3:我們項(xiàng)目因?yàn)榧傻氖窃朴螒?,里面有多個(gè)activity,并且會(huì)有某個(gè)activity需要銷(xiāo)毀的問(wèn)題,但是shadow的plugin默認(rèn)activity是公用的同一個(gè),銷(xiāo)毀一個(gè),整個(gè)會(huì)銷(xiāo)毀,解決方案,在sample-loader里的SampleComponentManager添加自定義activity:

@Override
    public ComponentName onBindContainerActivity(ComponentName pluginActivity) {
        switch (pluginActivity.getClassName()) {
            /**
             * 這里配置對(duì)應(yīng)的對(duì)應(yīng)關(guān)系
             */
            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);
    }

同時(shí)需要在sample-runtime里添加這些activity的空實(shí)現(xiàn),參考demo里的PluginDefaultProxyActivity。同時(shí)每添加一個(gè)activity,都需要在宿主里introduce-shadow-lib的manifest里添加activity配置。

最后./gradlew packageReleasePlugin進(jìn)行構(gòu)建將整個(gè)zip包放到遠(yuǎn)程服務(wù)上,至此plugin完成。

以上是本人的shadow實(shí)踐,技術(shù)有限,有不對(duì)的地方還請(qǐng)指教,謝謝。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容