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)
网域层(Domain Layer) 可选项,用于处理复杂逻辑或支持可重用性吗,当你需要从不同数据源获取数据时如需要同时从数据库和接口请求数据时,推荐使用UseCase进行组合。
App内实现:组合或复用数据源(UseCase)
CollectUseCase
无疑是更好的 (该层是可选的,具体还是要视情况而定)数据层(Data Layer)
使用通用的网络请求库,Retrofit
+ OkHttp
,这个没什么好说的,其中需要注意的是对异常的处理,无论是请求异常或是业务异常。
我见过大部分开源WanAndroid都是每个接口请求后自己再判断,先try catch异常,然后在里面判断是否有业务异常,这样也不是不行,但是不够优雅,使用起来我就不能直接拿到数据吗?而且本身这些非业务的异常也不是发起者自身能够完全处理的。所以需要一个全局的网络异常处理。
我们知道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。
*在对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
}
.............
这样我们就实现了一个较为统一且优雅的接口请求的异常处理。
这个我为啥要提呢?因为我觉得依赖注入是推荐的应用架构不可分割的一部分,在其中还有一层可选项叫网域层(Domain Layer)。
它是用于负责封装复杂的业务逻辑,或者由多个 ViewModel 重复使用的简单业务逻辑UseCase等。它是可选的,因为并非所有场景都有这类需求,例如处理复杂逻辑或支持可重用性。
既然需要可重用性,那不可避免的会需要很多依赖项,而Hilt正是为了解决这个问题而来的。
使用可参考官方文档使用 Hilt 实现依赖项注入。
同时Hilt支持对ViewModel的注入,可以免去很多ViewModelFactory的创建,当然如果需要的话你比如需要自己管理ViewModelStore等等,你还是可以通过注入到ViewModelFactory然后Provider对应的ViewModel变相的完成。细节可见AppViewModelFactory。
LiveData虽可以被Flow代替,但是它足够的轻量,很适合One-Shot型数据,比如只是需要获取一次的接口数据,还需要持有该数据的时候,而且本身也可以搭配协程使用将协程与 LiveData 一起使用,所以还是有其用武之地的,但是要拿它能作为数据流处理来用,那便超出其本身设计范围了。
包括说的postValue丢数据
(源码包括注释写的很清楚)、粘性事件
(注释有说:LiveData是一个数据持有类,注意是持有,那必然是一个Shared数据)等等,我并不觉得是其缺点,而是被赋予了过强的责任。当然也可能是早期协程尚未成熟而推出的过渡之举。
对于复杂数据流就使用Flow,明确区分了冷,热流,你所期望LiveData承载的,Flow(StateFlow, SharedFlow)完全支持,且可自定义。
协程需要作用域这个我们是知道的,官方有提供了LifecycleScope
和ViewModelScope
等具有生命周期感知的作用域,会在其生命周期结束时自动取消协程。
使用有以下方法:X代指Lifecycle.State
(CREATED, STARTED, RESUMED)
其实道理也很简单,因为一个页面的生命周期不是单向的,比如你打开地图,背后有一个位置更新数据流,如果这个时候你打开了新的页面,假设你采用1,2方式开启的协程,虽然2方式可以暂时挂起,也就是说View不会刷新,但是数据流还是在不断的产生位置信息,详细背景可见使用更为安全的方式收集 Android UI 数据流。
因此,需要一个能在指定生命周期挂起且能取消数据的产生,同时还能具备重启的方法,这也是repeatOnLifecycleX的作用,详细背景可见设计 repeatOnLifecycle API 背后的故事。
但是这可能会带来一个新的细节问题,比如使用冷流的时候,冷流会在新的订阅者收集数据时,按需执行生产者,也就是说冷流重启后会失去之前的状态,因此,最好将冷流转换为热流使用,因为热流的数据是Shared的,也就是说重新收集时,会收到之前的数据。
ViewModelScope相比LifecycleScope要简单的多,适合作为冷流共享时的作用域,如stateIn
,shareIn
时提供缓存数据的作用域,这一点在使用Paging的时候很有用处,Paging会提供一个FLow
,它是一个冷流,假如你单纯配合LifecycleScope,如下所示:
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
getPagingDataFlow().collect{
...
}
}
}
在协程重启后,会再次请求PagingDataFlow,也就是说再次请求接口,这肯定不是预期的,因为它是冷流,所以需要将其变成热流,好在Paging提供了这样的方法cacheIn(ViewModelScope)
会在ViewModelScope内共享数据,从而避免了重复的请求。
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数据列表(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)
}
}
}
SharedPreference不用说了,已经被抛弃了,替代品正是与协程相结合的DataStore
使用协程和Flow 以异步、一致的事务方式存储数据。
目前项目内只用到Preferences DataStore,具体实现可参考采用项目内使用DataStore持久化Cookie - CookieJarImpl。
使用下来怎么说呢?它本身使用使用协程和Flow处理,这既是优点也是缺点,因为它未实现SharedPreferences
,所以你想简单的像原来一样getString或是putString,不行,你需要开启协程,开启协程肯定也要提供作用域吧?这样下来其实写起来就特别的麻烦。
更搞的是PreferenceFragmentCompat
它还是用的SharePreference,你还没办法用DataStore,所以想要使用DataStore的话我建议还是搭配MMKV一起。
感谢鸿洋大佬的WanAndroid网站提供的开放API。
项目地址: Design WanAndroid
有任何问题欢迎提Issue,喜欢的话也可以点个⭐Star~