从0搭建Jetpack版的WanAndroid客户端

1,890 阅读11分钟

1、项目目的:

在接触Android Jetpack组件时, 就深深被其巧妙的设计和强大的功能所吸引,暗自告诉自己一定要学会这些组件,而网上并不能找到系统的学习资料,于是利用每天的时间访问Google Developers,把Jetpack的每个组件从使用到源码进行了系统的学习和总结,于是就有了带你领略Android Jetpack组件的魅力系列文章,希望在总结自己学习的同时,也能帮助需要这些资料的同学,在写完这些文章后,想在项目中使用这些强大组件的想法就更加想强烈了, 但又担心直接在公司项目中使用会又踩坑的危险,而且公司的项目又一时难以全部替换,好在WanAndroid提供了完整的应用接口,才有了这个Jetpack版的WanAndroid客户端,项目功能比较简单,作为Jetpack组件的实战项目,旨在抛砖引玉和大家一起真正的使用Jetpack组件。

2、项目简介:

  • 项目架构

既然本篇是对Android Jetpack组件的实战,那么就按照官方推荐的项目架构进行开发,架构内容见下图:

上面架构大家应是很熟悉的,基本原则和平时使用的MVC、MVP等一样,都是使界面、数据、和处理的逻辑进行解耦,打造稳定的、易测试、易扩展的项目架构,只是在这个过程中使用了全新的组件,如:ViewModel、LiveData等,使整个项目架构更加简单和灵活,关于使用的新组件不了解的可以点击文章开头的链接,学习相关组件的使用,本文默认读者已经了解组件的简单使用。

  • 项目内容:

  • 项目结构

本项目按照前面项目架构的指导,根据各个模块的功能进行分包管理,如下图:

3、项目实战

3.1、登陆模块
登陆模块遵循着一个Activit多Fragment的实现,提供注册(RegisterFragment)和登陆(LoginFragment)功能,相信这样的实现和写法对所有开发者来说都是So easy,甚至心里已将想好了如何像Activity添加Fragment,如何实现两个Fragment间的交互,我想说兄弟先停下脑子中的代码,来看看下面Loginactivity中的实现:

class LoginActivity : BaseCompatActivity() {

    override fun onErrorViewClick(v: View?) {}

    override fun initView(savedInstanceState: Bundle?) {}

    override fun getLayoutId() = R.layout.activity_login

    override fun onSupportNavigateUp() = Navigation.findNavController(this, R.id.fragment_navigation_login).navigateUp()}

onErrorViewClick()、initView()、getLayoutId()是在BaseCompatActivity中的抽象方法,用于加载布局和初始化控件,忽略这些方法后,真正实现像Activity中添加Fragment和Fragment的导航的代码就只有一行。。。,之所以这么简单完全得力于Navigation的使用,我们只需按规定的设置Navigation的xml文件,并将其加载到布局中,其他的操作都在Navigation中自动完成,下面看一下navigation.xml文件:

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/login_navigation"
    app:startDestination="@id/loginFragment">
    <fragment
        android:id="@+id/loginFragment"
        android:name="com.example.administrator.wanandroid.ui.fragment.LoginFragment"
        android:label="LoginFragment" >
        <action
            android:id="@+id/action_loginFragment_to_registerFragment"
            app:destination="@id/registerFragment" />
    </fragment>
    <fragment
        android:id="@+id/registerFragment"
        android:name="com.example.administrator.wanandroid.ui.fragment.RegisterFragment"
        android:label="RegisterFragment" />
</navigation>
  • 效果展示

3.2、文章模块

3.2.1、文章列表展示

对于常规的内容展示,使用RecyclerView并实现上拉加载和下拉刷新即可,此处使用Paging组件实现这些功能,对于Paging的下拉加载之前文章已经介绍了,通过自定以DataSource控制数据的加载和分页,本文不再进行介绍,这里只介绍对Paging组件进行了简单的封装,代码结构如下:

除了DataBase、Factory和Adaoter之外,上述封装中主要的类是三个类:

  1. Listing:用于封装需要监听的对象和执行的操作,用于系统交互
  2. BaseRepository:配置并实例化LivePagedListBuilder()对象,根据设定的监听状态和数据,封装List<M>对象
  3. BasePagingViewModel:保存所有的可观察的数据和所有的操作方法
  • Listing代码如下,属性和作用见代码注释:
