Design WanAndroid(WanAndroid的最佳可使用的Android客户端)

5,929 阅读11分钟

🦄Design WanAndroid

前言

  • 背景
    • 之前一直都是使用的Rxjava,响应式编程是真的写起来特别的简洁优雅,而且直观,一个数据流,从发射->中间的数据转换->消费一目了然(当然前提是本身使用恰当),其中各式各样的操作符完美覆盖任何场景。但是使用的多了也免不了会发现一些问题,比如debug的时候简直难受,还有对于当前逻辑没有显式的表明是否为子线程,特别在函数调用链长且在不同类的时候,往往要追溯很久等等此类。当然事物本来也无完美之事,我们能做的便是尽力追求完美的事物。
    • 此外便是目前Android也诞生了很多新东西,所以我打算尝试一下,说不定能带来一些新的视角,因为一件新事物的诞生往往是需要解决旧事物的某处不足。
  • 实现
    • 目前WanAndroid公开的Api均已实现,我需要的是一个完美的App而不是充满着TODO项。

介绍

App内通篇全采用Material Design 3风格,拒绝半完成式Material带来的UI的割裂感。

我见多很多WanAndroid的开源客户端,在UI上都不怎么重视,但是如果要是日常使用的App,没有得体的UI我相信很难有使用的动力,而Material Design无疑是最好的选择

所有Icon取自Material Symbols,统一而规范的设计。

主题色遵循Material3 Color system

  • PrimaryColor, On-primary, Primary container, On-primary container
  • SecondaryColor 同上
  • TertiaryColor

默认主题色采用Material Theme Builder从图片取色而成。

实现Dynamic Colors,开启动态主题色后,App主题色自动跟随系统主题色且适配深色模式,保持一贯的视觉体验(Android 12及以上支持)

所以可交互的UI均带有Ripple效果,明确表示这是个可交互控件,且Ripple颜色支持取自当前Dynamic colors的主题色



实现

使用buildSrc,实现全局且统一的依赖管理。

严格遵循Android Architecture Components,逻辑分为:

  • 界面层(UI Layer)

    • APP内实现:视图(Activity/Fragment等) + 数据驱动及处理逻辑的状态容器(ViewModel等)
  • 网域层(Domain Layer) 可选项,用于处理复杂逻辑或支持可重用性吗,当你需要从不同数据源获取数据时如需要同时从数据库和接口请求数据时,推荐使用UseCase进行组合。

    • App内实现:组合或复用数据源(UseCase)

      • 比如App内的收藏行为,本身这是一个非常公共性的操作,我可以在大部分地方取消或是收藏一篇文章,所以很适合重用,因此单独作为一个CollectUseCase无疑是更好的 (该层是可选的,具体还是要视情况而定)
  • 数据层(Data Layer)

    • App内实现:数据源(Repository)

Retorfit + OkHttp

使用通用的网络请求库,Retrofit + OkHttp,这个没什么好说的,其中需要注意的是对异常的处理,无论是请求异常或是业务异常。

我见过大部分开源WanAndroid都是每个接口请求后自己再判断,先try catch异常,然后在里面判断是否有业务异常,这样也不是不行,但是不够优雅,使用起来我就不能直接拿到数据吗?而且本身这些非业务的异常也不是发起者自身能够完全处理的。所以需要一个全局的网络异常处理。

CallAdapter, Converter

我们知道Retorfit的强大之一无疑在于其的可定制化强。所以也是从这两个入手。

WanAndroid的接口返回统一结构是:

{ 
    "data": ...,
    "errorCode": 0,
    "errorMsg": "" 
}

这次要做的是把data与error拆开来,正是上面说的使用起来我就不能直接拿到数据吗? 先枚举一下网球请求响应的状态,使用sealed class可以让when表达式穷举且比起enum class更为灵活。

源代码:NetworkResponse

sealed class NetworkResponse<out T: Any> {
    /**
     * 成功
     */
    data class Success<T: Any>(val data: T) : NetworkResponse<T>()

    /**
     * 业务错误
     */
    data class BizError(val errorCode: Int = UNKNOWN_CODE, val errorMessage: String = "") :
        NetworkResponse<Nothing>()

    /**
     * 其他错误
     */
    data class UnknownError(val throwable: Throwable) : NetworkResponse<Nothing>()
}

这是我们的需要统一的接口返回类型:

  • 如果接口errorCode = 0,说明业务逻辑正常,data直接赋值,返回NetworkResponse.Success

  • 如果errorCode !=0 ,说明有业务错误,data就不需要了,返回NetworkResponse.BizError

  • 对于非业务的异常,归类为UnknownError,因为对于下游来说是非预期的。

