大型项目架构:解析全动态插件化框架WXDynamicPlugin是如何做到全动态化的?

2,307 阅读14分钟

12121.jpg

全动态化 , 能让软件在增删功能方面,用户基本达到无感知

(一)零反射,零HooK,全动态化,插件化框架,全网唯一结合启动优化的插件化架构
(二)零反射,零HooK,全动态化,插件化框架,全网唯一结合启动优化的插件化架构
(三)零反射,零HooK,全动态化,插件化框架,全网唯一结合启动优化的插件化架构
(四)零反射,零HooK,全动态化,插件化框架,全网唯一结合启动优化的插件化架构

(五) 大型项目架构:全动态插件化+模块化+Kotlin+协程+Flow+Retrofit+JetPack+MVVM+极限瘦身+极限启动优化+架构示例+全网唯一

(六) 大型项目架构:解析全动态插件化框架WXDynamicPlugin是如何做到全动态化的?
(七) 还在不断升级发版吗?从0到1带你看懂WXDynamicPlugin全动态插件化框架
(八) Compose插件化:一个Demo带你入门Compose,同时带你入门插件化开发
(九) 花式高阶:插件化之Dex文件的高阶用法,极少人知道的秘密

一 、前言

WXDynamicPlugin全动态插件化框架开源也已经一个多月了,非常感谢各位网友的肯定和支持,断断续续也有些加我了解沟通一些问题。前面也有5篇系列性文章介绍说明,在此根据收到的反馈,再结合我之前5篇感觉有些没有介绍到的,再进行介绍补充。如果本文没有介绍到的,可以参考前面5篇相关文章介绍。

全动态化的意义
可以最大化不用发版:

插件化本身模块是可以做到动态化修改的,这样宿主不用发版,但是插件化第一次下载逻辑,加载插件的逻辑功能必须在宿主,这两部份逻辑如果需要修改,也做到动态化,那么对于框架的修改,插件的下载,加载修改可以做到全动态化,因为任何接入宿主的那部分代码逻辑不太可能一次性写得很完美,这样做能将全动态化做到最高。

本文介绍思路

WXDynamicPlugin.png

二、插件化框架加载流程图

WXDynamicPlugin流程图.png

三、启动页默认插件化

因为宿主只是一个没有任何业务的空壳子,只包含了:

  1. 系统必要的基础控件组件
  2. 下载加载插件的逻辑。其他全在插件里面了。

为什么宿主apk里面集成系统必要的基础控件组件?

        为了节省插件的体积,和减少下载插件的时间。我们试想:我们把必备的基础组件让宿主去集成,每一个插件都可以访问到,那么插件里面就不用集成,那么插件文件里面可以只有自己写的业务代码,没有依赖包的代码,这样插件体积最小,下载插件所需要的耗时也就最小。如果不这样,相同的组件,每个插件包都要依赖上,并打入插件包文件内,那么每个插件文件的体积都上去了,每个插件下载时间都多了,这样的话,还不如集成在宿主里面,因为这些组件也基本上是万年不怎么修改了。能够满足App的开发,这样和全动态发版没有什么影响。万不得已,兜底方案:可以在插件模块里面写个让宿主版本升级的功能就可以解决。

为什么下载和加载插件的逻辑写在宿主里面?

        这个不难理解,如果宿主第一次下载的逻辑写在插件里面,那么插件第一次怎么到本地,怎么下载, 如果插件加载的逻辑在插件里面,那么插件第一次怎么加载到宿主里面,和宿主怎么关联。

本框架全动态化后面介绍:第一次下载插件,第一次加载插件会接入宿主,后面如果需要修改宿主下载插件逻辑,修改宿主加载逻辑,都可以动态化修改,不用去改宿主了,见后面介绍。

启动页默认插件: 基于上面思考后,那么第一次下载插件时,总要有个耗时,这段时间给用户展示的UI界面就是启动默认插件,这个插件默认也要集成到宿主,这也是没办法的事情。不过,这个启动默认插件是以文件资源形式存在宿主 Assets目录下的 ,这样做的目的是,在第一次需要下载时候,因为宿主第一次启动没有任何UI,所以用这个展示,当宿主已经有下载好了插件之后,后面就不需要它了,启动时候就直接用 首页插件 了,因为已经有首页插件了,直接展示,启动速度就会更快了。

如下图所示:首页插件存放在assets下面

5bade44a-17d7-4552-a733-24237d5e1753.jpeg

首页插件源码工程及可以配置默认下载时展示图片如下:

0210fd03-be61-4aec-8231-b6f129d18a27.jpeg

四、首页插件化

从上面加载流程图可以看到,首页插件是分两部分的:

  1. 首页HomeActivity 首屏展示的第一个Fragment
    首页首屏展示的界面,全代码布局,是提前在启动框架里面布局完成,到首页时直接拿来渲染,以提高首屏启动速度。
  2. 首页HomeActivity 非首屏展示的其他Fragment
    首屏其他Fragment 做成了纯代码 dex + 资源两部分 ,这样做的目的,还是为了减少 插件打包后的体积,如果合成一起,只能打包成apk形式,这样至少也得700k左右,这样分开打包,3个插件总共才72.8k

d7495ca4-e073-4869-b124-bfebf201cf06.jpeg

五、四大组件插件化框架SDK的插件化

四大组件本框架中,只做了三部分:

  1. BroadcastReceiver: 可以动态注册,完全不和宿主里面 AndroidManifest 相关联,插件化SDK中未做任何处理
  2. 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" />
  1. 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
    }
}
  1. 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形式就可以了 只需要注意两点:

  1. 插件下Rex资源的Resource资源的获取
  2. 插件下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路径)

