本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
前言
我们通常会在一个项目工程 (Project) 里开发,该工程包含了 App 的所有业务。最开始我们会在一个 app 模块里实现业务功能,之后随着业务增加,代码量越来越多,编译时间越来越长。可能会抽取一些业务代码到新的模块,但是模块之间还是存在着错综复杂的依赖关系,因为有跳转页面、传递数据等需求,耦合度很高,这会导致以下痛点:
- 任何修改都要编译整个项目工程,经常编译一次需要非常久。项目越大,编ProcessOn译的时间越长。
- 业务模块耦合度很高,导致业务功能难以复用,即使把整个模块代码导入到别的项目工程也很难编译通过。
- 多人协作开发容易相互影响,代码合并经常冲突,使得协作开发的效率很低。
- 容易牵一发而动全身,导致维护成本变高。
这些代码耦合度高导致的问题都可以用组件化解决,本文会给大家介绍组件化的优势及其应用,以及更多的实战技巧。
相关系列文章:
- 《如何更好地进行 Android 组件化开发(一)实战篇》
- 《如何更好地进行 Android 组件化开发(二)技巧篇》
- 《如何更好地进行 Android 组件化开发(三)ActivityResult 篇》
- 《如何更好地进行 Android 组件化开发(四)登录拦截篇》
- (待更新...)
组件化的优势
组件是业务单一的功能模块,每个组件都可以独立运行,也可以集成到其他组件中运行。组件化相对于前面说的模块化,耦合度更低,能有效解决上述所讲的痛点。
- 开发时可以独立编译调试一个业务模块,无需编译整个工程,提高编译效率。
- 业务模块的耦合度降低,代码更加独立,更容易复用。
- 每个业务组件都有相应的负责人,大家的开发互不打扰,代码质量的好坏也只会影响到自己的业务模块,减少代码冲突,提高协作开发效率。
- 如果业务功能有问题,通常只需要修改对应业务组件,维护成本更低。
组件化架构
以下是个人理解的组件化架构方案:
从上到下分成了四层,只有上层模块才能依赖下层。讲一下每一层的作用:
- 基础层,是最基础的开发框架,包含了基础开发所需的基类、工具类、第三方库等。依赖该模块就能快速进行开发。
- 中间层,包含了路由的功能,可以和业务组件进行交互。由于依赖了基础开发框架,也算是一个增强版的组件化开发框架。
- 业务层,依赖于中间层,包含了各个业务功能的组件。每个业务组件都能运行出一个小型的 App 进行调试,也可能给其它模块进行复用。
- 应用层,也就是俗称的 App 壳,可以集成各种所需的业务组件,组合出不同 App。
如果是需要复用组件或者开发组件,就依赖中间层。如果 App 功能比较简单,根本用不上组件,那就依赖基础层。
如何组件化
统一依赖版本
随着业务功能不断增加,模块会越来越多,容易出现依赖版本不一致的情况,需要统一版本。常见的做法有两种。第一种是在 gradle 文件添加 Extra 属性,比如在工程根目录的 build.gradle 添加 ext {} 代码块声明变量,这些变量能在其它 gradle 文件直接使用。这样虽然能统一版本号和依赖,但是不能自动补全代码,不支持跳转。
所以个人推荐使用 buildSrc 的方式,实现起来也简单,在工程根目录添加一个 buildSrc 文件夹,并在文件夹创建一个 build.gradle.kts 文件,文件内容如下:
plugins {
`kotlin-dsl`
}
repositories {
gradlePluginPortal()
}
之后在 buildSrc\src\main\kotlin 目录下添加 Kotlin 常量,比如:
object Versions {
const val COMPILE_SDK = 32
const val TARGET_SDK = 32
const val MIN_SDK = 23
const val VERSION_CODE = 1
const val VERSION_NAME = "1.0.0"
const val ANDROID_GRADLE_PLUGIN = "7.1.2"
const val KOTLIN = "1.7.20"
const val APPCOMPAT = "1.5.1"
const val APP_STARTUP = "1.1.0"
const val CONSTRAINT_LAYOUT = "2.1.4"
const val CORE_KTX = "1.8.0"
const val ESPRESSO_CORE = "3.4.0"
const val EXT_JUNIT = "1.1.3"
const val JUNIT = "4.13.2"
const val MATERIAL = "1.6.1"
// ...
}
object Libs {
const val APPCOMPAT = "androidx.appcompat:appcompat:${Versions.APPCOMPAT}"
const val APP_STARTUP = "androidx.startup:startup-runtime:${Versions.APP_STARTUP}"
const val CONSTRAINT_LAYOUT = "androidx.constraintlayout:constraintlayout:${Versions.CONSTRAINT_LAYOUT}"
const val CORE_KTX = "androidx.core:core-ktx:${Versions.CORE_KTX}"
const val ESPRESSO_CORE = "androidx.test.espresso:espresso-core:${Versions.ESPRESSO_CORE}"
const val EXT_JUNIT = "androidx.test.ext:junit:${Versions.EXT_JUNIT}"
const val JUNIT = "junit:junit:${Versions.JUNIT}"
const val MATERIAL = "com.google.android.material:material:${Versions.MATERIAL}"
// ...
}
object ClassPaths {
const val ANDROID_GRADLE_PLUGIN = "com.android.tools.build:gradle:${Versions.ANDROID_GRADLE_PLUGIN}"
const val KOTLIN_GRADLE_PLUGIN = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.KOTLIN}"
const val KOTLIN_SERIALIZATION = "org.jetbrains.kotlin:kotlin-serialization:${Versions.KOTLIN}"
// ...
}
Sync Project 之后就能在 build.gradle 使用这些常量了。
buildscript {
repositories {
google()
jcenter()
}
dependencies {
classpath ClassPaths.ANDROID_GRADLE_PLUGIN
classpath ClassPaths.KOTLIN_GRADLE_PLUGIN
classpath ClassPaths.KOTLIN_SERIALIZATION
}
}
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
compileSdk Versions.COMPILE_SDK
defaultConfig {
minSdk Versions.MIN_SDK
targetSdk Versions.TARGET_SDK
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
// ...
}
dependencies {
api Libs.CORE_KTX
api Libs.APPCOMPAT
api Libs.MATERIAL
api Libs.CONSTRAINT_LAYOUT
// ...
}
buildSrc 支持自动补全,显示语法高亮且可以点击跳转。稍微有点小小不足是不能像第一种方案那样提示依赖有新版本,不过瑕不掩瑜。
独立调试与集成调试
通过设置 Gradle 的 plugin 可以配置 module 的类型,如果配置的是 com.android.application 插件,该模块就能打包运行。如果配置的是 com.android.library 插件,该模块就能被其它模块依赖使用。
所以当页面组件需要独立运行时就改成 com.android.application 插件,当需要集成调试被依赖时就改成 com.android.library 插件。通常会用一个调试开关变量来控制模块类型,所以我们在 buildSrc 模块里添加一个 DEBUG_MODULE 常量。
object Plugins {
const val DEBUG_MODULE = false
}
然后判断调试变量的值去设置不同的插件,这就只需修改变量的值并 Sync 一下就能切换模块类型。
if (Plugins.DEBUG_MODULE) {
apply plugin: 'com.android.application'
} else {
apply plugin: 'com.android.library'
}
apply plugin: 'xxxx' 是老的插件写法了,现在新建项目一般是新的 plugins DSL 写法:
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
个人研究了下在 id 后面加 apply() 函数可以控制是否应用该插件,所以改成下面的写法。
plugins {
id 'com.android.application' apply(Plugins.DEBUG_MODULE)
id 'com.android.library' apply(!Plugins.DEBUG_MODULE)
id 'org.jetbrains.kotlin.android'
}
看似好像没什么问题,但是 Sync 一下就会有报错。
报错的意思是参数列表必须是一个字面的 boolean 值,也就是必须写 true 或者 false,目前看来写变量是不行的,那该怎么解决呢?个人想了很久实在没办法了,只好新老写法一起使用。
plugins {
id 'org.jetbrains.kotlin.android'
}
if (Plugins.DEBUG_MODULE) {
apply plugin: 'com.android.application'
} else {
apply plugin: 'com.android.library'
}
注意 plugins {} 代码块要在最上面,这样编译才没问题。
当然只配置 application 插件还是不足以让一个模块运行起来的,我们还需要添加 applicationId,如果是 library 类型则不需要 applicationId。
并且 AndroidManifest 要做区分,想独立运行时在 AndroidManifest.xml 的 <appclication/> 节点要有图标、主题等信息,还要声明启动的 Activity。
如果业务模块已有的页面不适合做启动页,那么可以写个简单的测试页面。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.dylanc.componentization.account.impl">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ComponentizationSample">
<activity
android:name=".ui.SignInActivity"
android:exported="false" />
<activity
android:name=".ui.TestActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
而作为 library 的时候只需把其它模块需要跳转的 Activity 声明即可,由于需要两份不同的配置,这里在 src/main/manifest 文件夹新建了另一个 AndroidManifest.xml 文件。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.dylanc.componentization.account.impl">
<application>
<activity
android:name=".ui.SignInActivity"
android:exported="false"/>
</application>
</manifest>
在业务组件的 build.gradle 根据开关变量的值设置 applicationId 和 AndroidManifest.xml 的路径。
android {
defaultConfig {
if (Plugins.DEBUG_MODULE) {
applicationId "com.dylanc.componentization.account"
}
// ...
}
sourceSets {
main {
if (Plugins.DEBUG_MODULE) {
manifest.srcFile 'src/main/AndroidManifest.xml' // 独立调试
} else {
manifest.srcFile 'src/main/manifest/AndroidManifest.xml' // 集成调试
}
}
}
}
创建组件模块时建议选择 Phone & Tablet 新建一个 application 类型的模块,然后再更改 build.gradle 配置支持切换成 library 类型。
如果反过来选择 Android Library 新建一个 library 类型模块,想支持 application 类型来独立运行还要增加启动页、图标、App 名称、主题等不少代码,比较麻烦。
再分享点小技巧,当我们把前面的 DEBUG_MODULE 改为 true 时就可以独立调试各个模块,但是有的组件还会依赖于其它组件。比如社区发帖前肯定需要先登录,那么社区组件会依赖登录组件。当独立调试社区组件的时候,登录组件的模块也是 application 类型,此时是不能被依赖的。那么可以增加另一个变量来单独控制登录组件为 library 类型,这样就能正常调试社区组件了。
object Plugins {
const val DEBUG_MODULE = true
const val DEBUG_ACCOUNT_MODULE = false
}
还见过有人给每一个组件都添加变量,可能也是为了应对这种需要被依赖的情况,但是没有必要所有组件都一一对应一个调试变量,只需给独立调试时需要被依赖的组件添加调试变量即可。
代码隔离
独立调试和集成调试其实只是切换 module 的类型,解耦问题还得靠代码隔离。我们要尽量避免组件之间直接引用,除了依赖基础开发模块之外,不应该直接依赖于任何业务组件,这样才能保证脱离了其它业务模块后也能编译通过。
比如很多业务功能都需要登录后才能使用,那么一些业务组件需要依赖于账户组件。如果直接依赖了账户组件,可能会有意无意地访问到该组件的代码,增加了耦合度。可能有一天要在另一个 APP 使用新的账户系统,同事写好了另一套账户组件,返回的账户信息都没有变。本来是把老账户组件的依赖换成新账户组件就可以,但是由于存在耦合,换依赖后可能会编译不过,这就没法独立调试了。
所以为了保证在各种情况下都正常独立调试组件,实现代码隔离是非常必要的。这就可以用到 runtimeOnly 的依赖方式,这样依赖的模块只在运行时可用,我们开发的时候是不能访问到该模块的代码的,这就能实现代码隔离。
dependencies {
// ...
runtimeOnly project(path: ':module-account')
}
通过代码隔离就能降低业务组件间的耦合度,保证在各种情况下都正常独立调试或集成调试组件。不过这样就访问不到组件的代码了,组件之间要怎么交互又是个新的问题。
页面导航跳转
实现代码隔离后就不能访问到该模块下的类,那怎么跳转页面呢?可以用 Android 的隐式 Intent 的方式,但是隐式 Intent 需要通过 AndroidManifest 集中管理,协作开发比较麻烦,所以通常是使用路由框架来实现业务组件间的页面跳转。
本文使用的是经典的路由框架 ARouter,还可以使用 TheRouter、WMRouter、等路由框架,用法和实现原理都是类似的。
根据 ARouter 的官方文档介绍,ARouter 是一个用于帮助 Android App 进行组件化改造的框架,支持模块间的路由、通信、解耦。下面介绍下用法:
在 Kotlin 项目使用 ARouter 需要在 build.gradle 添加以下配置和依赖。
plugins {
// ...
id 'kotlin-kapt'
}
kapt {
arguments {
arg("AROUTER_MODULE_NAME", project.getName())
}
}
dependencies {
implementation "com.alibaba:arouter-api:1.5.2"
kapt "com.alibaba:arouter-compiler:1.5.2"
}
其中的 arouter-compiler 依赖会通过 APT 的方式生成代码,而生成代码的时候需要知道是在哪个模块,这是读取了 arguments 中的 AROUTER_MODULE_NAME 参数,所以还需要给 AROUTER_MODULE_NAME 设置为 project.getName()。简单来说就是上面的两段 kapt 代码必须一起使用。
在 Application 初始化 ARouter:
class App : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
ARouter.openLog()
ARouter.openDebug()
}
ARouter.init(this)
}
}
给 Activity 添加 @Route 注解,这样类的信息就和 path 字符串建立映射关系。
@Route(path = "/account/sign_in")
class SignInActivity : BaseActivity<AccountActivitySignInBinding>() {
// ...
}
path 由两部分组成,前面的 /account 是分组,为什么要加分组呢?如果只声明 sign_in,那多个组件都有 sign_in 路由的话,路由框架就不知道到底要用哪个了,加上分组就能规避这个问题。注意一个模块内只能有一个分组,否则编译会不通过。
之后我们就能用路由跳转到指定 path 的 Activity。
ARouter.getInstance().build("/account/sign_in").navigation()
调用 withXXXX() 函数可以传递参数,如果需要用 startActivityForResult(intent) 的方式跳转路由页面,要在 navigation() 函数加上 Activity 和 requestCode 参数。
ARouter.getInstance().build("/account/sign_in")
.withString("email", email)
.navigation(this, REQUEST_CODE_SIGN_IN)
现在 startActivityForResult(intent) 标记为弃用了,官方推荐用新的 ActivityResult API,但是目前的路由框架基本都不支持,确实不太好适配,后面会单独发篇文章来讲讲怎么给路由框架适配 ActivityResult API。
获取 Fragment
跳转 Activity 是解决了,我们开发中还会经常用到 Fragment,在访问不了 Fragment 类的情况下怎么得到 Fragment 实例呢?通常路由框架还会支持获取 Fragment。
我们给 Fragment 添加 @Route 注解。
@Route(path = "/account/me")
class MeFragment : BaseFragment<AccountFragmentMeBinding>() {
// ...
}
之后就能通过路由去实例化 Fragment 对象。
val meFragment = ARouter.getInstance().build("/account/me").navigation() as? Fragment
navigation() 函数是有返回值的,如果 path 对应的是一个 Activity 类,就会直接跳转页面并返回 null,如果 path 对应的是 Fragment 类,就会返回实例化的 Fragment 对象。不过返回的是 Object 类型,我们需要强转成可空的 Fragment 类型。传递参数给 Fragment 也是同样调用 withXXXX() 函数。
组件间通信
实现代码隔离后访问不到组件各个类的代码,怎么进行组件间的通信?比如获取数据、调用方法、实现监听等。其实也能用路由框架解决。
比如我们需要用账户组件判断是否登录,还有点击设置里的注销按钮时退出登录,先定义一个 AccountService 接口提供对应的方法,注意接口需要继承 IProvider。
interface AccountService : IProvider {
val isSignIn: Boolean
fun signOut()
}
在账户组件添加该接口的实现类,并用 @Route 注解添加路由。
@Route(path = "/account/service")
class AccountServiceProvider : AccountService {
override val isSignIn: Boolean
get() = AccountRepository.isSignIn
override fun signOut() {
AccountRepository.signOut()
}
override fun init(context: Context) = Unit
}
之前就能通过路由获取对应 path 的接口实例,用法类似 Fragment,强转一下返回值。
val accountService = ARouter.getInstance().build("/account/service").navigation() as? AccountService
if (accountService?.isSignIn == true) {
// ...
}
如果服务接口只有一个实例类,还可以用另一种获取方式,不传 path 字符串,直接传 Class 对象。这么用的时候最好将返回值声明为一个可空的类型,后面才不会忘了判空操作。
val accountService: AccountService? = ARouter.getInstance().navigation(AccountService::class.java)
if (accountService?.isSignIn == true) {
// ...
}
还有一个很重要的问题,组件通信的接口放在哪里?通常会用个单独的组件通信模块来存放所有组件路由接口,每个组件都依赖该模块实现自己业务的接口并提供路由。按照前面的架构图,我们是放在中间层的 module-common 模块,这也是网上组件化较为常见的做法。
不过这样会存在中心化问题,所有组件模块都依附于一个组件通信的 module-common 模块上。随着业务的不断膨胀,module-common 模块的代码会越来越臃肿,不仅仅只有组件通信的接口,当需要获取组件的数据时会添加所需的 Bean 类,当需要监听时会添加对应的 Listener 类等。这样 module-common 模块会越来越杂乱,也很难知道每个组件对哪些组件接口有依赖,所以有必要去中心化。
去中心化
将组件通信的 module-common 模块拆分成各个组件的 api 模块,比如有一个 module-account 组件,就会有对应的通信模块 module-account-api。我们修改一下前面架构图的中间层:
对中间层解耦后,使用一个组件都要添加两个依赖。
dependencies {
// ...
implementation project(path: ':module-account-api')
runtimeOnly project(path: ':module-account')
}
虽然这么使用会稍微有点麻烦,但是职责更加清晰。我们可以把对应的 api 模块作为这个组件的协议,比如 module-account-api 模块有以下的类。
object AccountPaths {
private const val GROUP = "/account"
const val SERVICE = "$GROUP/service"
const val FRAGMENT_ME = "$GROUP/me"
const val ACTIVITY_SIGN_IN = "$GROUP/sign_in"
}
interface AccountService : IProvider {
val isSignIn: Boolean
val user: User
fun signOut()
}
我们通过 module-account-api 模块的代码就能知道能和 module-account 组件做些什么交互,能跳转哪些 Activity 能跳转,能获取哪些 Fragment。不需要查询组件文档或问同事就能完成开发,减少使用和沟通成本。如果发现该组件没有自己所需的功能,再反馈给对应的开发同事。
微信有个 api 方案,在组件添加 .api 后缀的文件,通过 gradle 脚本生成 xxxx-api 模块。这个思路蛮有意思的,不过个人觉得和手动 New Module 差不了多少,会增加些学习成本,需要同事额外了解一些开发规范,并不是很有必要,如果感兴趣的可以自行了解一下。
组件初始化
不管是独立调试用到各组件的 Application,还是集成调试用到 App 壳的 Application,都需要保证所涉及组件的初始化逻辑能正常执行。这里推荐使用 Jetpack 的一个组件 —— App Startup。App Startup 是用于应用程序在启动时初始化组件。
有不少开源库实现自动初始化会借助 ContentProvider,虽然很巧妙,但是会增加许多额外的耗时。因为 ContentProvider 是 Android 四大组件之一,即使我们的初始化操作很轻量,依赖 ContentProvider 后就变成了一个重量级的操作。官方测试一个空的 ContentProvider 大约会占用 2ms 的耗时,如果很多第三方库都用 ContentProvider 自动初始化,就会增加不少耗时。
所以官方推出了 App Startup,App Startup 也会创建一个 ContentProvider,不过提供了一套初始化的标准。官方希望第三方库都基于这套标准进行初始化,这样就能减少 ContentProvider 的数量,减少启动的耗时。
App Startup 是用于自动初始化,那也能很好地运用到组件化项目中。接下来讲下怎么使用,首先在 build.gradle 添加 App Startup 的依赖。
implementation "androidx.startup:startup-runtime:1.1.0"
写一个类继承 Initializer,在 create() 方法里实现初始化的逻辑。其中 dependencies() 函数可以控制在哪些 Initializer 之后再初始化,没有要求就返回个空列表。
class AccountInitializer : Initializer<Unit> {
override fun create(context: Context) {
// 初始化
}
override fun dependencies() = emptyList<Class<out Initializer<*>>>()
}
在业务模块的 AndroidManifest.xml 中添加 provider,这样就能实现自动初始化了。
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="com.dylanc.componentization.account.impl.AccountInitializer"
android:value="androidx.startup" />
</provider>
上面的 <provider/> 一个固定的模版,需要修改的只有 android:name,改成对应 Initializer 实现类的全包名即可。
使用 App Startup 做初始化还有个好处是支持自定义的初始化顺序,在重写的 dependencies() 函数返回其它 Initializer 的 Class 列表,这样就能在这些 Initializer 之后才初始化。
由于我们做了代码隔离访问不到 Initializer 的代码,可能需要用 Class.forName(name) 得到 Class 对象,这么获取有点不好是字符串不会和全包名同步,要人为保证一致。不过这个类一般不会动,其实也还好。
我们可以在 api 模块定义一个属性获取 Initializer 的 Class,如果找不到类抛个异常提示一下。
@Suppress("UNCHECKED_CAST")
val accountInitializerClazz by lazy {
(Class.forName("com.dylanc.componentization.account.AccountInitializer") as? Class<out Initializer<*>>)
?: throw IllegalStateException("Please depend on module-account.")
}
在其它模块的 Initializer 的 dependencies() 函数中返回该属性就能修改初始化顺序。
class NewsInitializer : Initializer<Unit> {
override fun create(context: Context) {
// ...
}
// 在 AccountInitializer 之后初始化
override fun dependencies() = listOf(accountInitializerClazz)
}
如果不希望业务组件自动初始化,我们也能改成手动初始化。首先要在 provider 配置移除 Initializer,在 AndroidManifest.xml 添加以下配置:
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="com.dylanc.componentization.account.impl.AccountInitializer"
tools:node="remove" />
</provider>
关键代码是 tools:node="remove",这样在 merge 所有模块的 AndroidManifest.xml 时,会把对应的 meta-data 节点全部删除,该 Initializer 就不会自动初始化了。
之后我们只需在特定的时机再手动初始化即可,同样是用服务接口去获取 Initializer 的 Class 对象。
val accountService = ARouter.getInstance().navigation(AccountService::class.java)
?: throw IllegalStateException("Please depend on account-impl module.")
AppInitializer.getInstance(this).initializeComponent(accountService.initializerClazz)
总结
本文介绍了单一工程开发的缺点和组件化的优势,了解了组件化开发需要解决的问题和具体的解决方案,如何独立和集成调试、实现代码隔离、页面跳转、获取 Fragment、组件通信、组件初始化等。其中有些是用路由框架来解决,本文使用的是 ARouter,大家也可以选择其它路由框架,用法都是大同小异。
其实组件化开发的步骤和原理并不难,但实际开发中还会遇到更多问题,比如怎么划分组件、有多套 UI 需求怎么处理等。下一篇文章会和大家分享更多个人在实际开发中总结的经验,帮助大家更好地进行组件化开发。
示例代码:待补充。
参考文献
关于我
一个兴趣使然的程序“工匠” 。有代码洁癖,喜欢封装,对封装有一定的个人见解,有不少个人原创的封装思路。GitHub 有分享一些帮助搭建开发框架的开源库,有任何使用上的问题或者需求都可以提 issues 或者加我微信直接反馈。
- 掘金:juejin.cn/user/419539…
- GitHub:github.com/DylanCaiCod…
- 微信号:DylanCaiCoding