如何更好地进行 Android 组件化开发(二)技巧篇

2,476 阅读17分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

前言

上篇文章我们介绍了单一工程开发的缺点和组件化的优势,了解了组件化开发需要解决的问题和具体的解决方案,如何独立和集成调试、实现代码隔离、页面跳转、获取 Fragment、组件通信、组件初始化等。解决这些问题后就能完成组件化开发的工作了。

但是组件化有个更大的问题是如何划分组件,这个需要具体问题具体分析,网上很少有文章会详细讲怎么做。下面个人会提供一些拆分组件的思路,并且分享更多组件化的开发技巧和注意事项,帮助大家在实际开发中能更好地进行组件化开发。

建议看了上篇文章再来阅读本文,相关系列文章:

更多经验分享

组件如何划分

以下是个人总结的比较通用的拆分组件步骤:

  1. 按照业务拆分成一个个完整独立的业务功能。这里的完整是指包含该业务的增删查改,增删查改不一定都会有,但是如果有就一定要放在一起。
  2. 根据每个人工作安排看下独立成组件还是整合到一个组件。如果一人负责独多个强关联性的业务功能,可以整合到一个组件中。如果独立业务功能会被多个业务复用,建议自成一个组件。

首先对项目核心业务进行整体的评估,先对大的业务功能进行拆分,比如 App 核心业务有商城、社区分享、消息中心、用户信息管理等。之后再根据每一个业务的流程,对单一职责的功能进行细分。

比如一个商城的大功能,想买东西当然先要有商品,所以要展示商品列表,能点进去看详情,还有要能对商品进行价格降序、类品等筛选,这是一个查询商品的业务(增删改在商家端)。然后看完东西要下单,需要填地址,要能增删查改地址,设置默认地址,智能识别输入内容,这就是一个地址管理业务。下单后能查看待付款、待发货、待签收、待评价等订单列表,查看订单详情,涉及到订单的增删查,这就是一个订单业务。买的东西可能不满意,会有一套退货或换货流程,这就是一个售后业务。

这样我们就从一个完整的购物流程里,拆分出了商品、地址管理、订单组件、售后等业务功能。如果这几个业务都各自有人负责,那么就都写成组件,每人负责一个组件减少代码冲突。如果是一个人负责整个购物功能的开发,那么可以只写一个购物组件减少模块数量。假设地址管理还会在签到领礼品里用到,那么再抽出一个地址组件给购物组件和签到组件调用。

这是比较理想的情况,而实际开发中会有很多业务交叉依赖的情况。通常会有两种业务依赖关系:

  • 业务强依赖,一个组件对于另一个组件是必要的。最常见的场景是登录后才能做某事,比如下单功能,即使能独立调试也必须登录后才能下单,因为这是个前提,该组件脱离了账户组件是用不了的,不知道是谁的话怎么下单。
  • 业务弱依赖,一个组件对于另一个组件是非必要的。比如首页就聚合了各种业务的信息,在视频板块有消息中心的入口,在聊天的个人资料页面可能有朋友圈入口等,有一些页面整合了多种不相关的业务。

处理强依赖关系就直接对所需组件进行依赖或者合并组件代码。比如下单不仅仅需要登录,还得要先有商品。可以让订单组件直接包含查询商品的业务代码,而登录功能一般是会有个账户组件进行管理,我们直接依赖即可,这样独立调试时也能有完整的登录功能。

dependencies {
    // ...
    implementation project(':module-account-api')
    runtimeOnly project(':module-moment')
}

弱依赖关系会有很多种情况,最常见的是一个页面含有多种业务的信息,这里个人给出两种解决方案。

第一种方案是直接依赖 api 模块,最终运行有没组件的代码是让 App 壳来决定。

比如我们要做一个类似微信的 App,有 IM 功能和朋友圈功能,在聊天页面和朋友圈页面点击头像进入的个人资料,会有朋友圈入口和发消息入口。不过在另外一个 App 只需要 IM 功能,个人资料不会有朋友圈入口。

我们可以让 IM 组件依赖朋友圈的 api 模块。

dependencies {
    // ...
    implementation project(':module-moment-api')
}