那么,如何让接口返回这个类型?所以需要自定义CallAdapter,篇幅较长,可见NetworkResponseAdapter

在对CallAdapter的处理中,本身是对Call的处理,所以这里你是可以传入一个ErrorHandler之类的异常处理接口,来实现全局的响应异常处理

我们用获取首页置顶文章列表来举例,最终实现效果如下:

@GET("article/top/json")
suspend fun getArticleTopList(): NetworkResponse<List<Article>>

现在类型转换有了,那我要怎么做到把接口返回拆分开来? 其实无非还是解析,只是这次我们自己来处理解析的逻辑,所以需要自定义Converter,因为代码有点多,详细可见GsonConverterFactory,这里就不再贴全。

对于ResponseBody,需要自己进行解析处理

........
while (jsonReader.hasNext()) {
    when (jsonReader.nextName()) {
        "errorCode" -> errorCode = jsonReader.nextInt()
        "errorMsg" -> errorMsg = jsonReader.nextString()
        "data" -> data = adapter.read(jsonReader)
        else -> jsonReader.skipValue()
    }
}
...
return if (errorCode != 0) {
    NetworkResponse.BizError(errorCode, errorMsg)
} else {
    NetworkResponse.Success(data)
}

这样,我们实现了拆分,成功的请求我就不需要关注errorCode,业务错误的请求我同样不需要关注data。

同时,参照Kotlin集合类的扩展方法命名,再加入一点扩展函数方便使用

inline val NetworkResponse<*>.isSuccess: Boolean
    get() {
        return this is NetworkResponse.Success
    }

fun <T : Any> NetworkResponse<T>.getOrNull(): T? =
    when (this) {
        is NetworkResponse.Success -> data
        is NetworkResponse.BizError -> null
        is NetworkResponse.UnknownError -> null
    }

fun <T : Any> NetworkResponse<T>.getOrThrow(): T =
    when (this) {
        is NetworkResponse.Success -> data
        is NetworkResponse.BizError -> throw ApiException(errorCode, errorMessage)
        is NetworkResponse.UnknownError -> throw throwable
    }
.............

这样我们就实现了一个较为统一且优雅的接口请求的异常处理。

Hilt

这个我为啥要提呢?因为我觉得依赖注入是推荐的应用架构不可分割的一部分,在其中还有一层可选项叫网域层(Domain Layer)。

它是用于负责封装复杂的业务逻辑,或者由多个 ViewModel 重复使用的简单业务逻辑UseCase等。它是可选的,因为并非所有场景都有这类需求,例如处理复杂逻辑或支持可重用性。

既然需要可重用性,那不可避免的会需要很多依赖项,而Hilt正是为了解决这个问题而来的。 使用可参考官方文档使用 Hilt 实现依赖项注入

正如最开始提到的CollectUseCase,其本身肯定需要一个收藏Service,而且需要作为Provider提供于要使用ViewModel或是其他地方,所以依赖注入是最适合不过了,不然一层层的依赖传递下去,很容易变得难以改动。

同时Hilt支持对ViewModel的注入,可以免去很多ViewModelFactory的创建,当然如果需要的话你比如需要自己管理ViewModelStore等等,你还是可以通过注入到ViewModelFactory然后Provider对应的ViewModel变相的完成。细节可见AppViewModelFactory

Flow & LiveData

LiveData虽可以被Flow代替,但是它足够的轻量,很适合One-Shot型数据,比如只是需要获取一次的接口数据,还需要持有该数据的时候,而且本身也可以搭配协程使用将协程与 LiveData 一起使用,所以还是有其用武之地的,但是要拿它能作为数据流处理来用,那便超出其本身设计范围了。

包括说的postValue丢数据(源码包括注释写的很清楚)、粘性事件(注释有说:LiveData是一个数据持有类,注意是持有,那必然是一个Shared数据)等等,我并不觉得是其缺点,而是被赋予了过强的责任。当然也可能是早期协程尚未成熟而推出的过渡之举。

对于复杂数据流就使用Flow,明确区分了冷,热流,你所期望LiveData承载的,Flow(StateFlow, SharedFlow)完全支持,且可自定义。

LifecycleScope

协程需要作用域这个我们是知道的,官方有提供了LifecycleScopeViewModelScope等具有生命周期感知的作用域,会在其生命周期结束时自动取消协程。

使用有以下方法:X代指Lifecycle.State(CREATED, STARTED, RESUMED)

  1. launch
    • 立即开启协程(不推荐)
  2. launchWhenX
    • 处于指定生命周期后开启协程(会逐步弃用,被下一个代替)
  3. launch + repeatOnLifecycleX
    • 当生命周期重新位于指定的状态后可重启协程(推荐使用)

