全动态化 , 能让软件在增删功能方面,用户基本达到无感知
(一)零反射,零HooK,全动态化,插件化框架,全网唯一结合启动优化的插件化架构
(二)零反射,零HooK,全动态化,插件化框架,全网唯一结合启动优化的插件化架构
(三)零反射,零HooK,全动态化,插件化框架,全网唯一结合启动优化的插件化架构
(四)零反射,零HooK,全动态化,插件化框架,全网唯一结合启动优化的插件化架构(五) 大型项目架构:全动态插件化+模块化+Kotlin+协程+Flow+Retrofit+JetPack+MVVM+极限瘦身+极限启动优化+架构示例+全网唯一
(六) 大型项目架构:解析全动态插件化框架WXDynamicPlugin是如何做到全动态化的?
(七) 还在不断升级发版吗?从0到1带你看懂WXDynamicPlugin全动态插件化框架
(八) Compose插件化:一个Demo带你入门Compose,同时带你入门插件化开发
(九) 花式高阶:插件化之Dex文件的高阶用法,极少人知道的秘密
一 、前言
WXDynamicPlugin全动态插件化框架开源也已经一个多月了,非常感谢各位网友的肯定和支持,断断续续也有些加我了解沟通一些问题。前面也有5篇系列性文章介绍说明,在此根据收到的反馈,再结合我之前5篇感觉有些没有介绍到的,再进行介绍补充。如果本文没有介绍到的,可以参考前面5篇相关文章介绍。
全动态化的意义
可以最大化不用发版:
插件化本身模块是可以做到动态化修改的,这样宿主不用发版,但是插件化第一次下载逻辑,加载插件的逻辑功能必须在宿主,这两部份逻辑如果需要修改,也做到动态化,那么对于框架的修改,插件的下载,加载修改可以做到全动态化,因为任何接入宿主的那部分代码逻辑不太可能一次性写得很完美,这样做能将全动态化做到最高。
本文介绍思路
二、插件化框架加载流程图
三、启动页默认插件化
因为宿主只是一个没有任何业务的空壳子,只包含了:
- 系统必要的基础控件组件
- 下载加载插件的逻辑。其他全在插件里面了。
为什么宿主apk里面集成系统必要的基础控件组件?
为了节省插件的体积,和减少下载插件的时间。我们试想:我们把必备的基础组件让宿主去集成,每一个插件都可以访问到,那么插件里面就不用集成,那么插件文件里面可以只有自己写的业务代码,没有依赖包的代码,这样插件体积最小,下载插件所需要的耗时也就最小。如果不这样,相同的组件,每个插件包都要依赖上,并打入插件包文件内,那么每个插件文件的体积都上去了,每个插件下载时间都多了,这样的话,还不如集成在宿主里面,因为这些组件也基本上是万年不怎么修改了。能够满足App的开发,这样和全动态发版没有什么影响。万不得已,兜底方案:可以在插件模块里面写个让宿主版本升级的功能就可以解决。
为什么下载和加载插件的逻辑写在宿主里面?
这个不难理解,如果宿主第一次下载的逻辑写在插件里面,那么插件第一次怎么到本地,怎么下载, 如果插件加载的逻辑在插件里面,那么插件第一次怎么加载到宿主里面,和宿主怎么关联。
本框架全动态化后面介绍:第一次下载插件,第一次加载插件会接入宿主,后面如果需要修改宿主下载插件逻辑,修改宿主加载逻辑,都可以动态化修改,不用去改宿主了,见后面介绍。
启动页默认插件: 基于上面思考后,那么第一次下载插件时,总要有个耗时,这段时间给用户展示的UI界面就是启动默认插件,这个插件默认也要集成到宿主,这也是没办法的事情。不过,这个启动默认插件是以文件资源形式存在宿主 Assets目录下的
,这样做的目的是,在第一次需要下载时候,因为宿主第一次启动没有任何UI,所以用这个展示,当宿主已经有下载好了插件之后,后面就不需要它了,启动时候就直接用 首页插件
了,因为已经有首页插件了,直接展示,启动速度就会更快了。
如下图所示:首页插件存放在assets下面
首页插件源码工程及可以配置默认下载时展示图片如下:
四、首页插件化
从上面加载流程图可以看到,首页插件是分两部分的:
- 首页HomeActivity 首屏展示的第一个Fragment
首页首屏展示的界面,全代码布局,是提前在启动框架里面布局完成,到首页时直接拿来渲染,以提高首屏启动速度。 - 首页HomeActivity 非首屏展示的其他Fragment
首屏其他Fragment 做成了纯代码 dex + 资源两部分 ,这样做的目的,还是为了减少 插件打包后的体积,如果合成一起,只能打包成apk形式,这样至少也得700k左右,这样分开打包,3个插件总共才72.8k
五、四大组件插件化框架SDK的插件化
四大组件本框架中,只做了三部分:
BroadcastReceiver
: 可以动态注册,完全不和宿主里面AndroidManifest
相关联,插件化SDK中未做任何处理Activity
: 注册在宿主AndroidManifest中有4个模式的代理activity占坑位:
<activity
android:name="com.wgllss.dynamic.plugin.runtime.PluginStandardActivity"
android:launchMode="standard"
android:theme="@style/LauncherTheme2" />
<activity
android:name="com.wgllss.dynamic.plugin.runtime.PluginSingleInstanceActivity"
android:launchMode="singleInstance"
android:theme="@style/LauncherTheme2" />
<activity
android:name="com.wgllss.dynamic.plugin.runtime.PluginSingleTaskActivity"
android:launchMode="singleTask"
android:theme="@style/LauncherTheme2" />
<activity
android:name="com.wgllss.dynamic.plugin.runtime.PluginSingleTopActivity"
android:launchMode="singleTop"
android:theme="@style/LauncherTheme2" />
ContentProvider
: 注册在宿主中一个作为容器的WXPluginContentProvider
占坑位,供分发其他ContentProvider: 注册如下图:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.wgllss.dynamic.host.lib.provider">
<application>
<provider
android:name="com.wgllss.dynamic.host.lib.provider.WXPluginContentProvider"
android:exported="true"
android:authorities="${applicationId}.wx.dynamic.plugin.author" />
</application>
</manifest>
WXPluginContentProvider
代码分发如下图:
class WXPluginContentProvider : ContentProvider() {
override fun onCreate(): Boolean {
return false
}
override fun query(uri: Uri, projection: Array<String>?, selection: String?, selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
uri.path?.let {
return WXProviderManager.instance.containsKey(it)?.query(uri, projection, selection, selectionArgs, sortOrder)
}
return null
}
override fun getType(uri: Uri): String? {
uri.path?.let {
return WXProviderManager.instance.containsKey(it)?.getType(uri)
}
return null
}
override fun insert(uri: Uri, values: ContentValues?): Uri? {
uri.path?.let {
return WXProviderManager.instance.containsKey(it)?.insert(uri, values)
}
return null
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int {
uri.path?.let {
return WXProviderManager.instance.containsKey(it)?.delete(uri, selection, selectionArgs) ?: 0
}
return 0
}
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int {
uri.path?.let {
return WXProviderManager.instance.containsKey(it)?.update(uri, values, selection, selectionArgs) ?: 0
}
return 0
}
}
Service
: 注册在宿主中,Service的onStartCommand
4种返回值模式,外加这4种返回值模式的单独进程的Service,供8种占坑为的代理Service:
<service
android:name="com.wgllss.dynamic.plugin.runtime.PluginStartStickyService"
android:enabled="true"
android:exported="true" />
<service
android:name="com.wgllss.dynamic.plugin.runtime.PluginStartNotStickyService"
android:enabled="true"
android:exported="true" />
<service
android:name="com.wgllss.dynamic.plugin.runtime.PluginStartRedeliverIntentService"
android:enabled="true"
android:exported="true" />
<service
android:name="com.wgllss.dynamic.plugin.runtime.PluginStartStickyCompatibilityService"
android:enabled="true"
android:exported="true" />
<service
android:name="com.wgllss.dynamic.plugin.runtime.PluginProcessStartStickyService"
android:enabled="true"
android:exported="true"
android:process=":processSticky" />
<service
android:name="com.wgllss.dynamic.plugin.runtime.PluginProcessStartNotStickyService"
android:enabled="true"
android:exported="true"
android:process=":processNotSticky" />
<service
android:name="com.wgllss.dynamic.plugin.runtime.PluginProcessStartRedeliverIntentService"
android:enabled="true"
android:exported="true"
android:process=":processRedeliver" />
<service
android:name="com.wgllss.dynamic.plugin.runtime.PluginProcessStartStickyCompatibilityService"
android:enabled="true"
android:exported="true"
android:process=":processStickyCompatibility" />
这里注册的占坑为的Activity,和Service 实际代码 分发代理的SDK 源码在 Maven-Wgllss-Dynamic-Plugin-RunTime-Apk 工程里面。这里要修改,是可以动态更新的,保证了动态化。
六、纯Java 、Kotlin代码的插件化
插件化框架中的纯java Kotlin业务代码,使用ANT 编程,执行命令直接将 业务代码打包好的Jar 处理成dex文件。涉及到一个命令,配置好d8环境变量后,执行task
workingDirPath=D:\android_software\android_sdk\android_sdk\build-tools\32.0.0\
def assembleDxCommand = tasks.create("assembleDxCommand", Exec) {
group = 'other'
description = "${name}到dx执行中..."
workingDir workingDirPath
it.commandLine 'cmd', "/c", "d8 --output ${outputDexFile.name} ${outputFile.name}"
}.dependsOn(copyTask.name)
七、Res下资源和 Assets下资源插件化
这两个直接放在对应目录下,直接打包成Apk形式就可以了 只需要注意两点:
- 插件下Rex资源的Resource资源的获取
- 插件下Assets资源获取
这里这两快的获取统一做成一个插件管理器,以插件的形式,可以动态修改,
在Maven-Wgllss-Dynamic-Plugin-Manager工程下。
获取插件中 Resourse
的核心代码如下:
fun getWebRes(): Resources {
val file = DynamicManageUtils.getDxFile(context, dldir, webResFileName)
val flags = (PackageManager.GET_META_DATA or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES
or PackageManager.GET_PROVIDERS or PackageManager.GET_RECEIVERS)
val packageManager = context.applicationContext.packageManager
val packageInfo = packageManager.getPackageArchiveInfo(file.absolutePath, flags)
val applicationInfo = packageInfo!!.applicationInfo
applicationInfo.publicSourceDir = file.absolutePath
applicationInfo.sourceDir = applicationInfo.publicSourceDir
MMKVHelp.saveWebResPath(file.absolutePath)
return packageManager.getResourcesForApplication(applicationInfo)
}
private fun getResourcesForApplication(file: File): Resources {
val flags = (PackageManager.GET_META_DATA or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES
or PackageManager.GET_PROVIDERS or PackageManager.GET_RECEIVERS)
val packageManager = context.applicationContext.packageManager
val packageInfo = packageManager.getPackageArchiveInfo(file.absolutePath, flags)
val applicationInfo = packageInfo!!.applicationInfo
applicationInfo.publicSourceDir = file.absolutePath
applicationInfo.sourceDir = applicationInfo.publicSourceDir
return packageManager.getResourcesForApplication(applicationInfo)
}
插件中真实调用获取 Assets
下资源如下:
class ImplWebViewClient : WebViewClient() {
private val strOfflineResources by lazy { MMKVHelp.getJsPath() }
private val webResAssets by lazy { PluginManager.instance.getWebRes().assets }
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?) = true
override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
view.loadUrl("javascript:loadImage()")
}
@RequiresApi(Build.VERSION_CODES.N)
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest?): WebResourceResponse? {
val url = request?.url.toString()
val lastSlash: Int = url.lastIndexOf("/")
if (lastSlash != -1) {
val suffix: String = url.substring(lastSlash + 1)
if (suffix.endsWith(".css")) {
if (strOfflineResources.contains(suffix)) {
val mimeType = "text/css"
val offlineRes = "css/"
val inputs = webResAssets.open("$offlineRes$suffix")
return WebResourceResponse(mimeType, "UTF-8", inputs)
} else {
android.util.Log.e("ImplWebViewClient", "request css :${url}")
}
}
if (suffix.endsWith(".js")) {
if (strOfflineResources.contains(suffix)) {
val mimeType = "application/x-javascript"
val offlineRes = "js/"
val inputs = webResAssets.open("$offlineRes$suffix")
return WebResourceResponse(mimeType, "UTF-8", inputs)
} else {
android.util.Log.e("ImplWebViewClient", "request js :${url}")
}
}
}
return super.shouldInterceptRequest(view, request)
}
}
八、SO的插件化
so的插件化很简单,直接调用 外部路径,一句话:
System.load(外部so路径)
九、全动态化 之 切换宿主内配置的插件包所在服务器下载地址,调试模式
-
如何在已经发布到生产环境线上的插件文件,改成使用测试环境的插件文件? 在宿主中 WX\WX-Maven\WX-Host\sample宿主工程
FaceImpl
类中,配置了默认下载插件文件的地址,即打包好的插件文件存放的服务器地址。我们可以将它改成测试环境地址,或者自己本机测试地址,获取其他服务器地址,怎么改呢?这里不改上面提到的这个类。 -
找到WX\WX-Maven\WX-Plugin\Maven-Wgllss-Dynamic-Plugin-Manager\Maven-Wgllss-Dynamic-Plugin-DownloadFace-Impl工程下的
DownLoadFaceImpl
-
修改
DownLoadFaceImpl
中override fun getHostL()
地址为测试地址,然后打包,具体不知道怎么打包可以看下前面文章介绍。 -
修改下WX\WX-Maven\WX-Plugin\Maven-Wgllss-Dynamic-Plugin-Sample\maven-wgllss-sample-loader-version工程下
LoaderVersionImpl
类下,这里只需要改下面代码中一行后的版本号就可以了,默认第一次版本号1000,后续往上升。我示例里面注释掉的这行:
override fun getCdlfd() = Triple("com.wgllss.dynamic.plugin.download_face.DownLoadFaceImpl", "classes_downloadface_impl_dex", 1000)
-
注意下上面版本文件配置, 记得修改上面总版本号,记得测试环境的地址服务器里面已经存在了打包好的插件所有文件(切换过去那边总要先存在嘛),并且修改好的总版本号一定要大于测试环境(切换过去的环境)上面存放的文件的版本号,这样就可以动态切换宿主所依赖的插件下载地址了。
-
这样改如果改错了,线上面其他的用户都切换全错了,可以先测一下吗?可以:
override fun getBaseL(): String {
if (TextUtils.isEmpty(baseXL)) {
baseXL = StringBuilder().append(getHostL()).append(DeviceIdUtil.getDeviceId()).append("/").append(BuildConfig.VERSION_CODE).append("/").toString()
}
return baseXL
}
看到修改地址文件里面 上面代码么?里面 DeviceIdUtil.getDeviceId()
,DeviceIdUtil
这个里面可以添加自己手机设备测试的唯一标识,这个标识在开发时可以宿主打印出来看下。服务器地址后面的带了该标识的目录。其他没有添加唯一标识目录的,框架里面默认设置成 dxde_m_p
,也可以自定义,如下图:
object DeviceIdUtil {
private const val isDeviceSelfSerial = false
//自己设备测试序号
private val setsD = setOf("B05F9543937A5BA61901FC14F2540C62DA3E86C2", "2F6039397BA7EEC402E7036339963B23810CCBFD")
//其他设备测试序号 命名可自定义
private const val ELSED = "dxde_m_p"
fun getDeviceId(): String {
if (isDeviceSelfSerial) {
//todo 模拟序列号
return "2F6039397BA7EEC402E7036339963B23810CCBFD"
} else {
val context = AppGlobals.sApplication
val sbDeviceId = StringBuilder()
val androidID = getAndroidId(context)
val id = getDeviceUUID(context).replace("-", "")
if (androidID != null && androidID.isNotEmpty()) {
sbDeviceId.append(androidID)
sbDeviceId.append("|")
}
if (id != null && id.isNotEmpty()) {
sbDeviceId.append(id)
}
if (sbDeviceId.toString().isNotEmpty()) {
try {
val hash = getHashByString(sbDeviceId.toString())
val sha1 = bytesToHex(hash!!)
if (sha1 != null && sha1.isNotEmpty()) {
LogTimer.LogE(this, "sha1:$sha1")
return if (setsD.contains(sha1)) sha1 else ELSED
}
} catch (ex: Exception) {
ex.printStackTrace()
}
}
val s = sbDeviceId.toString()
return if (setsD.contains(s)) s else ELSED
}
}
- 上面做好改完打包发布 (怎么打包发布前面文章里面有) ,这样就可以完全动态修改了,可以针对测试机,设备单独配置了。同理里面
override fun isDebug() = false
,打开调试模式也可以这样切换,打包后的文件名字也是可以这样修改的。可以从测试切到线上,从线上切到线上另一个地址,从线上切回测试。注意,这里是切的是插件存放地址的切,业务接口地址切回测试环境,可以自己业务里面实现,也可以在在测试环境单独配置目录下存放 打好测试接口地址的所有插件包,或者好几套环境的插件包 ,全部单独配置,各自配置各自的服务器地址目录,各自接口地址的插件包单独目录。可以做到完全全动态化
十、全动态化 之 接入宿主的代码(如果想修改插件下载逻辑、加载逻辑)
- 在本篇文章前面介绍了 下载和加载插件的逻辑第一次默认写在宿主里面,因为怎么下载,怎么加载,都要设计到插件的版本控制,因为是通过版本控制去怎么下载,去怎么加载。也就是我前面文章介绍对比的。
版本控制逻辑全动态化
。其他插件化框架没有的,有也是宿主里面写了,不能全动态化 - 为什么这块要做成全动态化呢?
涉及到这块逻辑,不是单纯的下载文件,因为版本控制逻辑可能后续扩展需要改一下呢,加载插件逻辑需要改一下呢。因为一次性想不好所有之后续扩展场景。 - 这块动态化怎么操作?
找到WX\WX-Maven\WX-Plugin\Maven-Wgllss-Dynamic-Plugin-Manager\Maven-Wgllss-Dynamic-Plugin-Loader-Impl工程下的LoaderManagerImpl
类, 我示例里面,只加了一行打印代码测试,逻辑的实现都在父类:BaseLoaderManagerImpl
中实现的,包括根据版本下载,根据版本加载。里面涉及到要修改的可以重载父类的方法,可全部重写,里面涉及到调用其他类的,可以在该工程下新建文件,把那些类里面 的内容全copy进来,改成自己的实现逻辑。 - 改完上面之后需要修改版本文件配置:找到WX\WX-Maven\WX-Plugin\Maven-Wgllss-Dynamic-Plugin-Sample\maven-wgllss-sample-loader-version工程下
LoaderVersionImpl
类下,这里只需要改下面代码中一行后的版本号就可以了,默认第一次版本号1000,后续往上升。我示例里面注释掉的这行:
override fun getClmd() = Triple("com.wgllss.dynamic.plugin.loader.LoaderManagerImpl", "class_loader_impl_dex", 1000)
- 上面做好改完打包发布 (怎么打包发布前面文章里面有),下次就可以动态升级。
十一、开发时候如何调试的?
- 开发中宿主安装的测试包,是可以进行debug,打断点调试的。
- 开发中频繁改动发版如何只影响到自己的那台调试手机,在前面已经介绍过了,可以配置自己手机的唯一标识,只针对自己手机。既可以不影响到测试环境其他测试手机,也不影响到线上其他手机,等自己调试好了,然后再把改动的所有插件包传到其他设备共有的目录下面,测试如此,线上也是如此。
- 每次测试开发改动一下都要打插件包,改插件配置版本号发布,怎么改动版本号发布前面的文章里面有介绍过。懂了怎么发布插件新版本后。可以在自己的测试地址目录设备唯一标识目录下面,只传改动的插件文件包不用改配置版本号去发布,到自己手机设置找到自己host app,清除缓存目录,让其每次当作第一次启动,这样本地没有都会去执行下载。这样要方便些,省了大部分去配置升级插件版本管理发布流程。
总结:
本篇文章再次详细介绍了WXDynamicPlugin是怎么做到全动态化的,包括
- 启动页默认插件化,是如何做的?
- 首页的插件化,是如何做的?
- 四大组件插件化框架SDK的插件化,是如何做的?
- 纯Java、Kotlin代码的插件化,是如何做的?
- Res下、Assets下等资源的插件化,是如何做的?
- SO的插件化,是如何做的?
- 全动态化——切换宿主内配置的插件包所在服务器下载地址,调试模式,是如何做的?
- 全动态化——接入宿主的代码(如果想修改插件下载逻辑、加载逻辑),是如何做的?
- 开发时候如何调试的?是如何做的?
同时补充了 插件化框架的加载流程图,让看得更清晰。