然后我们在 IM 组件的个人资料页面,获取朋友圈模块的路由服务接口 MomentService,如果能获取到实例对象,就查询最近三张朋友圈图片并在页面上增加朋友圈入口。

val momentService = ARouter.getInstance().navigation(MomentService::class.java)
if (momentService != null) {
    val momentImages = momentService.getRecentMomentImages()
    // 在个人资料页面展示朋友圈入口
}

这样开发完后,后续复用就变得简单了。我们要开发类似微信的 App,就会同时依赖 IM 组件和朋友圈组件,那么在 IM 的个人资料页面能获取到 MomentService 的接口实例,从而按照需求添加入口。

dependencies {
    // ...
    implementation project(':module-im-api')
    runtimeOnly project(':module-im')
    implementation project(':module-moment-api')
    runtimeOnly project(':module-moment')
}

如果还有个 App 只有 IM 功能不需要朋友圈功能,那就不会依赖朋友圈组件,MomentService 接口就没法实例化,在 IM 的个人资料也就不会出现朋友圈入口。

dependencies {
    // ...
    implementation project(':module-im-api')
    runtimeOnly project(':module-im')
}

另一个方案是组件不依赖,业务交叉的部分在 App 壳里实现。

还用上面例子,依赖 IM 组件就只会有 IM 的功能,当 IM 组件的个人资料页面需要额外有朋友圈入口,那就在 App 壳里实现一个同时具有聊天入口和朋友圈入口的个人资料页面。

@Route(path = AppPaths.ACTIVITY_PROFILE)
class ProfileActivity : AppCompatActivity() {

  private lateinit var accountService: AccountService
  private lateinit var imService: IMService
  private lateinit var momentService: MomentService

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_profile)
    accountService = ARouter.getInstance().navigation(AccountService::class.java)!!
    imService = ARouter.getInstance().navigation(IMService::class.java)!!
    momentService = ARouter.getInstance().navigation(MomentService::class.java)!!
    // 展示个人资料,添加聊天入口和朋友圈入口
  }
}

给原本点击聊天头像和点击朋友圈头像跳转的页面添加路由,这样我们就能用路由的拦截器对路由进行拦截,改成跳转一个新的个人资料页面。

@Interceptor(priority = 1)
class ProfileInterceptor: IInterceptor {
  override fun init(context: Context) = Unit

  override fun process(postcard: Postcard, callback: InterceptorCallback) {
    if (postcard.path == IMPaths.ACTIVITY_PROFILE || postcard.path == MomentPaths.ACTIVITY_PROFILE) {
      callback.onInterrupt(null)
      ARouter.getInstance().build(AppPaths.ACTIVITY_PROFILE)
          .with(postcard.extras)
          .navigation()
    } else {
      callback.onContinue(postcard)
    }
  }
}

那在这个 App 壳里,个人资料页面就会有同时有聊天入口和朋友圈入口。

既然 Activity 能拦截,那么 Fragment 能拦截吗?很可惜路由框架一般是不支持的,但是我们自己能另外实现。我们可以用接口服务提供一个 xxxFragmentFactory 的配置,用于创建某个 Fragment。

interface AccountService : IProvider {
  // ... 
  var profileFragmentFactory: (Bundle) -> Fragment
}

在组件的服务接口实现类返回一个默认的 Fragment。

@Route(path = AccountPaths.SERVICE)
class AccountServiceProvider : AccountService {
  // ...
  
  override var profileFragmentFactory: (Bundle) -> Fragment = {
    ProfileFragment().apply { arguments = it }
  }
}

并且在组件内的某个页面通过组件服务的 xxxFragmentFactory 来创建出所需的 Fragment。

val fragment = accountService?.profileFragmentFactory(bundle)

如果想拦截该组件的 Fragment,就设置对应的 xxxFragmentFactory 创建出新的 Fragment 对象来替换掉原本的 Fragment,这就实现了拦截 Fragment 的效果。

accountService?.profileFragmentFactory = {
  FullProfileFragment().apply { arguments = it }
}

另外说一下首页会有各种业务的 Fragment,可能有的人会抽出个首页组件来管理,但是这个首页组件基本只会用在一个特定的 App 里使用,其实写在 App 壳里更合适。有些人觉得 App 壳里应该是没多少代码的,但是个人认为套壳很经常是需要定制化的,是需要写些定制化的代码的。

