背景:
在开发股票类APP的报价页面时,我们常会遇到需要动态展示多种类型视图的问题。一个典型的报价页面通常包括盘口信息、K线图,成交明细和买卖盘,资金分析等模块。不同证券类型(如股票、指数)以及市场差异(如A股、港股,美股等)所需要展示的功能模块也不相同。
股票报价页面,要适应不同的需求,展示不同的功能模块,这要求我们的布局得是动态化和组件化的。耦合度低,能很轻松的添加和删除这个View,而且代码上也要可维护和方便复用。
本文将介绍一下怎么使用组件化的设计方式构建一个灵活,维护性强的股票报价页面。
先看效果图:
**股票类型 **
指数类型 底部只有成分股列表
报价页面需求拆解
典型的股票APP报价页面设计,从上到下通常包含以下模块:
- 盘口信息:展示基础行情数据,如当前价格、涨跌幅、成交量,市值,市盈率等指标;
- 分时K线图:展示分时走势或K线图数据,用于分析股票的今日以及历史价格趋势;
- 明细数据:展示实时成交明细数据,包括时间、价格、成交量等;
- 买卖盘:实时更新买卖档数据(如买五、卖五,港股美股的档位数和A股有些不一样);
- 经纪席位(港股特有):展示主要经纪商的交易情况;
- 资金分析:展示今日资金流向。主力资金等数据;
- 成分股列表(指数特有):展示指数的成分股的报价信息及其权重等信息;
我们开发这个页面的过程中,需要根据不同的证券类型(股票、指数)及市场(A股、港股)灵活组合不同的模块来满足需求。例如:
- 股票页面:包含盘口信息、分时K线图、明细、内嵌的交易模块,买卖盘、资金信息等;
- 指数页面:去除买卖盘,交易模块,资金分析模块,底部增加成分股列表;
- 港股页面:需要加入经纪席位模块;
实现方案分析
报价页面是一个可滑动的列表,里面有不同的视图类型,要实现动态显示不同的UI,一般有几种实现方式:
1. NestedScrollView
嵌套不同的模块View
在 XML 布局文件中,我们可以使用NestedScrollView
嵌套各种不同的模块View。通过设置不同的条件,这些被嵌套的 View 能够实现动态显示与隐藏的效果。之所以使用NestedScrollView
作为可滑动的父控件,是为了解决嵌套RecycleView
时的滑动冲突问题。
当然布局优化上也可以使用ViewStub
来动态inflate()
加载这个视图,减轻UI渲染压力。这种实现方式的优缺点:
- 优点:简单直接,更新UI的状态也很方便;
- 缺点: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领域还是难以取代的。
不过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/…