/**
 * 用于封装需要监听的对象和执行的操作,用于系统交互
 * pagedList : 观察获取数据列表
 * networkStatus:观察网络状态
 * refreshState : 观察刷新状态
 * refresh : 执行刷新操作
 * retry : 重试操作
 * @author  : Alex
 * @date    : 2018/08/21
 * @version : V 2.0.0
 */
data class Listing<T>(
        val pagedList: LiveData<PagedList<T>>,
        val networkStatus: LiveData<Resource<String>>,
        val refreshState: LiveData<Resource<String>>,
        val refresh: () -> Unit,
        val retry: () -> Unit)
  • BaseRepositroy
abstract class BaseRepository<T, M> : Repository<M> {

    /**
     * 配置PagedList.Config实例化List<M>对象,初始化加载的数量默认为{@link #pageSize} 的两倍
     *  @param pageSize : 每次加载的数量
     */
    override fun getDataList(pageSize: Int): Listing<M> {

        val pageConfig = PagedList.Config.Builder()
                .setPageSize(pageSize)
                .setPrefetchDistance(pageSize)
                .setInitialLoadSizeHint(pageSize * 2)
                .setEnablePlaceholders(false)
                .build()

        val stuDataSourceFactory = createDataBaseFactory()
        val pagedList = LivePagedListBuilder(stuDataSourceFactory, pageConfig)
        val refreshState = Transformations.switchMap(stuDataSourceFactory.sourceLivaData) { it.refreshStatus }
        val networkStatus = Transformations.switchMap(stuDataSourceFactory.sourceLivaData) { it.networkStatus }

        return Listing<M>(
                pagedList.build(),
                networkStatus,
                refreshState,
                refresh = {
                    stuDataSourceFactory.sourceLivaData.value?.invalidate()
                },
                retry = {
                    stuDataSourceFactory.sourceLivaData.value?.retryFailed()
                }
        )
    }

    /**
     * 创建DataSourceFactory
     */
    abstract fun createDataBaseFactory(): BaseDataSourceFactory<T, M>
}

上述代码中做了以下事情:

  1. 创建BaseDataSourceFactory实例
  2. 初始化并配置Paging组件
  3. 转换并监听BaseDataSourceFactory中保存的可观察的DataSource状态的变化
  4. 将所有的监听状态封装到Listing的实例中

对于上拉加载之前的文章有介绍,可对于下拉刷新的实现并没有直接介绍,不过从上面的代码可以看出,此处的refresh()调用DataSource的invalidate()方法,通知数据实失效,此时数据会从新加载。

  • BasePagingViewModel

BasePagingViewModel的作用就是ViewModel的基本作用,不过这里进行了相关状态的转换和监听,没错就是前面生成和封装的Listing实例中的操作,

open class BasePagingViewModel<T>(resposity: Repository<T>) : ViewModel() {
    //开始时建立DataSource和LiveData<Ling<StudentBean>>的连接
    val data = MutableLiveData<Int>()
    // map的数据修改时,会执行studentResposity 重新创建 LiveData<Ling<StudentBean>>
    private val repoResult = Transformations.map(data) {
        resposity.getDataList(it)
    }
    // 从Ling对象中获取要观察的数据,调用switchMap当repoResult 修改时会自动更新 生成的LiveData
    // 监听加载的数据
    val pagedList = Transformations.switchMap(repoResult) {
        it.pagedList
    }!!
    // 网络状况
    val networkStatus = Transformations.switchMap(repoResult) { it.networkStatus }!!
    // 刷新和加载更多的状态
    val refreshState = Transformations.switchMap(repoResult) { it.refreshState }!!

    /**
     * 执行刷新操作
     */
    fun refresh() {
        repoResult.value?.refresh?.invoke()
    }

    /**
     * 设置每次加载次数,初始化 data 和 repoResult
     * @param int 加载个数
     */
    fun setPageSize(int: Int = 10): Boolean {
        if (data.value == int)
            return false
        data.value = int
        return true
    }

    /**
     * 执行点击重试操作
     */
    fun retry() {
        repoResult.value?.retry?.invoke()
    }
}

ViewModel中储存和执行的方法见上面的注释,所有的监听状态都是转换Listing实例,而Listing实例的创建又是转换DataSource,所以用户执行的操作和DataSource就联系起来了,当你使用了Paging组件的时候,你真的会有牵一发而动全身的感觉,简单来说只要DataSource的数据、请求状态、请求结果任意一个发生改变,相应的ViewModel中的数据就会改变,那在Fragment中监听的Observer就会执行相应的方法,响应用户的操作。

  • 使用效果

3.3.2、文章阅读

