背景:項(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):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')
在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ù)有限徙瓶,有不對的地方還請指教,謝謝嫉称。