现代App的主流结构

现在的主流App,例如微信QQ、抖音快手、头条新浪微博,又或者是滴滴美团,这些App其实从页面结构形态上只需要分两类,一类是微信QQ、抖音快手和头条微博这些,是典型的Feed流+详情页,Feed流作为详情页的入口,在主页面展示,而详情页中大块是个图文或者视频,下面是转评赞区,评论区一般来说又是个简单的Feed流。详情页中也可能还会有一些其他详情页的入口,比如有一行"猜你喜欢"的横向Feed。这类App可以被统称为信息流Feed型,而另一类就像滴滴美团这种的,主要结构是功能型,美团可能也有一点偏Feed,美团的整个首页也是一个基于位置信息的推荐Feed流。但美团的大部分主流页面是以查看商店说明、商品详情以及下单结账相关的,这类页面是重业务的,可能一个业务需要多个页面协同合作,数据需要在多个页面之间传递与校验,页面意外销毁了需要有恢复机制。而滴滴是个以地图为核心,出行与路线规则的App,也是主打功能的,我们暂且可以将这类App称为功能型App。

信息Feed流的App主打信息与信息之间的关系处理,页面之间的路由关系取决于信息与信息之间的关系,而功能型App主打业务逻辑的贯通,业务逻辑的走向决定了页面路由的结构。而从软件工程的更高角度来看这两类App,其实它们可以归纳入信息分发类App,另一类截然不同的软件可以说是ERP软件,ERP软件主要是管理人与事务之间的关系,用户被抽象为软件系统中的一个角色对象Role,事务被抽象为一个个的Action,Action具有状态和属性并可持久化,整个软件系统的工作流程可被理解为Role在做Action从而导致Action的属性变化和状态的迁移。Action的属性和状态变化后的持久化结果,被统计起来制成清单,作为ERP的展示页。可以说ERP是纯业务型的软件,对用户行为的处理与记录是最根本的功能,而信息分发流的软件,更偏向于如何理解用户的需求,将用户想要的信息以何种形式展现给用户。而由于是在移动端,手机被设计成一种长条型的设备,因此App的信息流一般也被设计成一个纵向的列表状。

一个典型Feed流的工作模式

Feed流的结构一般分为三大区块,分别是header区、content区以及footer区,作用分别是下拉加载过程的展示与提示用、信息内容展示、上滑加载更多内容过程中的展示。footer区一般是最简单的,只需要插入到feed的尾部,当滑动到底部并且加载更多数据时展现出来,header区又可分为loading和notify两个功能,loading与footer对应,在下拉刷新时展现出来,意为让用户等待加载结果。而notify则主要是提示作用,比如下拉加载了多少条数据,dislike与remove的操作结果提示等等。中间的content区则是主要内容展示区,需要根据服务端返回的每条数据的type,查找相应的View布局,将每条数据渲染成一个个的UI控件。

Feed的工作模式分为以下几个case


  • 初始化加载
  • 下拉刷新并提示,显示loading
  • 滑动
  • 滑到底部加载更多数据,显示footer
  • content的item操作时,触发列表刷新


 这里还需要提到一种叫无限Feed流的工作模式,即当用户滑到距离底部n个item的时候,就需要触发loadmore,当用户继续滑动时,就不会出来Feed流中断的现象,而是感觉整个Feed流无限长,这样能够提高用户体验度。

基于RecyclerView实现Feed流的过程

Feed流由于其item布局样式多样,Feed列表数据大,RecyclerView天然就对Feed实现友好。RecyclerView在FeedList的基础上增加了两层缓存,可让开发者更灵活地处理布局重用的问题,并且将View的创建过程和数据绑定过程分离,能够在更细粒度上优化Feed的性能,在布局重用的基础上,让整个Feed在布局多样的环境下还能保持好性能。

Feed流的加载过程一般分为初始化,也即加载数据、初始化View,数据的初始渲染。更细一点分为网络请求/本地查询,将结果进行解析,数据刷新到列表中,设置列表状态。而非初始化加载的过程则在数据解析后到数据刷新到列表前还有一个去重/去dislike的过程。下拉刷新有两种策略,是否remove all已有数据,将新请求的数据加载到列表的头部,而上拉loadmore则是将数据加载到列表的尾部,一般说来都可以直接使用RecyclerView的局部刷新api来实现。