这个部分的实现比较简单,也是组件的经典结构,详情页主要是根据文章的Url和Title决定,换句话说只要Url和Title改变文章的内容就会改变,所以只要在ViewModel中保存Title和Url的可观察类,在Activity中监听二者并在其改变时执行相应的操作。

  val contentTitle = MutableLiveData<String>()
  val contentUrl = MutableLiveData<String>()
  • Activity中观察数据:
 model.contentTitle.observe(this, Observer {
            supportActionBar?.title = it
        })
        model.contentTitle.value = mTitle

 private fun initWebView() {
        model.contentUrl.observe(this, Observer {
            createWebView(it)
        })
        model.contentUrl.value = mUrl
    }
  • 效果展示

3.3.3、文章收藏和加入阅读计划

这部分和上面文章展示大致相似,只不过比它多了初始化收藏状态、收藏后上传服务器和保存数据库的操作,也就是多了ArticleDetailResposity中的调度操作,执行逻辑大致如下:

  1. 在显示详情时,初始化本篇文章的收藏状态和加入计划状态
  2. 点击收藏或计划后响应操作
  3. 执行逻辑后响应界面修改

实现过程如下:

  • 在ArticleDetailRepository中创建LivaData标记收藏和阅读的状态
class ArticleDetailRepository(val api: Api, val context: Context) {
    val articleIsCollected = MutableLiveData<Boolean>()
    val articleIsReadLater = MutableLiveData<Boolean>()
}
  • 在ViewModel中转换ArticleDetailRepository中的LiveData
    /**
     * 是否收藏
     */
    val collected = Transformations.map(aricleDetailResposity.articleIsCollected) { it }!!

    /**
     * 是否加入阅读计划
     */
    val readPlan = Transformations.map(aricleDetailResposity.articleIsReadLater) { it }!!

  • 在UI界面中观察数据
//如果文章已收藏则显示“取消收藏”,否则显示“文章收藏”
model.collected.observe(this, Observer {
            if (it!!) collectButton.setText(R.string.cancel_collect_article) else collectButton.setText(R.string.collect_article)
        })
//如果文章已加入计划则显示“取消阅读计划”,否则显示“加入阅读计划”
model.readPlan.observe(this, Observer {
            if (it!!) readPlanButton.setText(R.string.delete_read_plan) else readPlanButton.setText(R.string.add_read_plan)
        })

到这里实现了监听文章的操作状态,根据文章收藏和加入计划的状态,改变相应的UI控件,那么剩下的是执行相应的操作,然后去改变ArticleDetailRepository中可观察数据的状态,此处文章的收藏和阅读计划相同,都是根据本地数据的存储或服务端数据进行初始化,操作成功后再修改数据库数据,关于网络的请求本文不做介绍了,只是在请求收藏链接成功后修改ArticleDetailRepository中状态即可,本文主要介绍“加入”和“取消”阅读计划,此部分是保留在本地的数据库中,所以结下来就看看阅读计划的数据库创建。

  • DataBase:本项目后面的几个关于数据库的操作,如:项目学习等,不一一介绍都以此阅读计划为例
@Database(entities = [CollectArticle::class,ReadPlanArticle::class,StudyProject::class,RecentSearch::class],version = 1 ,exportSchema = false)
abstract class AndroidDataBase : RoomDatabase() {

    abstract fun getCollectDao() : CollectedDao  // 用于收藏文章操作
    abstract fun getReadPlanDao() : ReadPlanDao  // 用于阅读计划操作
    abstract fun getStudyProjectDao() : StudyProjectDao // 用于项目学习操作
    abstract fun getRecentSearchDao() : RecentSearchDao // 用于最近搜索操作

   companion object {
       @Volatile
      private var instence : AndroidDataBase? = null
           fun getInstence(context: Context) : AndroidDataBase{
               if (instence == null){
                   synchronized(AndroidDataBase::class){
                       if (instence == null){
                           instence = Room.databaseBuilder(context.applicationContext,AndroidDataBase::class.java,"WanAndroid")
                                   .build()
                       }
                   }
               }
               return instence!!
           }
   }
}
  • Entity
@Entity(tableName = "read_plan")
data class  ReadPlanArticle(var author: String? = null,
                            var chapterName: String? = null,
                            var link: String? = null,
                            var articleId: Int = 0,
                            var title: String? = null
                            ){
    @PrimaryKey(autoGenerate = true)
    var id: Int = 0
}
  • Dao