九、全动态化 之 切换宿主内配置的插件包所在服务器下载地址,调试模式

  1. 如何在已经发布到生产环境线上的插件文件,改成使用测试环境的插件文件? 在宿主中 WX\WX-Maven\WX-Host\sample宿主工程 FaceImpl 类中,配置了默认下载插件文件的地址,即打包好的插件文件存放的服务器地址。我们可以将它改成测试环境地址,或者自己本机测试地址,获取其他服务器地址,怎么改呢?这里不改上面提到的这个类。

  2. 找到WX\WX-Maven\WX-Plugin\Maven-Wgllss-Dynamic-Plugin-Manager\Maven-Wgllss-Dynamic-Plugin-DownloadFace-Impl工程下的 DownLoadFaceImpl

  3. 修改 DownLoadFaceImploverride fun getHostL() 地址为测试地址,然后打包,具体不知道怎么打包可以看下前面文章介绍。

  4. 修改下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)
  1. 注意下上面版本文件配置, 记得修改上面总版本号,记得测试环境的地址服务器里面已经存在了打包好的插件所有文件(切换过去那边总要先存在嘛),并且修改好的总版本号一定要大于测试环境(切换过去的环境)上面存放的文件的版本号,这样就可以动态切换宿主所依赖的插件下载地址了。

  2. 这样改如果改错了,线上面其他的用户都切换全错了,可以先测一下吗?可以:

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
        }
    }
  1. 上面做好改完打包发布 (怎么打包发布前面文章里面有) ,这样就可以完全动态修改了,可以针对测试机,设备单独配置了。同理里面 override fun isDebug() = false ,打开调试模式也可以这样切换,打包后的文件名字也是可以这样修改的。可以从测试切到线上,从线上切到线上另一个地址,从线上切回测试。注意,这里是切的是插件存放地址的切,业务接口地址切回测试环境,可以自己业务里面实现,也可以在在测试环境单独配置目录下存放 打好测试接口地址的所有插件包,或者好几套环境的插件包 ,全部单独配置,各自配置各自的服务器地址目录,各自接口地址的插件包单独目录。可以做到完全全动态化

十、全动态化 之 接入宿主的代码(如果想修改插件下载逻辑、加载逻辑)

  1. 在本篇文章前面介绍了 下载和加载插件的逻辑第一次默认写在宿主里面,因为怎么下载,怎么加载,都要设计到插件的版本控制,因为是通过版本控制去怎么下载,去怎么加载。也就是我前面文章介绍对比的。版本控制逻辑全动态化。其他插件化框架没有的,有也是宿主里面写了,不能全动态化
  2. 为什么这块要做成全动态化呢?
    涉及到这块逻辑,不是单纯的下载文件,因为版本控制逻辑可能后续扩展需要改一下呢,加载插件逻辑需要改一下呢。因为一次性想不好所有之后续扩展场景。
  3. 这块动态化怎么操作?
    找到WX\WX-Maven\WX-Plugin\Maven-Wgllss-Dynamic-Plugin-Manager\Maven-Wgllss-Dynamic-Plugin-Loader-Impl工程下的 LoaderManagerImpl 类, 我示例里面,只加了一行打印代码测试,逻辑的实现都在父类:BaseLoaderManagerImpl 中实现的,包括根据版本下载,根据版本加载。里面涉及到要修改的可以重载父类的方法,可全部重写,里面涉及到调用其他类的,可以在该工程下新建文件,把那些类里面 的内容全copy进来,改成自己的实现逻辑。
  4. 改完上面之后需要修改版本文件配置:找到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)
  1. 上面做好改完打包发布 (怎么打包发布前面文章里面有),下次就可以动态升级。

十一、开发时候如何调试的?

  1. 开发中宿主安装的测试包,是可以进行debug,打断点调试的。
  2. 开发中频繁改动发版如何只影响到自己的那台调试手机,在前面已经介绍过了,可以配置自己手机的唯一标识,只针对自己手机。既可以不影响到测试环境其他测试手机,也不影响到线上其他手机,等自己调试好了,然后再把改动的所有插件包传到其他设备共有的目录下面,测试如此,线上也是如此。
  3. 每次测试开发改动一下都要打插件包,改插件配置版本号发布,怎么改动版本号发布前面的文章里面有介绍过。懂了怎么发布插件新版本后。可以在自己的测试地址目录设备唯一标识目录下面,只传改动的插件文件包不用改配置版本号去发布,到自己手机设置找到自己host app,清除缓存目录,让其每次当作第一次启动,这样本地没有都会去执行下载。这样要方便些,省了大部分去配置升级插件版本管理发布流程。

总结:

本篇文章再次详细介绍了WXDynamicPlugin是怎么做到全动态化的,包括

  1. 启动页默认插件化,是如何做的?
  2. 首页的插件化,是如何做的?
  3. 四大组件插件化框架SDK的插件化,是如何做的?
  4. 纯Java、Kotlin代码的插件化,是如何做的?
  5. Res下、Assets下等资源的插件化,是如何做的?
  6. SO的插件化,是如何做的?
  7. 全动态化——切换宿主内配置的插件包所在服务器下载地址,调试模式,是如何做的?
  8. 全动态化——接入宿主的代码(如果想修改插件下载逻辑、加载逻辑),是如何做的?
  9. 开发时候如何调试的?是如何做的?

同时补充了 插件化框架的加载流程图,让看得更清晰。

项目github地址
项目gitee地址

感谢阅读:

欢迎关注 ,点赞,收藏

你们的支持是我创作的动力,开源不容易