遵循最新安卓架构,做一个完美的玩Android App

298 篇文章 4 订阅
183 篇文章 13 订阅

🦄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)完全支持,且可自定义。

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

作者:Lowae
链接:https://juejin.cn/post/7117594416235151367
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值