@Dao
 interface ReadPlanDao {
    @Insert
    fun insert(readPlanArticle: ReadPlanArticle)
    @Delete
    fun remove(readPlanArticle: ReadPlanArticle)
    @Query("SELECT * from read_plan")
    fun getArticleList():DataSource.Factory<Int,ReadPlanArticle>
    @Query("SELECT * from read_plan WHERE articleId = :id")
    fun getArticle(id :Int):ReadPlanArticle
}

数据库的创建和要执行的操作已在上述配置完成,关于Room的使用这里不再介绍,结下来看看ArticleDetailRepository中是如何使用数据库,响应和修改LivaData的数据,我们依次看看初始化、加入计划和取消计划的操作

  • 初始化:主要查询数据库中是否保存此文章,并更新界面UI
fun isRaedPlan(context: Context, id: Int) {
        runOnIoThread {
            val liva = AndroidDataBase.getInstence(context).getReadPlanDao().getArticle(id)
            if (liva != null) {
                articleIsReadLater.postValue(true)
            } else {
                articleIsReadLater.postValue(false)
            }
        }
}

上述代码执行操作:根据文章Id从数据库查询此文章,如果存在将articleIsReadLater设置为true,否则设置为false,那么ViewModel和Activity中的观察者都会执行响应改变。

注意:数据库的所有操作都不能放在主线程中

  • 加入阅读计划:向数据库添加一条记录,并在添加成功后修改articleIsReadLater值
fun addStudyProject(readPlanArticle: StudyProject) {
        runOnIoThread {
            AndroidDataBase.getInstence(context).getStudyProjectDao().insert(readPlanArticle)
            articleIsReadLater.postValue(true)
        }
    }
  • 取消阅读计划:删除数据库记录,并修改articleIsReadLater值
fun removeReadLater(id: Int) {
        runOnIoThread {
            val readPlanArticle = AndroidDataBase.getInstence(context).getReadPlanDao().getArticle(id)
            AndroidDataBase.getInstence(context).getReadPlanDao().remove(readPlanArticle)
            articleIsReadLater.postValue(false)
        }
    }
  • 效果展示

3.3.4、阅读计划的展示

阅读计划的内容是储存在本地数据库中,所以对文章的展示自然是Room的数据库的查询,而查询后数据的展示又是RecyclerView的使用,提到RecyclerVie就会想到Paging组件,没错我们想到的Google已经想到了,他们对Room和Paging进行了额外的支持,即可以实现对数据库的监听,当数据库改变时直接显示在RecyclerView中,首先在Room中设置数据库和查询数据库,此步骤前面已经完成,看一下这个方法:

 @Query("SELECT * from read_plan")
 fun getArticleList():DataSource.Factory<Int,ReadPlanArticle>

这里Room查询直接返回了DataSource.Factory的实例,也就是说Room已经在查询的时候就直接初始化了DataSource,简化了我们的操作,接下来看看ViewModel中如何处理数据:

class PlanArticleModel(application: Application) : AndroidViewModel(application) {

    val dao = AndroidDataBase.getInstence(application).getReadPlanDao()

    val livePagingList : LiveData<PagedList<ReadPlanArticle>> = LivePagedListBuilder(dao.getArticleList(),PagedList.Config.Builder()
            .setPageSize(5)
            .build()).build()
}

上面代码执行如下操作:

  • 继承AndroidViewModel
  • 初始化数据库查询的ReadPlanDao实例
  • 初始化并配置LivePagingList

在Ui中监听ViewModel中的LiveData:

model.livePagingList.observe(this, Observer {
      adapter.submitList(it)
})

此时你在添加和移除数据库操作时,Room返回的DataSource中的数据会发生改变,进而RecyclerView自动实现数据刷新,效果如下:


3.3、其余模块

  • 项目模块:实现代码和文章模块相似,Paging展示项目列表,Room保存数据,只是所有的操作都针对于玩安卓中的学习项目;
  • 导航模块:根据Tag导航响应的文章
  • 公众号:在Fragment中使用Paging展示各个公众号中的文章
  • 搜索模块:SearchView搜索文章,Room保存最近搜索

4、总结

以上是本项目各个模块的实现和分析,实现的项目比较简单,主要是展示以下组件的使用以及组件间的配合使用,本计划加入WorkManger做定时提醒的功能,并加上一些完整的功能,但由于各种原因(具体大家都懂。。。),后面有机会会继续完善,那本文到此结束,希望对大家学习和了解Jetpack组件,以及灵活应用组件有所帮助,让大家一起更好的学安卓、玩安卓!

点击查看源码,欢迎Star!