Android抖音App架构 抖音app信息架构_Android抖音App架构


Paging的功能

首先上一张paging的架构图


Android抖音App架构 抖音app信息架构_vue_02


Jetpack推荐使用MVVM通过paging来实现无限加载的列表,Paging作为View层,在ViewModel层中保存PagedList数据,Repository创建PagedList数据并更新到ViewModel中,这里的Repository就相当于Model层,View监听到PagedList的变化后通知PagedListAdapter去刷新列表,而PagedList在创建时传入了DataSource,PagedListAdapter在加载更多数据时调用DataSource的loadAfter方法加载数据并用onResult方法将数据存入PagedList中并刷新列表。

这里还涉及到分页的概念,这跟前端的分页展示是一个形式,只不过前端是用下一页数据替换当前页数据,而paging的下一页则是以loadmore的形式接在当前页的底部。更确切地说分页是paging的加载方式,每次都只加载一页,当用户滑动位置底部还剩预定义的n个item时,就触发加载下一页的动作,这样来实现一个无限列表。

Paging以提供一个PagedListAdapter来暴露功能,PagedList是数据的储存对象,也是页加载的配置对象,PagedListAdapter不推荐再使用Recycler.Adapter的局部刷新api,而是使用submitList方法,这个方法使用了diff操作,即会对每个position位置上的数据,把刷新前后的数据进行对比,这个diff是个接口,使用者可以自己控制diff的粒度。Paging的diff操作在设置的IO线程,而刷新过程则是main线程。

Paging来实现一个无限列表

无限列表具有下拉刷新和loadmore功能,而loadmore又是使用paging自带的自动loadmore,因此其实一般情况不需要实现一个footer,因此loadmore时还没有到达底部,footer永远显示不出来,这里的demo就按最简化的原则。当然也可以添加一个footer,每次插入数据前将footer给移除掉,添加完后再add进来。下拉刷新使用SwipeRefreshLayout来实现setRefreshing(boolean)可以控制是否显示loading动画。

按照"Paging的功能"中的架构图,所有的逻辑与UI展示的中枢是ViewModel,那就先实现一个无限列表的ViewModel。ViewModel中需要持有两个LiveData,分别是刷新状态和PagedList。数据请求前和完成时需要更新刷新状态,以便更新UI,PagedList在初始化和下拉动作中创建出来并更新到PagedList的LiveData中,监听observer中发现有更改就将这个PagedList给submit到Adapter中去,以刷新列表,这样来达到初始化和下拉刷新过程的刷新列表操作。


class ListViewModel: ViewModel() {

    companion object {
        const val STATE_INIT = 0
        const val STATE_REFRESHING = 1
        const val STATE_REFRESHED = 2
        const val STATE_LOADING = 3
        const val STATE_LOADED = 4
    }

    val refreshState = MutableLiveData<Int>().apply { postValue(STATE_INIT) }

    val repository = ListDataRepository(refreshState)

    val pagedList =  MutableLiveData<PagedList<Item>>().apply { postValue(repository.getPagedList()) }

    fun refresh() {
        pagedList.postValue(repository.getPagedList())
    }

}



这里定义了5种刷新状态,refreshState表示刷新状态的LiveData,pagedList表示PagedList的LiveData,repository是数据中心,负责生产出PagedList,refresh方法是在下拉动作监听中调用以更新pagedList的值。

接着就是定义UI了,首先是Activity的布局。


<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/swipeRefreshLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView xmlns:app="http://schemas.android.com/apk/res-auto"
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scrollbars="vertical"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />

</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>


SwipeRefreshLayout+RecyclerView组成,RecyclerView使用纵向布局。再然后是每个item的布局,依然是简单的title+summary。


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal" android:layout_width="match_parent"
    android:layout_height="70dp"
    android:gravity="center"
    >

    <TextView
        android:id="@+id/title"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:textSize="20sp"
        />
    <TextView
        android:id="@+id/summary"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="2"
        android:textSize="15sp"
        />
</LinearLayout>


接着是Activity中的UI初始化以及LiveData的监听


class PagingDemoActivity: AppCompatActivity() {

    companion object {
        class ListViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
            val title = itemView.findViewById<TextView>(R.id.title)
            val summary = itemView.findViewById<TextView>(R.id.summary)
        }

