「神奇宝贝项目」让人眼前一亮的 Jetpack + MVVM 极

2020-07-16  本文已影响0人  Android进阶小麦

前言

Jetpack 实战项目 PokemonGo(神奇宝贝)基于 MVVM 架构和 Repository 设计模式,PokemonGo 项目中用到的技术,都是之前写过的一系列文章里面涉及到的知识点:Paging3(network + db),Dagger-Hilt,App Startup,DataBinding,Room,Motionlayout,Kotlin Flow,Coil,JProgressView 等等。

项目 PokemonGo 已经上传到 GitHub: https://github.com/hi-dhl/PokemonGo,欢迎前去查看,动态效果图如下所示,如果动图无法查看,请点击这里查看 动态效果图 | 静态图

Jetpack 实战项目 PokemonGo 包含了以下功能:

  1. 自定义 MediatorResult 实现 network + db 的混合使用 ( MediatorResult 是 Paging3 当中重要成员 )
  2. 使用 Data Mapper 分离数据源 和 UI
  3. Kotlin Flow 结合 Retrofit2 + Room 的混合使用
  4. Kotlin Flow 与 LiveData 的使用
  5. 使用 Coil 加载图片
  6. 使用 ViewModel、LiveData、DataBinding 协同工作
  7. 使用 Motionlayout 做动画
  8. App Startup 与 Hilt 的使用

Jetpack 实战项目 PokemonGo 涉及的技术:

以上技术栈对应之前写的技术文章:

如果之前对这些技术没有接触过,或者只是听说,对阅读本文没有什么影响,本文会对这些技术结合着项目 PokemonGo 来分析,为了文章的简洁性,本文不会细究技术细节,因为每个技术都需要花好几篇文章才能分析清楚,我会在后续的文章去详细分析。

如何检查依赖库最新版本

在之前的文章 再见吧 buildSrc, 拥抱 Composing builds 提升 Android 编译速度 分析过,到目前为止大概管理 Gradle 依赖提供了 4 种不同方法:

新版的 AndroidStudio 只支持 ext 的方式手动方式管理 检查依赖库是否存在最新版本,不支持 buildSrc、gradle-wrapper 版本的检查。

满足不了 PokemonGo 项目的需求,在 PokemonGo 项目中采用 buildSrc 方式去管理所有依赖库,因为 PokemonGo 项目采用单模块结构,而且支持 自动补全单击跳转 很方便,所这里用到了 Gradle Versions Plugin 插件去检查依赖库的最新版本,检查结果如下所示:

The following dependencies have later release versions:
 - androidx.swiperefreshlayout:swiperefreshlayout [1.0.0 -> 1.1.0]
     https://developer.android.com/jetpack/androidx
 - com.squareup.okhttp3:logging-interceptor [3.9.0 -> 4.7.2]
     https://square.github.io/okhttp/
 - junit:junit [4.12 -> 4.13]
     http://junit.org
 - org.koin:koin-android [2.1.5 -> 2.1.6]
 - org.koin:koin-androidx-viewmodel [2.1.5 -> 2.1.6]
 - org.koin:koin-core [2.1.5 -> 2.1.6]

Gradle release-candidate updates:
 - Gradle: [6.1.1 -> 6.5.1]
复制代码

会列出所有需要更新的依赖库的最新版本,并且 Gradle Versions Plugin 比 AndroidStudio 所支持的更加全面:

那么如何使用呢?只需要三步

MVVM 架构

Jetpack 实战项目 PokemonGo 基于 MVVM 架构和 Repository 设计模式,如今几乎所有的 Android 开发者至少都听过 MVVM 架构,在谷歌 Android 团队宣布了 Jetpack 的视图模型之后,它已经成为了现代 Android 开发模式最流行的架构之一,如下图所示:

MVVM 有助于将应用程序的业务逻辑与 UI 完全分开。 如果业务逻辑与 UI 逻辑之间的联系非常紧密,那么维护将很困难,由于很难重用业务逻辑,因此编写单元测试代码非常困难,一堆重复的代码和复杂的逻辑。

Jetpack 的视图模型的 MVVM 架构由 View + DataBinding + ViewModel + Model 组成。

DataBinding

DataBinding(数据绑定)实际上是 XML 布局中的另一个视图结构层次,视图 (XML) 通过数据绑定层不断地与 ViewModel 交互。

我们来看一个例子,首页上有个 RecyclerView 用来展示神奇宝贝数据(名字、图片、点击事件等等),每一个 item 对应一个 ViewHolder,来看一下 ViewHolder 的实现。

class PokemonViewModel(view: View) : DataBindingViewHolder<PokemonListModel>(view) {
    private val mBinding: RecycleItemPokemonBinding by viewHolderBinding(view)

    override fun bindData(data: PokemonListModel, position: Int) {
        mBinding.apply {
            pokemon = data
            executePendingBindings()
        }
    }

}
复制代码

正如你所看到的,由于使用了数据绑定,ViewHolder 里面的代码变的非常简单,可能这个例子不够明显,我们来看一个劲爆的,点击首页每一个 item 会跳转到详情页面,详情页面如下图所示:

image.png

详情页面(DetailActivity)展示了神奇宝贝的详细数据,先查询数据库,如果没有找到,读取网路数据然后保存到数据库,由于使用了数据绑定,代码变得非常简单,如下所示:

class DetailActivity : DataBindingAppCompatActivity() {

    private val mBindingActivity: ActivityDetailsBinding by binding(R.layout.activity_details)
    private val mViewModel: DetailViewModel by viewModels()
    lateinit var mPokemonModel: PokemonListModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        mBindingActivity.apply {
            mPokemonModel = requireNotNull(intent.getParcelableExtra(KEY_LIST_MODEL))
            pokemonListModel = mPokemonModel
            lifecycleOwner = this@DetailActivity
            viewModel = mViewModel.apply {
                fectchPokemonInfo(mPokemonModel.name)
                    .observe(this@DetailActivity, Observer {})
            }
        }
    }
}
复制代码

正如你所见 DetailActivity 代码变得非常简单,如果以后我们想要改变网络的 URL、Model、获取或保存数据的方式等等,我们不需要改变 DetailActivity 中的任何代码。

更多关于 DataBinding 的使用请参考我另外一个仓库 JDataBinding:目前已经封装了一系列的组件包含 DataBindingActivity、DataBindingAppCompatActivity、DataBindingFragmentActivity、DataBindingFragment、DataBindingDialog、DataBindingListAdapter、DataBindingViewHolder 等等。

ViewModel

ViewModel 是 MVVM 架构中非常重要的设计,它在 activities 或 fragments 和业务逻辑中起到了非常重要的作用,它不依赖于 UI 组件,使得单元测试更加容易,ViewModel 以生命周期的方式管理界面相关的数据,直到 Activity 被销毁。

LiveData 与 ViewModel 具有很好的协同作用,LiveData 持有从数据源获取到的数据,并且它可以被 DataBinding 组件观察,当 Activity 被销毁时,它将被取消订阅。

而详情页面(DetailActivity) 代码之所以能这么简单得益于 ViewModel、LiveData、DataBinding 协同工作, 我们来看一下 ViewModel 代码。

class DetailViewModel @ViewModelInject constructor(
    val polemonRepository: Repository
) : ViewModel() {
    private val _pokemon = MutableLiveData<PokemonInfoModel>()
    val pokemon: LiveData<PokemonInfoModel> = _pokemon

    @OptIn(ExperimentalCoroutinesApi::class)
    fun fectchPokemonInfo(name: String) = liveData<PokemonInfoModel> {
        polemonRepository.featchPokemonInfo(name)
        .collectLatest {
                _pokemon.postValue(it)
                emit(it)
            }
        .......
        // 省略部分代码,
    }

}
复制代码