其实道理也很简单,因为一个页面的生命周期不是单向的,比如你打开地图,背后有一个位置更新数据流,如果这个时候你打开了新的页面,假设你采用1,2方式开启的协程,虽然2方式可以暂时挂起,也就是说View不会刷新,但是数据流还是在不断的产生位置信息,详细背景可见使用更为安全的方式收集 Android UI 数据流

因此,需要一个能在指定生命周期挂起且能取消数据的产生,同时还能具备重启的方法,这也是repeatOnLifecycleX的作用,详细背景可见设计 repeatOnLifecycle API 背后的故事

但是这可能会带来一个新的细节问题,比如使用冷流的时候,冷流会在新的订阅者收集数据时,按需执行生产者,也就是说冷流重启后会失去之前的状态,因此,最好将冷流转换为热流使用,因为热流的数据是Shared的,也就是说重新收集时,会收到之前的数据。

ViewModelScope

ViewModelScope相比LifecycleScope要简单的多,适合作为冷流共享时的作用域,如stateIn,shareIn时提供缓存数据的作用域,这一点在使用Paging的时候很有用处,Paging会提供一个FLow<PagingData<*>>,它是一个冷流,假如你单纯配合LifecycleScope,如下所示:

lifecycleScope.launch {
            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                getPagingDataFlow().collect{
                    ...
                }
            }
}

在协程重启后,会再次请求PagingDataFlow,也就是说再次请求接口,这肯定不是预期的,因为它是冷流,所以需要将其变成热流,好在Paging提供了这样的方法cacheIn(ViewModelScope)会在ViewModelScope内共享数据,从而避免了重复的请求。

Paging

RecyclerView本身是很灵活的,但是由于其灵活所以写起来还是有一点繁琐,这也是Paging出现的原因,使用它能够很方便的实现分页请求,状态管理且支持Flow等数据流式处理。

但是它对于多类型RecyclerView还是没给出一个较好的方案,即使是目前的ConcatAdapter,感觉也是不够好,所以引入了MultiType,但新的问题随之而来,MultiType并不支持Paging,所以决定定制化MultiType,使其支持Paging的机制。

通过查看PagingDataAdapter的源码,可以发现其本身功能不多,全由AsyncPagingDataDiffer来实现,所以实现起来不麻烦,先将MultiTypeAdapter注意逻辑抽出为基类,(主要逻辑在于register,抽出来也不麻烦),然后子类继承并实现PagingDataAdapter的功能即可。具体源码可见PagingMultiTypeAdapter

同时对于PagingSource的使用简单封装了一个Key值为Int类型的PagingSource,因为PagingSource本身也很简单,位于的区别在于load,所以直接暴露给外部,外部提供返回值就行了。 IntKeyPagingSource

/**
* @param service Api Service
* @param pageStart 分页起始页码
* @param load List<V>数据列表(LoadResult.Page里的data只支持List)
*/
class IntKeyPagingSource<S : BaseService, V : Any>(
    private val pageStart: Int = BaseService.DEFAULT_PAGE_START_NO_1,
    private val service: S,
    private val load: suspend (S, Int, Int) -> List<V>
) : PagingSource<Int, V>() {

    override fun getRefreshKey(state: PagingState<Int, V>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, V> {
        val page = params.key ?: pageStart
        return try {
            val data = load(service, page, params.loadSize)
            LoadResult.Page(
                data = data,
                prevKey = if (page == pageStart) null else page - 1,
                nextKey = if (data.isEmpty()) null else page + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }
}

DataStore

SharedPreference不用说了,已经被抛弃了,替代品正是与协程相结合的DataStore使用协程和Flow 以异步、一致的事务方式存储数据。

目前项目内只用到Preferences DataStore,具体实现可参考采用项目内使用DataStore持久化Cookie - CookieJarImpl

使用下来怎么说呢?它本身使用使用协程和Flow处理,这既是优点也是缺点,因为它未实现SharedPreferences,所以你想简单的像原来一样getString或是putString,不行,你需要开启协程,开启协程肯定也要提供作用域吧?这样下来其实写起来就特别的麻烦。

更搞的是PreferenceFragmentCompat它还是用的SharePreference,你还没办法用DataStore,所以想要使用DataStore的话我建议还是搭配MMKV一起。

最后

感谢鸿洋大佬的WanAndroid网站提供的开放API

项目地址: 🦄Design WanAndroid

有任何问题欢迎提Issue,喜欢的话也可以点个⭐Star~