以上两种方案各有优缺点,第一种方案的优点是可根据有无某个组件的实现模块去动态添加入口,开发起来比较方便,但缺点是组件之间的耦合度高。第二种方案的优点是组件之间的耦合度低,但缺点是需要根据需求额外写个新页面整合两个模块的信息,要得到信息又得增加路由接口方法,可能还要拦截路由跳到新页面,会在 APP 壳里写不少代码。具体选哪个方案还是要根据实际情况来决定。

多套 UI

组件化的优势是耦合度低能独立调试提高编译效率,还有个优势是更加容易复用,那就不可避免要让同一套业务在多个 app 里使用。而 app 的风格通常不一样,这就需要组件支持多套 UI,该怎么配置 UI 是个问题。

个人提供三个配置 UI 的方案,第一个是配置自定义 style 属性。首先在 api 模块添加 attrs.xml 文件,声明所需的主题属性及其类型。

<resources>
  <attr name="account_sign_in_bg" type="color"/>
  <attr name="account_sign_in_logo" type="reference"/>
</resources>

然后在组件的布局里通过 ?attr/xxxxx 的方式使用已声明的主题属性,比如:

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:background="?attr/account_sign_in_bg">

  <ImageView
    android:id="@+id/iv_logo"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginBottom="48dp"
    app:layout_constraintBottom_toTopOf="@+id/et_username"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:srcCompat="?attr/account_sign_in_logo" />
  
  ...

</androidx.constraintlayout.widget.ConstraintLayout>

在 App 壳的 Application 主题里配置颜色、图片、大小等主题属性,更改对应样式。

<resources xmlns:tools="http://schemas.android.com/tools">
  <!-- Base application theme. -->
  <style name="Theme.ComponentizationSample" parent="Theme.MaterialComponents.DayNight.NoActionBar">
    <item name="colorPrimary">@color/purple_500</item>
    <item name="colorPrimaryVariant">@color/purple_700</item>
    <item name="colorOnPrimary">@color/white</item>
    <item name="colorSecondary">@color/teal_200</item>
    <item name="colorSecondaryVariant">@color/teal_700</item>
    <item name="colorOnSecondary">@color/black</item>
    <item name="android:statusBarColor">@color/white</item>
    <item name="android:windowLightStatusBar">true</item>
      
    <!-- Customize your theme here. -->
    <item name="account_sign_in_bg">@color/sign_in_bg</item>
    <item name="account_sign_in_logo">@drawable/ic_sign_in_logo</item>
  </style>
</resources>

第二个方案是通过变量来控制展示怎么样的 UI。在服务接口提供一些样式的配置,比如主题、排布方式、图标、颜色等。

interface AccountService : IProvider {
  // ... 
  var theme: AccountTheme
}

enum AccountTheme {
  BLUE, GREEN, ORANGE
}

之后在服务接口的实现类返回默认的样式。

@Route(path = AccountPaths.SERVICE)
class AccountServiceProvider : AccountService {
  // ...
  
  var theme = AccountTheme.GREEN
}

需要修改组件样式就通过服务接口来配置。

accountService?.theme = AccountTheme.ORANGE

第三个方案是多渠道打包,不同的渠道会打包运行出不同的样式。

可能不少人只是做过友盟多渠道,并没在多个模块用过多渠道。而网上有很多讲解多渠道的文章都比较老了,照着敲可能都编译不过。现在做多渠道必须要先声明 flavorDimensions,表示有哪些渠道的维度 ,比如我们在组件的 build.gradle 里声明一个 ui 维度,并在 productFlavors 里定义几个 ui 维度的渠道。

android {
    // ...
    
    flavorDimensions "ui"
    productFlavors {
        blue {
            dimension "ui"
        }
        green {
            dimension "ui"
        }
        orange {
            dimension "ui"
        }
    }
}

这里的渠道名是用主题色,还可以用 ui1、ui2 代表第几套 ui,或者直接用 app 名作为渠道名也可以。在该模块的 src 文件夹下创建渠道名的文件夹,添加 drawable、color、layout 等同名资源,打包该渠道时就会用渠道文件夹下的资源去替换的 main 目录的同名资源。

