股票APP中如何实现一个灵活可维护性强的股票报价页面?

525 阅读10分钟

背景:

在开发股票类APP的报价页面时,我们常会遇到需要动态展示多种类型视图的问题。一个典型的报价页面通常包括盘口信息、K线图,成交明细和买卖盘,资金分析等模块。不同证券类型(如股票、指数)以及市场差异(如A股、港股,美股等)所需要展示的功能模块也不相同。
股票报价页面,要适应不同的需求,展示不同的功能模块,这要求我们的布局得是动态化和组件化的。耦合度低,能很轻松的添加和删除这个View,而且代码上也要可维护和方便复用。
本文将介绍一下怎么使用组件化的设计方式构建一个灵活,维护性强的股票报价页面。
先看效果图:
**股票类型 ** stock.gif 指数类型 底部只有成分股列表 index.gif

报价页面需求拆解

典型的股票APP报价页面设计,从上到下通常包含以下模块:

  1. 盘口信息:展示基础行情数据,如当前价格、涨跌幅、成交量,市值,市盈率等指标;
  2. 分时K线图:展示分时走势或K线图数据,用于分析股票的今日以及历史价格趋势;
  3. 明细数据:展示实时成交明细数据,包括时间、价格、成交量等;
  4. 买卖盘:实时更新买卖档数据(如买五、卖五,港股美股的档位数和A股有些不一样);
  5. 经纪席位(港股特有):展示主要经纪商的交易情况;
  6. 资金分析:展示今日资金流向。主力资金等数据;
  7. 成分股列表(指数特有):展示指数的成分股的报价信息及其权重等信息;

我们开发这个页面的过程中,需要根据不同的证券类型(股票、指数)及市场(A股、港股)灵活组合不同的模块来满足需求。例如:

  • 股票页面:包含盘口信息、分时K线图、明细、内嵌的交易模块,买卖盘、资金信息等;
  • 指数页面:去除买卖盘,交易模块,资金分析模块,底部增加成分股列表;
  • 港股页面:需要加入经纪席位模块;

实现方案分析

报价页面是一个可滑动的列表,里面有不同的视图类型,要实现动态显示不同的UI,一般有几种实现方式:

1. NestedScrollView嵌套不同的模块View

在 XML 布局文件中,我们可以使用NestedScrollView嵌套各种不同的模块View。通过设置不同的条件,这些被嵌套的 View 能够实现动态显示与隐藏的效果。之所以使用NestedScrollView作为可滑动的父控件,是为了解决嵌套RecycleView时的滑动冲突问题。