activity_details.xml 代码

<layout 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">

    <data>
        <variable
            name="viewModel"
            type="com.hi.dhl.pokemon.ui.detail.DetailViewModel" />

    </data>

    ......
    <androidx.appcompat.widget.AppCompatTextView
        android:id="@+id/weight"
        android:text="@{viewModel.pokemon.getWeightString}"/>
    ......

</layout>
复制代码

这是获取神奇宝贝的详细信息,通过 DataBinding 以声明方式将数据(神奇宝贝的体重)绑定到界面上,更多使用参考项目中的代码。

Repository

Repository 设计模式是最流行、应用最广泛的设计模式之一,在 Repository 层获取网络数据,并将数据存储到数据库中,在这一层中有两个非常重要的成员 Paging3 库中的 MediatorResult 和 Data Mappers。

MediatorResult

在之前的文章 Jetpack 成员 Paging3 实践以及源码分析(一)Jetpack 新成员 Paging3 网络实践及原理分析(二) 分别分析了使用 Paging3 访问 数据库网络,但是遗漏了 MediatorResult 类的使用,MediatorResult 是 Paging3 当中一个非常重要的成员,用于实现 数据库网络 访问,所以这里是对之前的文章一个补充。

MediatorResult 很重要,需要单独花一篇文章去分析,为了节省篇幅,在这里不会详细的去分析它,如果对 MediatorResult 不太理解没有关系,我会在后续的文章里面详细的分析它。

项目中网络访问用的是 Retrofit2 & OkHttp3 用来请求网络数据,使用 Room 作为数据库存储,将获得的数据保存到数据库中,Room 在 SQLite 上提供了一个抽象层,流畅地访问 SQLite 数据库,同时拥有了 SQLite 全部功能,在编译的时候进行错误检查。

@OptIn(ExperimentalPagingApi::class)
class PokemonRemoteMediator(
    val api: PokemonService,
    val db: AppDataBase
) : RemoteMediator<Int, PokemonEntity>() {
    val mPageKey = 0
    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, PokemonEntity>
    ): MediatorResult {
        try {

            ......
            val pageKey = when (loadType) {
                // 首次访问 或者调用 PagingDataAdapter.refresh()
                LoadType.REFRESH -> null
                // 在当前加载的数据集的开头加载数据时
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
                // 在当前数据集末尾添加数据
                LoadType.APPEND -> {
                    ......
                    if (remoteKey == null || remoteKey.nextKey == null) {
                        return MediatorResult.Success(endOfPaginationReached = true)
                    }
                    remoteKey.nextKey
                }
            }

            ......
            // 使用 Retrofit2 获取网络数据
            val page = pageKey ?: 0
            val result = api.fetchPokemonList(
                state.config.pageSize,
                page * state.config.pageSize
            ).results

            .......

            db.withTransaction {
                if (loadType == LoadType.REFRESH) { // 当首次加载,或者下拉刷新的时候,清空当前数据 }
                ......
                // 存储获取到的数据
                remoteKeysDao.insertAll(entity)
                pokemonDao.insertPokemon(item)
            }

            return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
        } catch (e: IOException) {
            return MediatorResult.Error(e)
        } catch (e: HttpException) {
            return MediatorResult.Error(e)
        }
    }
}
复制代码

注意:使用了 @OptIn(ExperimentalPagingApi::class) 需要在 App 模块 build.gradle 文件内添加以下代码。

android {
    kotlinOptions {
        freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"]
    }
}
复制代码

MediatorResult 的实现类 PokemonRemoteMediator 中的核心部分是关于参数 LoadType 的判断。

接下来的逻辑和之前请求网络数据的逻辑没有什么区别了,使用 Retrofit2 获取网络数据,然后使用 Room 将数据保存到数据库中。