在 App 壳里可能要做友盟多渠道,这些都定义为 appstore 的维度,我们再声明一个 ui 维度的渠道,选择用哪套样式,这样打包出来就是对应的样式了。

android {
    // ...

    flavorDimensions "appstore", "ui"
    productFlavors {
        orange {
            dimension "ui"
        }
        xiaomi {
            dimension "appstore"
        }
        huawei {
            dimension "appstore"
        }
        oppo {
            dimension "appstore"
        }
        vivo {
            dimension "appstore"
        }
    }
}

以上三种配置方案可以根据需要选择使用,或者也可以混着用。

KV 存储

开发中肯定会需要保存一些配置,这些配置信息通常是会用 SharedPreferences、MMKV 或 DataStore 进行缓存。在组件化项目中每个人各自负责一些模块,你不知道同事会写些什么 key,所以会存在 key 值重名的隐患。其实这也很容易解决,就是每个模块都新建一个专属的 SharedPreferences、MMKV 等对象,这样即使重名了也不会相互覆盖。

不过通常项目所封装 KV 工具类是类似下面的用法:

MMKVUtils.encode(KEY_ACCOUNT, account)
val darkMode = SPUtils.getBoolean(KEY_DARK_MODE, false)

这种封装只是用个单例减少了创建存储对象的步骤,并不能解决 KV 存储的两大问题:

  • 替换存储实例不方便,组件化项目应该要在不同模块使用不同的存储实例。
  • 需要声明非常多的 key 值常量。

所以推荐使用个人封装的库 MMKV-KTX,能有效解决这两大问题。

在根目录的 build.gradle 添加:

allprojects {
    repositories {
        //...
        maven { url 'https://www.jitpack.io' }
    }
}
dependencies {
    implementation 'com.github.DylanCaiCoding:MMKV-KTX:1.2.14'
}

用法很简单,让一个类实现 MMKVOwner 接口,即可通过 by mmkvXXXX() 方法将属性委托给 MMKV,例如:

object DataRepository : MMKVOwner {
  var isFirstLaunch by mmkvBool(default = true)
  var account by mmkvParcelable<Account>()
}

这是用到了 Kotlin 属性委托的高级特性,设置或获取属性的值会调用对应的 encode() 或 decode() 函数。

if (DataRepository.isFirstLaunch) {
  // ...
  DataRepository.isFirstLaunch = false
}

用了属性名作为 key 值,这就不用声明大量的 key 值常量。

支持以下的委托方法:

方法默认值
mmkvInt()0
mmkvLong()0L
mmkvBool()false
mmkvFloat()0f
mmkvDouble()0.0
mmkvString()/
mmkvStringSet()/
mmkvBytes()/
mmkvParcelable()/

属性委托其实并不难,有不少人会写。但是多数人只是用属性委托省去 key 值,如果读取和保存不是同一个属性,就会存在保存失败的隐患,比如:

class InputWifiActivity : AppCompatActivity() {
  private var psd by mmkvString()

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_input_wifi)
    //...
    btnConfirm.setOnClickListener {
      psd = etPassword.text.toString()
      //...
    }
  }
}
class QRCodeActivity : AppCompatActivity() {
  private val pwd by mmkvString()

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_wifi)
    //...
    createQRCode(ssid, pwd)
  }
}

可以看到存的是 psd,但是别人读的时候按照自己习惯写了 pwd,这样肯定是读不出数据。其实保存和读取的最好是同一个属性,这样 key 一定是一致的,就不会保存失败。所以个人做了点优化,加了个 MMKVOwner 的接口,能获取一个 MMKV 对象:

interface MMKVOwner {
  val kv: MMKV get() = com.dylanc.mmkv.kv
}

val kv: MMKV = MMKV.defaultMMKV()

必须实现了 MMKVOwner 接口才能把属性委托给 MMKV,像上面分开写多个委托属性就要让多个类都实现 MMKVOwner 接口。虽然只是很简单的步骤,但是重复写多了也会感到麻烦,会减少一些分开写的用法,尽量集中写到 Repository 或 Model 等数据类里。并且对于一些不太懂的同事,想在其它类里写 by mmkvXXX() 会报错找不到函数,只能依葫芦画瓢地写在数据类中。

