安卓插件化shadow实践
原文地址 www.jianshu.com
背景:项目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实践,技术有限,有不对的地方还请指教,谢谢。