1.背景
博主当前所在的项目是以 sdk 的方式为其他业务提供服务,具体的结构如下图所示,业务1,2,3是不同的应用,分别处于不同的进程,引用的 sdk 内部会通过跨进程的方式和我们进行交互。
我们在实际的工作当中发现会有以下的问题:
1.业务接入成本大
特别是开发的过程当中,sdk 变更会比较频繁,每次变更都需要通知业务去更新,然后重新出包,更新成本较大。
2.不方便测试
由于我们是以 sdk 的方式提供服务,具体的业务应用场景在业务端,每次测试都需要发布一个新的版本,然后更新业务代码,打包之后才能进行测试。
3.业务依赖的版本不一致,可能引发异常
不同的业务依赖的版本不一致,导致有可能需要做新旧版本的兼容逻辑,增加逻辑的复杂性。
我们想到,使用动态加载的方式也许能够解决这些问题。
2.解决方案
dex 动态加载在安卓上已经是一个比较成熟的方案,如果把 sdk 做成一个壳,只保留接口,而接口的实现放在远端的 dex 文件当中,sdk 初始化的时候,从远端获取到 dex 文件,并使用 dex 文件当中的接口实现来初始化接口类,似乎就可以实现我们的想法了。
-
首先我将现有的 public 方法全部都抽成了一个 interface
-
然后新建一个 RemoteDex 模块,将接口的实现,全部都放在这个模块当中
-
单独编译 RemoteDex 模块,得到模块的 jar 包,里面包含了 dex 文件
-
在 sdk 初始化的时候,通过 dex 文件去实例化接口
发现可以使用,这种方式可行。
3.需要解决的问题
3.1 dex文件如何访问
最开始,我们想到可以将 dex 文件存放在一个公共的目录当中,不同的应用从这个目录当中读取文件进行初始化,但实际上,处理起来会涉及到不同的读写权限,而且还需要保证其他进程只读,而我们的进程可读可写,有点麻烦。
最后我们还是使用 contentProvider 的方式来让其他进程获取文件。在编译阶段,将 dex 文件复制到 assets 目录当中,其他应用获取时,服务进程会从本地的 assets 目录获取 dex 文件,并返回。
class DexProvider : ContentProvider() {
private val TAG = "DexProvider"
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
val dexPath = context?.cacheDir?.absolutePath + "/${DEX_FILE_NAME}"
val dexFile = File(dexPath)
Log.i(TAG, "openFile dexFile ="+dexFile)
if (!dexFile.exists() || (dexFile.exists() && Utils.md5(dexFile) != BuildConfig.REMOTE_DEX_MD5)) {
Log.i(TAG, "start copy dex")
val inputStream = context?.assets?.open("RemoteDex.jar")
inputStream.use {
val outputStream = FileOutputStream(dexFile)
it?.copyTo(outputStream)
outputStream.close()
Log.i(TAG, "copy dex success")
}
}
if (dexFile.exists()) {
Log.i(TAG, "dexFile exists")
return ParcelFileDescriptor.open(dexFile, ParcelFileDescriptor.MODE_READ_ONLY)
}
Log.i(TAG, "dexFile not exists")
return super.openFile(uri, mode)
}
......
}
3.2 如何编译
我们每次编译的时候都需要能够确保当前 RemoteDex 模块里面的最新的代码编出来的 dex 文件都能够被放到 assets 目录当中,所以我们需要找到一个合适的 hook 点,在项目正式编译之前,先触发模块单独编译,并将 dex 写入 assets 目录,然后再进行整体编译。
我找到了 afterEvaluate 这个时机,afterEvaluate 在项目的所有脚本和插件被完全评估之后执行,这时正好可以执行我们的脚本,触发模块单独编译,完成之后再整体编译。
afterEvaluate {
// 编译RemoteDex模块,并将dex写入assets目录
def paramValue = project.hasProperty("executeShell") ? project.findProperty("executeShell") : "yes"
println "Parameter value: $paramValue"
if (paramValue == "yes") {
def osName = System.getenv("OS")
if (osName != null && osName.toLowerCase().contains("windows")) {
// Windows 系统
println "Running on Windows"
project.exec {
def scriptPath = file('../API/Setting/RemoteDex/gradleDex.bat')
commandLine("cmd", "/c", scriptPath)
}
} else {
//流水线或其他系统
println "Running on other system"
project.exec {
commandLine 'sh', '../API/Setting/RemoteDex/gradleDex.sh'
}
}
}
}
这里需要注意的是,在我们自定义的编译脚本当中,要增加参数,来区分是单独编译还是完整编译,否则会形成死循环。
#!/bin/bash
# 在gradle中调用的脚本
echo "start RemoteDex build"
buildDir="build/outputs/aar"
buildOutputReleaseDir="build/intermediates/library_assets/release/out"
buildOutputDebugDir="build/intermediates/library_assets/debug/out"
sourceAAR="RemoteDex-release.aar"
sourceJar="classes.jar"
outputDex="RemoteDex.jar"
assetFolder="src/main/assets"
cd ../
./gradlew :API:Setting:RemoteDex:assembleRelease -PexecuteShell='no'
# 2.解压aar文件
cd API/Setting/RemoteDex
cd $buildDir
unzip -o $sourceAAR
# 3.将jar包打包成dex文件
echo "d8 $sourceJar to $outputDex"
../../../script/d8 $sourceJar --output ./$outputDex
# 4.将dex复制到对应的asset目录下和对应的output目录下
cd ../../../
if [ -d "$assetFolder" ]; then
echo "assetFolder exist"
if [ -e "$assetFolder/$outputDex" ]; then
echo "delete origin dex file"
rm "$assetFolder/$outputDex"
fi
else
echo "assetFolder not exist,auto create"
mkdir $assetFolder
fi
cp "$buildDir/$outputDex" "$assetFolder"
cp -f "$buildDir/$outputDex" "$buildOutputReleaseDir"
cp -f "$buildDir/$outputDex" "$buildOutputDebugDir"
echo "end build"
4.总结
这样我们就实现了 sdk 远程加载的方式进行初始化。每个业务只需要引用一个壳工程,每次只需要服务进程更新即可,避免业务需要频繁更新 sdk 版本的问题和版本不一致的问题。