        val diff = object: DiffUtil.ItemCallback<Item>() {
            override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
                return oldItem == newItem
            }

            override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
                return oldItem.id == newItem.id
            }
        }
        class ListAdapter: PagedListAdapter<Item, ListViewHolder>(diff) {
            override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder {
                val viewHolder = ListViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_demo_paging, parent, false))
                return viewHolder
            }

            override fun onBindViewHolder(holder: ListViewHolder, position: Int) {
                holder.title.text = currentList?.get(position)?.title
                holder.summary.text = currentList?.get(position)?.summary
                getItem(position)
            }
        }
    }

    lateinit var viewModel: ListViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_demo_paging)
        viewModel = ViewModelProvider(this, object : ViewModelProvider.Factory {
            override fun <T : ViewModel?> create(modelClass: Class<T>): T {
                return ListViewModel() as T
            }
        })[ListViewModel::class.java]

        val adapter = ListAdapter()
        viewModel.pagedList.observe(this, Observer {
            adapter.submitList(it)
        })
        viewModel.refreshState.observe(this, Observer {
            when(it) {
                ListViewModel.STATE_REFRESHING -> swipeRefreshLayout.isRefreshing = true
                ListViewModel.STATE_REFRESHED -> swipeRefreshLayout.isRefreshing = false
                ListViewModel.STATE_LOADING -> Toast.makeText(this, "loading", Toast.LENGTH_SHORT).show()
                ListViewModel.STATE_LOADED -> Toast.makeText(this, "load success", Toast.LENGTH_SHORT).show()
            }
        })

        recyclerView.adapter = adapter
        swipeRefreshLayout.setOnRefreshListener {
            viewModel.refresh()
        }
    }

}


如上所述,当ViewModel的pagedList监听到修改时,就将这个新的PagedList给submit到Adapter中去,这样可以触发DataSource的loadInitial

方法,在loadInitial方法中进行初始数据的构建。ViewMode的refreshState的监听则是为了更新refresh的loading动画或者是在loadmore的时候给予一些提示信息。SwipeRefreshLayout的下拉监听setOnRefreshListener中直接调用ViewModel的refresh方法生产出一个PagedList并更新到pagedList的LiveData中。**这里特别需要注意一点的是,基于Paging实现的Adapter,需要在onBindView中调用一下getItem,因为自动loadmore是在getItem中触发的,在getItem中会比较当前最下面的一个item下面还会有几个item,如果数量小于调用的prefetchDistance,则会触发DataSource的loadAfter加载更多数据**。

Repository的实现比较简单


class ListDataRepository(val refreshState: MutableLiveData<Int>) {

    companion object {

        val mainHandler = Handler(Looper.getMainLooper())
    }

    val dataSourceFactory = ListDataSourceFactory(refreshState)

    val config = PagedList.Config.Builder()
        .setEnablePlaceholders(true)
        .setPrefetchDistance(3)
        .setInitialLoadSizeHint(30)
        .setPageSize(10)
        .build()

    fun getPagedList(): PagedList<Item> {
        return PagedList.Builder<Int, Item>(dataSourceFactory.create(), config)
            .setFetchExecutor {
                GlobalScope.launch { it.run() }
            }.setNotifyExecutor {
                mainHandler.post(it)
            }.build()
    }
}


准确来说只有一个getPagedList方法,也就是如果生产出一个PagedList对象,使用PagedList.Builder方式,传入一个PagedList.Config对象和DataSourceFactory对象,PagedList.Config是Paging的配置对象,prefetchDistance表示距离底部还剩多少item的时候就开始触发loadmore,也即调用DataSource的loadAfter方法。initialLoadSizeHint表示初始item多个少,这将会作为参数传给DataSource的loadInitial方法。pageSize就表示每页多少个数据,即每次调用loadAfter时,传入的参数中,size变量是多少。PagedList.Builder还可以设置fetchExecutor和NotifyExecutor,这表示loadxxx方法在fetchExecutor中执行,而submit之后的操作在NotifyExecutor中执行。


class ListDataSourceFactory(val refreshState: MutableLiveData<Int>): DataSource.Factory<Int, Item>() {

    override fun create(): DataSource<Int, Item> {
        return ListDataSource(refreshState)
    }

}