接下来聊一下 Repository 中另外一个重要的成员 Data Mapper,在项目中起到了非常的重要,在一个快速开发的项目中,为了越快完成第一个版本交付,下意识的将数据源和 UI 绑定到一起,当业务逐渐增多,数据源变化了,上层也要一起变化,导致后期的重构工作量很大,核心的原因耦合性太强了。

Data Mapper(个人建议)

Data Mapper 的意识非常重要,在项目中起到了非常的重要,关于 Data Mappers 在 Repository 中的重要性可以看一下国外大神写的这篇文章 The “Real” Repository Pattern in Android 在 Medium 上获得了 4.9K 的赞。

使用 Data Mapper 分离数据源的 Model 和 页面显示的 Model,不要因为数据源的增加、修改或者删除,导致上层页面也要跟着一起修改,换句话说使用 Data Mapper 做一个中间转换,如下图所示,来源于网络:

使用 Data Mapper(数据映射)优点如下:

如果在一个大型项目中直接使用 Data Mapper 会有适得其反的效果,所以需要结合设计模式来完善,这不在本文讨论范围之内,其实在这里我想表达是,不要因为快速实现某个功能,下意识的将数据源的 model 和 UI 绑定在一起。

Data Mappe 实现方式有很多种,可以手动实现,也可以通过引入第三方框架,其中有名框架 modelmapper,在 PokemonGo 项目中是手动实现的。

Koltin Flow

停止使用 RxJava,尝试一下 Flow,不仅简单而且功能很强大,Retrofit2 和 Room 也都提供了对应的支持。

Flow 库是在 Kotlin Coroutines 1.3.2 发布之后新增的库,也叫做异步流,类似 RxJava 的 Observable,在 PokemonGo 项目中也用到了 Flow。

override suspend fun featchPokemonInfo(name: String): Flow<PokemonInfoModel> {
    return flow {
        val pokemonDao = db.pokemonInfoDao()
        var infoModel = pokemonDao.getPokemon(name)
        // 查询数据库是否存在,如果不存在请求网络
        if (infoModel == null) {
            // 网络请求
            val netWorkPokemonInfo = api.fetchPokemonInfo(name)
            ......
            pokemonDao.insertPokemon(infoModel) // 插入更新数据库
        }

        val model = mapper2InfoModel.map(infoModel) // 数据转换
        emit(model)
    }.flowOn(Dispatchers.IO)
}
复制代码

在这里做了三件事:

依赖注入

Hilt、Dagger、Koin 等等都是依赖注入库,使用依赖注入库有以下优点:

在 PokemonGo 项目中使用的是 Hilt,Hilt 是在 Dagger 基础上进行开发的,减少了在项目中进行手动依赖,Hilt 集成了 Jetpack 库和 Android 框架类,并删除了大部分模板代码,让开发者只需要关注如何进行绑定,同时 Hilt 也继承了 Dagger 优点,编译时正确性、运行时性能、并且得到了 Android Studio 的支持,来看一下 Hilt 与 Room 在一起使用的例子。

@Module
@InstallIn(ApplicationComponent::class)
object RoomModule {

    /**
     * @Provides 常用于被 @Module 注解标记类的内部的方法,并提供依赖项对象。
     * @Singleton 提供单例
     */
    @Provides
    @Singleton
    fun provideAppDataBase(application: Application): AppDataBase {
        return Room
            .databaseBuilder(application, AppDataBase::class.java, "dhl.db")
            .fallbackToDestructiveMigration()
            .allowMainThreadQueries()
            .build()
    }

    @Singleton
    @Provides
    fun provideTasksRepository(
        db: AppDataBase
    ): Repository {
        return PokemonFactory.makePokemonRepository(db)
    }
}
复制代码

这里需要用到 @Module 注解,使用 @Module 注解的普通类,在其内部提供 Room 的实例,更多使用可以查看 PokemonGo 项目。

小巧灵活的进度条

神奇宝贝详情页的进度条使用的是 JProgressView :一个小巧灵活可定制的进度条,支持图形:圆形、圆角矩形、矩形等等,效果如下图所示:

起源于当时想用一个现成的库,但是在网上找了很多,没有一个合适自己的,要不大而全,要不作者好久没更新了,要不不兼容 DataBinding,于是乎就自己封装了一个小巧灵活的进度条,项目长期维护并持续更新,如果有更好的建议欢迎告知我,JProgressView 使用非常的简单,根据自己的需求去配置即可。

<com.hi.dhl.jprogressview.JProgressView
    android:layout_width="match_parent"
    android:layout_height="18dp"
    android:layout_below="@+id/exp"
    android:translationZ="100dp"
    app:maxProgressValue="@{viewModel.pokemon.maxExp}"
    app:progressValue="@{viewModel.pokemon.exp}"
    app:progress_animate_duration="@integer/progress_animate_duration"
    app:progress_color="@color/color_progress_4"
    app:progress_color_background="@color/color_progress_bg"
    app:progress_paint_bg_width="@dimen/circle_stroke_width"
    app:progress_paint_value_width="@dimen/circle_stroke_width"
    app:progress_text_color="@android:color/black"
    app:progress_text_size="@dimen/text_size_12sp"
    app:progress_type="@integer/porgress_tpye_round_rect" />
复制代码
名称 值类型 默认值 备注
progress_type integer 圆形:1 矩形:0;矩形:0;矩形:0
progress_animate_duration integer 2000 动画运行时间
progress_color color Color.GRAY 当前进度颜色
progress_color_background color Color.GRAY 进度条背景颜色
progress_paint_bg_width dimen 10 进度条背景画笔的宽度
progress_paint_value_width dimen 10 当前进度画笔的宽度
progress_text_color color Color.BLUE 进度条上的文字的颜色
progress_text_size dimen sp2Px(20f) 进度条上的文字的大小
progress_text_visible boolean 默认不显示:false 是否显示文字
progress_value integer 0 当前进度
progress_value_max integer 100 当前进度条的最大值

更多关于进度条的使用,查看 JProgressView 仓库,全文到这里就结束了,为了节省篇幅,很多在之前系列文章里面分析过的,这里不在详细分析了,更多技术细节会在后续的系列文章中分析。

正在建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,目前已经包含了 App Startup、Paging3、Hilt 等等,正在逐渐增加其他 Jetpack 新成员,仓库持续更新,可以前去查看:AndroidX-Jetpack-Practice, 如果这个仓库对你有帮助,请仓库右上角帮我点个赞。

结语

致力于分享一系列 Android 系统源码、逆向分析、算法、翻译、Jetpack 源码相关的文章,正在努力写出更好的文章,如果这篇文章对你有帮助给个 star,文章中有什么没有写明白的地方,或者有什么更好的建议欢迎留言,欢迎一起来学习,在技术的道路上一起前进。

总结

【Android 详细知识点思维脑图(技能树)】

image

其实Android开发的知识点就那么多,面试问来问去还是那么点东西。所以面试没有其他的诀窍,只看你对这些知识点准备的充分程度。so,出去面试时先看看自己复习到了哪个阶段就好。

虽然 Android 没有前几年火热了,已经过去了会四大组件就能找到高薪职位的时代了。这只能说明 Android 中级以下的岗位饱和了,现在高级工程师还是比较缺少的,很多高级职位给的薪资真的特别高(钱多也不一定能找到合适的),所以努力让自己成为高级工程师才是最重要的。

这里附上上述的面试题相关的几十套字节跳动,京东,小米,腾讯、头条、阿里、美团等公司19年的面试题。把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节。

由于篇幅有限,这里以图片的形式给大家展示一小部分。

image

详细整理在石墨文档可以见;

Android架构视频+BAT面试专题PDF+学习笔记​

网上学习 Android的资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。希望这份系统化的技术体系对大家有一个方向参考。

上一篇下一篇

猜你喜欢

热点阅读