当 MMKV 的委托属性都写在 Repository 或 Model 等数据类后,就能重写 kv 属性一键替换该类里所有属性使用的 MMKV 对象,这就能满足我们组件化开发的期望,不同的组件可以使用不同的 MMKV 对象,即使与其它组件重名了 key 值都不会相互覆盖。

object AccountRepository : MMKVOwner {
  // ...

  override val kv: MMKV = MMKV.mmkvWithID("account")
}

虽然 MMKVOwner 接口的代码量很少,只是个小细节,但是作用非常大。

以上是 MMKV 的委托方案,其它常用的还有 SharedPreferences 和 DataStore。目前 SharedPreferences 已经弃用了,官方建议用 DataStore 替代。而 DataStore 结合了一些 Kotlin 的语法特性,用法比较特别,不太好封装,网上多数只是封装了个默认的单例对象,这就不推荐了。个人有想到一个封装思路,实现出类似上面 MMKV 的委托用法,解决 key 值爆炸和替换存储实例不方便的问题,以后会写篇文章分享,有兴趣的可以关注一下。

适度使用路由框架

有些人引入路由框架后就觉得必须好好利用起来,会把所有的 startActivity(intent) 都改成使用路由跳转。个人之前刚进新公司做组件化项目的时候,有些同事是没接触过组件化的,在一次 review 代码的时候发现网络请求都用路由服务接口来调用。我当时人都看傻了,因为 Retrofit 的接口是在同一个模块内能直接拿到的,用路由就很多余,增加了一次无意义的转发。

很多人都知道路由框架怎么用,但是并不知道该在哪里使用。我个人的建议是,路由框架只在组件间必要的交互上使用,其它非必要的情况能不用就不用

举个例子,账户组件一般会提供一个退出登录的接口方法,使其能在点击退出按钮的时候调用。那既然提供了退出登录的方法,是不是也应该提供个登录方法让别人可以传个账号密码进行登录?不,通常是没有必要的。因为多数时候判断到没有登录后,只会跳到登录页面,并不会在当前页面发起登录的网络请求。所以我们只需给 SignActivity 添加 @Route 注解,不用提供发起登录请求的接口方法,即使提供了也基本没有人会去调用。

还有跳转页面需不需要都改成路由的形式呢?个人建议需要跳转到其它业务组件的某个页面时,才给该页面加上路由。在组件内跳转页面不要使用路由,因为阅读路由跳转的代码还需要知道 path 对应哪个 Activity,如果是 startActivity(intent) 就能直接看到跳转的 Activity,可读性更好。但是有路由跳转拦截需求的话还是得用路由跳转,因为那并不是只调用了 startActivity(intent)。

如果能直接访问到所需的类且没有路由拦截的需求,用了路由和直接实现的效果是一样的,而使用路由会增加代码的复杂度,降低代码的阅读性,并没有必要使用路由。个人建议是在其它模块需要与你负责的组件进行交互才添加路由,在模块内能直接访问到所需的类就按照平时的开发习惯进行实现。

总结

组件化开发的步骤和原理并不难,难的是怎么在复杂的业务需求中更好地进行组件化开发。所以本文分享了很多个人做组件化开发的经验和技巧,根据什么规则去划分组件,划分的时候难免会有些业务会有依赖关系,该怎么处理。还分享了怎么让一个组件能配置多套 UI,KV 存储要注意在每个组件都创建各自的存储对象。

最后还给了个适度使用路由框架的建议,路由框架虽然强大,但是会降低代码的可读性,增加维护成本。如果不是需要增加必要的组件间交互,个人不建议使用路由,按照平时的习惯来开发会更好。

示例代码:待补充。

关于我

一个兴趣使然的程序“工匠”  。有代码洁癖,喜欢封装,对封装有一定的个人见解,有不少个人原创的封装思路。GitHub 有分享一些帮助搭建开发框架的开源库,有任何使用上的问题或者需求都可以提 issues 或者加我微信直接反馈。