🦄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。
默认主题色采用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)
-
数据层(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 = 0, 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。
我们用获取首页置顶文章列表来举例,最终实现效果如下
@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。
再加一点扩展函数方便使用
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 实现依赖项注入
Flow & LiveData
LiveData虽可以被Flow代替,但是它足够的轻量,很适合One-Shot型数据,比如只是需要获取一次的接口数据,而且本身也可以搭配协程使用将协程与 LiveData 一起使用,所以还是有其用武之地的。
而复杂数据流就使用flow,其实类似Rxjava,使用起来区别不大,只是有较多的@ExperimentalCoroutinesApi
。。。希望能早日stable。对于flow主要的还是StateFlow 和 SharedFlow,其实和Rxjava的冷,热流概念是一致的。
Paging
RecyclerView本身是很灵活的,但是由于其灵活所以写起来还是有一点繁琐,这也是Paging出现的原因,使用它能够很方便的实现分页请求,状态管理且支持Flow等数据流式处理。
但是它对于多类型RecyclerView还是没给出一个较好的方案,即使是目前的ConcatAdapter,感觉也是不够好,所以引入了MultiType,但新的问题随之而来,MultiType并不支持Paging,所以决定定制化MultiType,使其支持Paging的机制。
通过查看PagingDataAdapter的源码,可以发现其本身功能不多,全由AsyncPagingDataDiffer
来实现,所以实现起来不麻烦,先将MultiTypeAdapter注意逻辑抽出为基类,(主要逻辑在于register,抽出来也不麻烦),然后子类继承并实现PagingDataAdapter的功能即可。具体源码可见PagingMultiTypeAdapter。
同时对于PagingSource的使用简单封装了一个Key值为Int类型的PagingSource,因为PagingSource本身也很简单,位于的区别在于load,所以直接暴露给外部,外部提供返回值就行了。
IntKeyPagingSource
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
使用下来怎么说呢?它本身使用使用协程和Flow处理,这既是优点也是缺点,因为它未实现SharedPreferences
,所以你想简单的像原来一样getString或是putString,不行,你需要开启协程,开启协程肯定也要提供作用域吧?这样下来其实写起来就特别的麻烦。
目前实现具体可参考采用项目内使用DataStore持久化cookie - CookieJarImpl,还是有那么一点别扭的。
更搞的是PreferenceFragmentCompat
它还是用的SharePreference,你还没办法用DataStore,所以想要使用DataStore的话我建议还是搭配MMKV一起。
最后
感谢WanAndroid网站提供的开放API。
项目地址: 🦄Design WanAndroid
有任何问题欢迎提Issue,喜欢的话也可以点个⭐Star~