当然布局优化上也可以使用ViewStub来动态inflate()加载这个视图,减轻UI渲染压力。这种实现方式的优缺点:

  1. 优点:简单直接,更新UI的状态也很方便;
  2. 缺点:View视图的动态显示和隐藏逻辑,以及业务逻辑都由UI载体Activity中完成,会造成Activity很臃肿,难以维护,而且无法复用。
    另外NestedScrollView中嵌套RecycleView,会造成首次渲染慢的问题。因为父类View必须把所有RecycleView中的item一次全部渲染出来,确定RecycleView的高度,最终才能确定NestedScrollView的高度。滑动的时候,RecycleView中的item无法复用,也会不流畅,尤其对于有成百上千个成分股的指数报价页面而言,这种不流畅的滑动体验会严重影响用户体验。

    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
        ```
    <ViewStub
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout="@layout/stock_bottom" />
    <androidx.core.widget.NestedScrollView/>

2. NestedScrollView嵌套不同的Fragment

报价中的功能模块,不管是K线图,明细还是买卖盘,每一个实现起来其实都是挺多业务逻辑的。想要独立/多人并行开发,可维护性强的话,我们可以使用Fragment来包装一下功能View实现。


    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

            <androidx.fragment.app.FragmentContainerView
                android:id="@+id/fragment_top"
                android:layout_width="match_parent"
                android:layout_height="wrap_content" />

            <androidx.fragment.app.FragmentContainerView
                android:id="@+id/fragment_center"
                android:layout_width="match_parent"
                android:layout_height="wrap_content" />

            <androidx.fragment.app.FragmentContainerView
                android:id="@+id/fragment_bottom"
                android:layout_width="match_parent"
                android:layout_height="wrap_content" />
        </LinearLayout>
    </androidx.core.widget.NestedScrollView>

在xml布局中嵌入FragmentContainerView,然后通过FragmentManager动态的给各个FragmentContainerView添加要显示的功能块Fragment
Fragment切片的设计初衷,也是为了给开发者提供一个轻量级的,带生命周期的视图,方便开发者在UI页面中的不同部分嵌套使用。


    val fragmentTransaction = childFragmentManager.beginTransaction()

    fragmentTransaction.add(
        R.id.fragment_top,
        topFragment!!,
        null
    )

这个方案确实解决了前一种方案在可维护性方面的问题,它通过采用不同的 Fragment 来拼凑布局。然而,Fragment 相较于 View 更为 “重型”,在 UI 渲染时会带来较大压力,不够轻量级。同样也会有NestedScrollView中嵌套RecycleView,首次渲染慢,无法复用问题。

3. 使用RecycleView的ItemViewType

借助 RecyclerView的Adapter中的 viewType 特性,我们能够让 RecyclerView 在不同位置呈现出不同的布局样式。这样整个股票报价页面就是一个RecycleView,足够的轻量级,给 UI 渲染带来的压力也就小多了,能够高效且流畅地完成界面展示。然而,这种实现方式也存在明显弊端。由于要依据不同的 viewType 去处理各种布局逻辑,Adapter 中的业务逻辑会急剧增多,Adapter类变得很臃肿,维护困难。而且股票报价页面的数据来自多个数据源,更新数据上也不好处理。

class CustomAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    companion object {
        private const val VIEW_TYPE_PANKOU = 0 // 盘口信息布局类型
        private const val VIEW_TYPE_KLINE = 1  // K 线布局类型
    }

    override fun getItemViewType(position: Int): Int {
        return when (position) {
            0 -> VIEW_TYPE_PANKOU // 位置 0 显示盘口信息
            1 -> VIEW_TYPE_KLINE  // 位置 1 显示 K 线图
            else -> throw IllegalArgumentException("Unsupported position: $position")
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when (viewType) {
            VIEW_TYPE_PANKOU -> PankouViewHolder(
                LayoutInflater.from(parent.context).inflate(R.layout.item_pankou, parent, false)
            )
            VIEW_TYPE_KLINE -> KLineViewHolder(
                LayoutInflater.from(parent.context).inflate(R.layout.item_kline, parent, false)
            )
            else -> throw IllegalArgumentException("Unsupported view type: $viewType")
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        // 根据 position 对应的布局类型处理数据绑定逻辑
        if (holder is PankouViewHolder) {
            // 绑定盘口信息布局数据
        } else if (holder is KLineViewHolder) {
            // 绑定 K 线图布局数据
        }
    }

    override fun getItemCount(): Int = 2

    // 盘口信息 ViewHolder
    class PankouViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)

    // K 线布局 ViewHolder
    class KLineViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
}



4. 阿里巴巴的VLayout库实现

该库发布于2017年,当初主要是为了实现首页多视图复杂列表的动态展示。通过对RecyclerView中LayoutManager的扩展,支持不同的 LayoutHelper,实现加载不同的布局的能力,可以让我们根据实际需求灵活地加载和管理各种布局。虽然该库已经停止维护,但是日常使用还是挺方便的,也是本示例使用的方式。更多了解请移步查看:github.com/alibaba/vla…

5. RecycleView库中的ConcatAdapter

ConcatAdapter 和阿里的 VLayout 库在功能上有相似之处,二者都可用于组合不同 UI 类型的 Adapter。不过,ConcatAdapter 直到 2021 年才在官方的 RecyclerView 1.2 版本中推出,这着实是 “姗姗来迟”。

阿里的 VLayout 库出现的早,为开发者在处理复杂多样的列表布局时提供了有效的解决方案,这在当时的Android领域还是难以取代的。

图片.png

不过ConcatAdapter的出现,也算是官方给出的解决方案了,为我们提供了一种便捷组合不同布局样式的Adapter方式,而且各个Adapter也不需要额外的修改,直接通过ConcatAdapter串联起来就实现了不同布局样式的RecycleView,使用起来很简单,像这样的方式。

val stockInfoAdapter = StockInfoAdapter(stockInfo)
val kLineAdapter = KLineAdapter(kLineData)
val tradeDetailAdapter = TradeDetailAdapter(tradeDetails)
val orderBookAdapter = OrderBookAdapter(orderBookData)
val fundsAdapter = FundsAdapter(fundsData)
val concatAdapter = ConcatAdapter(
    stockInfoAdapter,
    kLineAdapter,
    tradeDetailAdapter,
    orderBookAdapter,
    fundsAdapter,
)

val recyclerView: RecyclerView = findViewById(R.id.recyclerView)
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = concatAdapter

具体实现

因为ConcatAdapter出来的晚,功能相对简单,不支持多种布局类型和特殊布局,如吸顶效果(该效果在指数报价页面的底部成分股列表会用到)。所以本示例就是使用了Vlayout来实现的,总体上实现还是很简单的。

先给RecyclerView初始化一个DelegateAdapter对象,注意这里要设置合适的缓存池大小,因为有些指数的成分股数量大于1000所以设置的多些,这样用户来回滑动起来就比较流畅。当然现在有些股票APP不会全部把这个列表显示出来,而是进行分页加载,这样UI渲染的压力就小很多。


    fun initRecyclerView(recyclerView: RecyclerView, context: Context?): DelegateAdapter {
        //初始化
        //创建VirtualLayoutManager对象
        val layoutManager = VirtualLayoutManager(context!!)
        recyclerView.setLayoutManager(layoutManager)
        recyclerView.itemAnimator = null
        //设置回收复用池大小,(如果一屏内相同类型的 View 个数比较多,需要设置一个合适的大小,防止来回滚动时重新创建 View)
        val viewPool = RecyclerView.RecycledViewPool()
        viewPool.setMaxRecycledViews(ViewTypePrice, 1)
        viewPool.setMaxRecycledViews(ViewTypeChart, 1)
        viewPool.setMaxRecycledViews(ViewTypeBuSellPan, 1)
        viewPool.setMaxRecycledViews(ViewTypeStickTop, 1)
        viewPool.setMaxRecycledViews(ViewTypeBottomList, 1000)
        viewPool.setMaxRecycledViews(ViewTypeBottomFundFlow, 1)
        recyclerView.setRecycledViewPool(viewPool)
        //设置适配器
        val delegateAdapter = DelegateAdapter(layoutManager)

        recyclerView.setAdapter(delegateAdapter)
        return delegateAdapter
    }

然后就是根据不同的条件把不同功能的Adapter按需求和显示顺序添加到LinkedList链表中,最后把串起来的Adapters设置进DelegateAdapter里,这样就能显示出来了。

       private var mAdapters = LinkedList<DelegateAdapter.Adapter<*>>()
    //指数底部只有成分股股列表
    if (isIndex) {
        itemList = List(1200) { StockItem("腾讯控股 $it") }
        mAdapters.add(vlayoutUtils.initStickTopAdapter(this))
        bottomListAdapter = BottomListAdapter(this)
        mAdapters.add(bottomListAdapter!!)
        updateBottomList()
    } else {
        val tradeAdapter = TradeAdapter(this)
        mAdapters.add(tradeAdapter)
        mAdapters.add(
            vlayoutUtils.initLineLayoutAdapter(
                this,
                R.layout.recycleview_item_buysellpan,
                ViewTypeBuSellPan
            )
        )
        val bottomFundAnalysisAdapter = BottomFundAnalysisAdapter(this)
        mAdapters.add(bottomFundAnalysisAdapter)
    }
    //设置适配器
    delegateAdapter.setAdapters(mAdapters)

注意点

1. 单个Adapter类刷新数据,要使用notifyItemChanged()或者notifyItemRangeChanged()方法;

如果某个Adapter类刷新数据调用notifyDataSetChanged()方法的话,其他的Adapter也会调用onBindViewHolder()方法,造成整个RecycleView的Adapter都跟着重新刷新了,这样就可能会造成状态丢失或者无效刷新的问题;


        fun update(itemList: List<StockItem>?) {
            if (itemList != null) {
                this.itemList = itemList
                mCount = itemList.size
    //            notifyDataSetChanged()//会更新其他的Adapter
                notifyItemRangeChanged(0,mCount)
            }
            Log.i(TAG, "update: ...")
        }

这是因为VLayout内部注册了RecyclerView.AdapterDataObserver的监听,一个Adapter类调用notifyDataSetChanged()会触发整个RecycleView调用notifyDataSetChanged()方法。


    AdapterDataObserver observer = new AdapterDataObserver(mTotal, mIndexGen == null ? mIndex++ : mIndexGen.incrementAndGet());
    adapter.registerAdapterDataObserver(observer);

    @Override
    public void onChanged() {
        if (!updateLayoutHelper()) {
            return;
        }
        notifyDataSetChanged();
    }

2. Vlayout中的Adapter里避免嵌套列表数量比较多的RecycleView
之前笔者为了图方便,设置成分股的Adapter的ItemCoun数量为1,然后Item布局中加了一个RecycleView来加载成分股列表。结果是有上千个成分股数量的指数报价页面,打开这个Activity页面时渲染时间都超过了500ms,造成启动Activity速度慢,体验很糟糕。
这是因为VLayout的测量逻辑,对于嵌套的 RecyclerView,当其高度设置为wrap_content时,需要完整测量这个嵌套RecyclerView的所有 item 的高度,确定嵌套RecycleView 的高度后才能确定最外层RecycleView自身的高度,这导致测量耗时显著增加。

解决方案: 去掉 RecyclerView的嵌套,直接使用 LinearLayoutHelper的item layout,扁平化处理,有多少个Item数量就设置给ItemCount。这样做旧不用再额外测量嵌套 RecyclerView 的总高度,只是测量当前屏幕的Item高度,渲染快,而且滑动列表也能复用。

注意:如果RecyclerView中确实需要嵌套横向的RecyclerView来实现卡片布局的话,由于其高度固定,以及卡片数量不会太多,这种情况对UI的渲染压力还是没有太多影响的。

总结

使用VLayout来实现股票报价页面,既轻量又能很好的维护,用组块化的方式,灵活拼装不同的Adapter。在这种实现方式下,各个不同的功能模块逻辑能够在各自对应的Adapter中独立实现。如此一来,每个功能模块的代码结构清晰,便于开发人员进行维护和修改。
而且基于Adapter也能很好的复用,以买卖盘功能为例,其对应的 Adapter可以在买卖盘详情页面直接复用。避免了重复开发,还能保证不同页面中相同功能模块的一致性和稳定性,可谓一举多得,美滋滋。

最后附上示例源码,给大家参考一下:github.com/finddreams/…