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

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

說明:涉及的安卓知識多而雜梅肤,還是最好先把原理過一遍司蔬,不需要完全理解,至少有個大體的運(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地址對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:測試代碼

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

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

2:宿主

話不多說黔寇,直接進(jìn)主題偶器,宿主的跟目錄build.gradle里引用shadow,先設(shè)置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的源碼發(fā)布到了自己的maven倉庫屏轰,記得更改下版本號和下載信息。

在app的build.gradle引入:

    //如果introduce-shadow-lib發(fā)布到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')

這里根據(jù)我自己的項(xiàng)目,是喚起云游戲检眯,不過這里不涉及云游戲的代碼厘擂,宿主app里點(diǎn)擊某個按鈕觸發(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只是單純的插件化功能,并沒有做到版本更新機(jī)制锰瘸,所以這塊是需要我們自己去寫判斷邏輯的刽严。

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

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","進(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傳來的Loading頁面
                }

                @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")) {
            //在全動態(tài)架構(gòu)中,Activity組件沒有打包在宿主而是位于被動態(tài)加載的runtime实蓬,
            //為了防止插件crash后茸俭,系統(tǒng)自動恢復(fù)crash前的Activity組件吊履,此時由于沒有加載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()//之前正在更新中调鬓,暗示更新出錯了艇炎,應(yīng)該放棄之前的緩存
                || fixedPathPmUpdater.getLatest() == null;//沒有本地緩存
        Future<File> update = fixedPathPmUpdater.update();
        if (needWaitingUpdate) {
            try {
                update.get();//這里是阻塞的,需要業(yè)務(wù)自行保證更新Manager足夠快腾窝。
            } catch (Exception e) {
                throw new RuntimeException("Sample程序不容錯", e);
            }
        }
        sPluginManager = new DynamicPluginManager(fixedPathPmUpdater);
    }

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

public static void setParams(String openId) {

        SharedPreferences sharedPreferences =  mHostApplicationContext.getSharedPreferences("startCloudData", Context.MODE_MULTI_PROCESS);//向sp中傳值
        SharedPreferences.Editor editor = sharedPreferences.edit();//獲取編輯器
        //存儲數(shù)據(jù)時選用對應(yīng)類型的方法
        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疆柔,這里改動量很小,只是下載的插件地址修改镶柱,修改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("不認(rèn)識的fromId==" + fromId);
        }
    }

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

依賴配置參考demo和宿主里的就行歇拆,沒有特殊的地方鞋屈。

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

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

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

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

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

引用時一定要用pluginCompileOnly:

    //注意sample-host-lib要用compileOnly編譯而不打包在插件中蚓聘。在packagePlugin任務(wù)中配置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里就和正常開發(fā)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數(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:通過日志發(fā)現(xiàn),onCreate會執(zhí)行多次侣签,目前不清楚是我集成的云游戲?qū)е逻€是shadow導(dǎo)致塘装,所以加了一層判斷: if (savedInstanceState == null) {}

踩坑3:我們項(xiàng)目因?yàn)榧傻氖窃朴螒颍锩嬗卸鄠€activity影所,并且會有某個activity需要銷毀的問題蹦肴,但是shadow的plugin默認(rèn)activity是公用的同一個,銷毀一個型檀,整個會銷毀冗尤,解決方案,在sample-loader里的SampleComponentManager添加自定義activity:

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

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

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

以上是本人的shadow實(shí)踐无埃,技術(shù)有限徙瓶,有不對的地方還請指教,謝謝嫉称。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末侦镇,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子织阅,更是在濱河造成了極大的恐慌壳繁,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,968評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件荔棉,死亡現(xiàn)場離奇詭異闹炉,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)润樱,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評論 2 382
  • 文/潘曉璐 我一進(jìn)店門渣触,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人壹若,你說我怎么就攤上這事嗅钻≡肀” “怎么了?”我有些...
    開封第一講書人閱讀 153,220評論 0 344
  • 文/不壞的土叔 我叫張陵啊犬,是天一觀的道長灼擂。 經(jīng)常有香客問我壁查,道長觉至,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,416評論 1 279
  • 正文 為了忘掉前任睡腿,我火速辦了婚禮语御,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘席怪。我一直安慰自己应闯,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,425評論 5 374
  • 文/花漫 我一把揭開白布挂捻。 她就那樣靜靜地躺著碉纺,像睡著了一般。 火紅的嫁衣襯著肌膚如雪刻撒。 梳的紋絲不亂的頭發(fā)上骨田,一...
    開封第一講書人閱讀 49,144評論 1 285
  • 那天,我揣著相機(jī)與錄音声怔,去河邊找鬼态贤。 笑死,一個胖子當(dāng)著我的面吹牛醋火,可吹牛的內(nèi)容都是我干的悠汽。 我是一名探鬼主播,決...
    沈念sama閱讀 38,432評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼芥驳,長吁一口氣:“原來是場噩夢啊……” “哼柿冲!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起兆旬,我...
    開封第一講書人閱讀 37,088評論 0 261
  • 序言:老撾萬榮一對情侶失蹤假抄,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后爵憎,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體慨亲,經(jīng)...
    沈念sama閱讀 43,586評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,028評論 2 325
  • 正文 我和宋清朗相戀三年宝鼓,在試婚紗的時候發(fā)現(xiàn)自己被綠了刑棵。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,137評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡愚铡,死狀恐怖蛉签,靈堂內(nèi)的尸體忽然破棺而出胡陪,到底是詐尸還是另有隱情,我是刑警寧澤碍舍,帶...
    沈念sama閱讀 33,783評論 4 324
  • 正文 年R本政府宣布柠座,位于F島的核電站,受9級特大地震影響片橡,放射性物質(zhì)發(fā)生泄漏妈经。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,343評論 3 307
  • 文/蒙蒙 一捧书、第九天 我趴在偏房一處隱蔽的房頂上張望吹泡。 院中可真熱鬧,春花似錦经瓷、人聲如沸爆哑。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽揭朝。三九已至,卻和暖如春色冀,著一層夾襖步出監(jiān)牢的瞬間啃匿,已是汗流浹背沸呐。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評論 1 262
  • 我被黑心中介騙來泰國打工烘绽, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留瘟檩,地道東北人。 一個月前我還...
    沈念sama閱讀 45,595評論 2 355
  • 正文 我出身青樓伶氢,卻偏偏與公主長得像趟径,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子癣防,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,901評論 2 345