DataSourceFactory就更简单了,只是为了创建一个DataSource对象。最后是DataSource


/**
 * 数据源
 */
class ListDataSource(val refreshState: MutableLiveData<Int>): ItemKeyedDataSource<Int, Item>() {

    companion object {
        var idCreator = 0
        fun getId(): Int {
            return idCreator++
        }

        fun createList(params: LoadInitialParams<Int>): ArrayList<Item> {
            val size = params.requestedLoadSize
            val list = ArrayList<Item>()
            for (i in 0..size) {
                val id = getId()
                list.add(Item(id, "title-$id", "summary-$id"))
            }
            return list
        }
    }

    fun createList(params: LoadParams<Int>): ArrayList<Item> {
        val size = params.requestedLoadSize
        val list = ArrayList<Item>()
        for (i in 0..size) {
            val id = getId()
            list.add(Item(id, "title-$id", "summary-$id"))
        }
        return list
    }

    override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<Item>) {
        refreshState.postValue(ListViewModel.STATE_REFRESHING)
        GlobalScope.launch {
            delay(1000L)
            callback.onResult(createList(params))
            refreshState.postValue(ListViewModel.STATE_REFRESHED)
        }
    }

    override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Item>) {
        refreshState.postValue(ListViewModel.STATE_LOADING)
        GlobalScope.launch {
            delay(500L)
            callback.onResult(createList(params))
            refreshState.postValue(ListViewModel.STATE_LOADED)
        }
    }

    override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Item>) {
    }

    override fun getKey(item: Item): Int {
        return item.id
    }

}


这里为了demo的简单就搞了个id生成器,而title和summary也是根据id生成的。最主要的就是loadInitial的loadAfter两个方法,为了模拟请求解析耗时,这里使用了协和分别delay了1000ms和500ms。在请求数据前分别将refreshState更改为STATE_REFRESHING和STATE_LOADING,这将会通知Activity中触发SwipeRefreshLayout的下拉动画和在loadmore时弹个toast提示,而请求结束后,用callback将数据传回给Adapter以刷新列表,同时更改refreshState的值为STATE_REFRESHED和STATE_LOADED,通知Activity停止下拉动画和loadmore再弹个loadmore成功的toast提示。DataSource还实现了getKey方法,这是因为这里使用的是ItemKeyDataSource,ItemKeyDataSource的意思是如果是上滑loadmore,则loadAfter加载的是最后一个item的key的下一个,而如果是下拉触发loadBefore,则加载的是第一个item的key的上一个,因此需要实现getKey来确保每个Item都有一个key。

最后的实现效果就是下拉到顶部时,会出来一个loading动画,过1000ms后,列表刷新,同时loading动画消失。如果上滑到底部,则会在还未到底部时就触发loadmore,并弹一个"loading"的toast,500ms后发现列表变长了,又弹了一个"load success"的toast,这样这个列表在上滑的过程中就会无限请求,让人感觉就是这个feed无限长,也就是实现了一个无限列表。

Paging在请求失败时的处理

在下拉刷新的请求失败时,由于下拉刷新是用户手动触发的,因此可以将这次的callback直接丢弃掉,当用户再次下拉刷新时,仍然会再次触发loadInitial方法,重新生成一个callback对象,再次请求时使用这个新的callback即可。

但在loadmore时,我们做的是类似自动触发loadmore的,当距离底部还有n个item时触发loadmore,这时如果用户刚好滑到了底部,则下面没有更多item了,滑动过程中无法再次触发自动loadmore,也就无法再调用loadAfter传入一个新的callback。这时我们就只能在请求错误时将这次的callback保存下来,同时监听RecyclerView的滑动,在滑动监听中去请求数据,请求成功后使用保存下来的这个callback执行其onResult方法,将数据传回给Adapter以刷新列表,加载更多数据,这样用户就又得到了一个无限列表了。

具体代码就不贴了,这里保存下来会导致一个内存泄漏,因为callback是持有了DataSource和PagedList对象的,如果保存了callback对象,而又有一个下拉刷新导致PagedList更新了,上一个PagedList则无法释放,而如果用WeakRerence的话,Callback会马上释放,因此用WeakReference是不能实现的,因此理想的实现应该是在更新PagedList时,将上次保存的callback给置null,因此这时又初始化了一个新列表了,也不会导致用户的Feed断流了。