前言:
本文主要是针对在最近的一次版本迭代中,使用RecycleView实现一个版本首页功能时,整体的设计思路分享,还有遇到的一系列问题的梳理和总结。对作者本人来说是一次记录的过程,以后遇到类似的问题时方便进行回顾和复盘,如果同时能对其他人有所启发和帮助,那就再好不过了。
主要涉及的问题点:
- 后台接口数据不确定时的数据实体映射方案
- RecycleView中组件状态数据保存和恢复
- RecycleView中组件渲染刷新时机和脏数据的清理
- RecycleView中组件定时器创建和销毁的生命周期管理:
- RecycleView中组件定时器相关性能优化
- 首页列表卡顿问题的性能优化
- 内存泄漏问题的排查和解决。
1、首页设计方案:
首先我们先来看下设计出来的效果以及产品要求(截图出来部分只是整个首页的一部分,实际需要显示模块是比较多的,目前截图已经可以说明情况,就不再展示其他模块的截图)。
如图所示只是整个首页模块的一部分,整体首页有10到20个模块,由后台进行配置显示哪些模块,而客户端根据后台返回的数据type映射数据进行模块展示。
现在我们先来明确下产品的要求:
1)最多达20个功能模块,模块比较多
2) 由后台随意配置显示指定模块
3)模块位置可随意变化,由后台控制模块展示的顺序和位置
根据上述产品功能需求,我们基本就能确定实现方案。由后台返回一个数据的数组,每个数组都包含不同模块的数据实体,
这些数据实体有一些共同属性,以及type。客户端根据type来判断对应的是哪一个模块并进行显示。
后台数据定义:
根据和后台确定的接口定义可以看出,后台返回不同模块数据的数组,每个模块的数据有共同的属性即父类BaseModule,但是每个模块的数据类型并不相同,那么问题就来了,我们该如何定义接口返回的字段呢。
这是一个问题。
众所周知,我们使用网络请求获取数据,在使用Gson FastJson等实体映射工具时,需要事先定义好这个网络请求需要返回的数据类型。但是本次的接口返回的数据是一个内部数据类型无法确定的数组,我们并不知道具体的数据类型,那么要如何定义本次接口返回的数据实体呢?
2、数据实体映射实现
请先查看下图:
考虑到数组内数据无法确定,那么我们只能先定义一个已知的数据类型,因为我们使用的实体映射是Gson,所以首页接口返回的实体定义为List<LinkedTreeMap>。 由于type和数据实体存在一个映射关系,所以先在Map中保存这种映射关系。比如registerMap.put<type , BaseHallModule.class>。比如type=1,就映射的是 EntityClassA实体。
在通过网络请求得到List<LinkedTreeMap>的数据之后,我们需要将其映射为已知的各个模块的实体类。
此时就需要对List<LinkedTreeMap> 进行遍历操作。在遍历时,通过linkedTreeMap.get("type"),就可以拿到该条数据的type。而由于之前我们已经对type和实体的数据类型进行了注册,那么此时再通过registerMap.get("type") 就可以拿到该条实体的Class。 然后通过JSON.parseObject(JSON.toJSONString(linkTreeMap.get(DATA_PARAM)), clazz),其中clazz= registerMap.get("type") 就可以直接映射得到该模块数据的实体对象。
此时我们就将后台返回的数据实体,由List<LinkedTreeMap> 转化为了List<BaseHallModule>(其中各个模块数据都是BaseHallModule的子类),就可以将其交给RecycleView去填充数据了。
3、RecycleView中组件状态数据保存和恢复。
我们先来看这块的功能,这里是一个选择模块,后台可以指定选中哪几个选项,比如此时后台默认要求选中选项1 和选项3 。如果用户选中了 选项2 和选项3, 如下图:
用户将该组件滚出屏幕,再滚回来时,如果我们未做任何处理,那么此时该组件又会默认恢复到选中的状态。
当然这是因为该组件在列表中由不可见到可见时,其Adpater的onBinderViewHolder方法会重新执行,因为我们并未对用户对选项的选中进行数据的保存,就会导致onBinderViewHolder执行时,setData又会将最初后台默认选中的index设置给组件,组件又默认选中选项1 和选项3 ,导致显示混乱。
此时有两种方式解决这个问题:
3.1 : 对选项的选择状态进行状态数据的保存,我们可以将用户对组件操作的状态数据 ,比如这里选项选中的索引值保存在ViewHolder的原始数据中,当用户改变索引值时,将原始数据中的索引值进行更新,那么在下次该组件由不可见到可见时,就可以恢复到用户操作的正确的索引值上来。
3.2 : 在该组件在列表中,由不可见到可见时,判断该组件上一次操作的数据lastData 和 本次OnBindViewHolder时,传进来的Data是否一致,如果不一致则进行OnBindViewHolder的更新操作,即SelectView.setData()刷新界面,如果一致,则直接返回,不再刷新界面。
4、RecycleView中组件的渲染刷新时机:
具体说明见 3.2。
先来看下HallBaseViewHolder的代码(首页所有ViewHolder的基类)
public abstract class HallBaseViewHolder<T extends HallBaseListItem>
extends CustomRecyclerView.BaseViewHolder {
protected Context mContext;
private T mLastData;
public HallBaseViewHolder(View itemView) {
super(itemView);
ButterKnife.bind(this, itemView);
mContext = itemView.getContext();
}
void preUpdate(T t, int position) {
if (!intercept() || mLastData == null || !mLastData.equals(t)) {
updateData(t, position);
}
mLastData = t;
}
/**
* 更新数据
*
* @param t t
*/
public abstract void updateData(T t, int position);
protected boolean intercept() {
return true;
}
}
HallBaseAdapter : RecycleView的Adapter:
public class HallBaseAdapter extends CustomRecyclerView.BaseAdapter<HallBaseListItem, HallBaseViewHolder> {
private Context mContext;
private CustomSwipeRefreshLayout mCustomSwipeRefreshLayout;
private CustomRecyclerView mCustomRecyclerView;
private ITimerContext mHallTimerContext;
public HallBaseAdapter(Context mContext, CustomRecyclerView customRecyclerView,
CustomSwipeRefreshLayout customSwipeRefreshLayout) {
this.mContext = mContext;
this.mCustomRecyclerView = customRecyclerView;
this.mCustomSwipeRefreshLayout = customSwipeRefreshLayout;
}
public void setmHallTimerContext(ITimerContext mHallTimerContext) {
this.mHallTimerContext = mHallTimerContext;
}
@Override
public void onSectionClick(View view, boolean isSelected, int position) {
}
@Override
public HallBaseViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
HallBaseViewHolder hallBaseViewHolder = null;
switch (viewType) {
case ModuleType.HALL_BANNER:
hallBaseViewHolder = BannerViewHolder.of(mContext, parent);
((BannerViewHolder) hallBaseViewHolder).setmHallRefreshWrapper
(mCustomSwipeRefreshLayout);
break;
case ModuleType.SERVICE_NOTICE:
hallBaseViewHolder = ServerNoticeViewHolder.of(mContext, parent);
break;
case ModuleType.ANNOUNCEMENT:
hallBaseViewHolder = AnnouncementViewHolder.of(mContext, parent);
break;
case ModuleType.DIGITAL_QUICK_BUY:
hallBaseViewHolder = DigitalQuickBuyViewHolder.of(mContext, parent);
break;
case ModuleType.ACTIVITY_ZONE:
hallBaseViewHolder = ActivityZoneViewHolder.of(mContext, parent);
break;
case ModuleType.IMAGE_ZONE:
hallBaseViewHolder = AdViewHolder.of(mContext, parent);
break;
case ModuleType.SPF_QUICK_BUY_SINGLE:
hallBaseViewHolder = SpfQuickBuyViewHolder.of(mContext, parent);
break;
......
......
......
}
return hallBaseViewHolder;
}
@Override
public void onBindViewHolder(HallBaseViewHolder holder, int position) {
super.onBindViewHolder(holder, position);
HallBaseListItem hallBaseListItem = getShowItemData(position);
if (hallBaseListItem.ismNeedRefreshData()) {
holder.updateData(hallBaseListItem, position);
hallBaseListItem.setmNeedRefreshData(false);
} else {
holder.preUpdate(hallBaseListItem, position);
}
}
}
这里我们先只关注OnBindViewHolder中的方法,
我们注意到在该方法中,会首先判断该条数据是否需要强制刷新即(hallBaseListItem.ismNeedRefreshData() ,这个判断和定时器的单刷时机有关,后面会详细说明),如果不强制刷新,那么会执行BaseHallViewHolder的preUpdate()方法。
void preUpdate(T t, int position) {
if (!intercept() || mLastData == null || !mLastData.equals(t)) {
updateData(t, position);
}
mLastData = t;
}
首先每个ViewHolder可以选择是否拦截onBindViewHolder的刷新(即intercept()方法,默认为true即拦截不刷新UI)。只有在不拦截,而且本次的onBindViewHolder的数据和上次保存的数据不一样(即数据变化了,比如下拉刷新),才会进行RecycleView里的组件View的刷新操作。
注意:
如果某个组件在列表中只存在一个,那么通过这种方式(即使不做状态数据的保存和恢复操作),是可以解决由不可见到可见时的用户操作状态的正确性的,因为此时数据未变化,updateData()方法不执行,列表不会进行刷新操作。但是如果列表中存在多个组件,只通过preUpdate 数据相同性判断,无法解决数据错乱的问题。
如下图:
在滚动列表时,部分组件由不可见状态到可见状态,此时由于OnBindViewHolder传进来的数据未变化,那么
updateData()并不会执行。但是因为RecycleView的View在进行复用时,并不确定复用的是哪一个View,如果一页显示5个View时,第6个View复用第1个View,其updateData()方法不执行(原因见前面一句),那么第6个View显示的选中状态就完全是第1个View的状态,此时就会出现显示混乱的问题。
组件复用结论:
1) RecycleView中的组件状态数据保存和恢复是一定要做的流程,否则在多个View显示时,就会出现状态数据显示错乱的问题。
2)ViewHolder中preUpdate的判断也是有必要的,当data.equals(lastData)时,不必刷新页面(即使在多个View复用时也是如此,如果data一样,说明其复用的是自己本身)。这样可以减少列表滚动时的UI渲染刷新的消耗,提升操作流畅性和性能。
5、RecycleView中组件复用时脏数据的清理:
在Recycleview中的自定义组件,某些可见的View如果进行了一些计算产生的数据,保存在View中时(自定义组件中的全局变量),在View进行复用时,如果不在初始化数据时对这些数据进行清理,在下一个Item可见并复用上面的View时,这些数据会对这个item产生影响,并有极大可能造成数据错乱的问题。所以在设计自定义组件时,如果这个组件会在Recycleview中复用的话,需要考虑在setData时,清理或者重置这些数据。
6、RecycleView中组件定时器生命周期的管理:
在ListView或者RecycleView中使用定时器是是一件麻烦事。
因为这涉及到几个问题:
1)定时器由谁创建,由谁管理的问题
2)定时器在列表中何时创建,何时销毁的问题
3)在整个页面数据变化时(比如下拉刷新)定时器的管理和数据刷新的问题
4)单个定时器倒计时结束时,如何进行局部刷新的问题(整体页面刷新相对浪费资源和性能)。
针对上述问题,我们逐个进行分析。
1)定时器由创建,由谁管理的问题
因为在列表中,只有Item可见的时候,才会执行adapter的onBindViewHolder的方法,进行View的data填充以更新渲染UI。而当第一次创建且不可见时,是不需要执行定时器的。那么在adapter onBinderViewHodler时,View.setData()中创建定时器是再好不过了。由于在列表中,View的生命周期并不是确定的(一次页面刷新之后,也许这个View就不存在了,而且不能通过onDetachFromWindow时移除定时器,因为View在列表中由可见到不可见时,也会执行这个方法。),所以组件中定时器的管理放在Adpater中进行,又列表进行管理。
当然定时器在进行倒计时时,需要实时更新定时器的数据到数据源(否则再次onBindViewHolder时定时器就不准了,跟上述状态数据保存恢复原理一样)。
可以将各个组件产生的定时器,统一放在一个全局的List中进行管理,比如List<Timer> mTimerList;
2)定时器何时创建,何时销毁的问题
3)页面数据变化时(比如下拉刷新)定时器的管理和数据刷新的问题
定时器创建时机: 在adapter onBinderViewHolder时,View.setData时创建
定时器销毁时机:
a) 组件中 : 因为View在RecycleView中会进行复用。那么在adapter onBinderViewHolder时,View.setData时,如果数据源里指示该组件需要定时器,如果该View中已经存在定时器,那么可以直接用这个定时器去执行数据源里的倒计时;如果该View中不存在定时器,那就需要创建一个定时器。如果该View中已经存在了一个定时器,但是onBinderViewHolder时,数据源中指示不再需要定时器,此时需要将这个定时器移除。
b)组件中: 当组件中的倒计时结束时,也要在View中移除这个定时器。
c) 在整个页面列表数据发生变化时(比如下拉刷新),需要移除并销毁整个mTimerList中的定时器。因为在页面数据变化后,开发者不知道哪些View还在,哪些View已经不存在了。那么此时比较便捷且稳妥的做法就是,在页面数据返回后,移除所有的定时器。放心,此时后台会告诉我们需要的准确的倒计时的数据,后面只需要按照上述组件创建定时器的步骤执行就可以继续显示不同组件需要显示的定时器了。
d)在整个页面销毁时,需要销毁整个页面里的所有定时器。可以通过上述说的操作全局mTimerList来进行处理。
4) 定时器的局部刷新问题:
关于定时器的局部刷新: 首先服务器要有单刷某个模块的单刷接口(如果后台不提供单独模块的单刷接口,请直接刷新整个页面,并把锅推给后台谢谢)
来来来,不说了,show the code。
@Override
public void onNext(StatusResponse<ResultDetailEntity> data) {
if (data.getCode() == 0) {
// 成功
ResultDetailEntity entity = data.getResult();
long countDownTime = entity.getSaleEndCountdownTime();
if (mHallBaseListItemList != null && mHallBaseListItemList.size() > 0) {
for (HallBaseListItem hallBaseListItem : mHallBaseListItemList) {
if (hallBaseListItem.getViewType() == ModuleType.KS_HZ_QUICK_BUY) {
Object object = hallBaseListItem.getData();
if (object != null && object instanceof K3hzRecommend) {
K3hzRecommend k3hzRecommend = (K3hzRecommend) object;
k3hzRecommend.setSaleEndCountdownTime(countDownTime);
hallBaseListItem.setmNeedRefreshData(true);
mHallBaseAdapter.notifyDataSetChanged();
}
}
}
}
}
}
其实就是在组件中的倒计时结束后,请求这个模块的单刷接口,去单独获取这个模块的数据,然后从
mHallBaseListItemList中找出原来的数据替换掉,或者设置成单刷接口返回的数据,notify adapter即可,这样就不会像全刷一样,浪费性能和资源。
注意这里hallBaseListItem.setmNeedRefreshData(true);,即在ViewHolder preUpdate方法中,一定要执行UI的刷新操作。
关于定时器的一个很重要的且易忽略的问题:
一个组件需要显示倒计时,这时后台返回了倒计时是50秒,但是此时这个组件在列表中是不可见的。那么此时定时器是不执行的,过了20秒后,用户将这个组件滚动到可见状态,此时倒计时应该是还有30秒,但是数据源那里就还是50秒。这时就会出现倒计时数据不准确的问题,解决方法就是在后台接口返回倒计时数据时,将相对数据50秒,转化成时间戳,这样即使出现上面的情况,倒计时就也就是准确的了。
7、RecycleView中组件定时器相关性能优化
理论上来说,首页这里的配置是可以有无数个定时器存在的,所以对于定时器的性能管理是一个需要严肃思考的问题(对于所有定时器有一个统一的TimerManager来进行创建和管理,也是非常有必要的)。
1)列表滚动时,定时器会执行,但不渲染UI。只有在列表静止时才会更新定时器的UI。
mCustomRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
if (mKsRecommendView != null) {
mKsRecommendView.resumeRenderCountDown();
}
} else {
if (mKsRecommendView != null) {
mKsRecommendView.pauseRenderCountDown();
}
}
}
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
}
});
即在定时器更新方法中,设置一个标志位, 以判断是否要执行更新定时器UI的方法。
2) 拥有定时器的组件如果在列表中不可见时,定时器会执行,但不渲染UI。只有在该组件在列表中可见时
才会更新UI。
if (!mPauseRenderCountDown && mIsBelongPageVisible) {
if (mCustomRecyclerView != null) {
int firstVisiblePosition = mCustomRecyclerView.getmLinearLayoutManager()
.findFirstVisibleItemPosition();
int lastVisiblePosition = mCustomRecyclerView.getmLinearLayoutManager()
.findLastVisibleItemPosition();
if (mAdapterPosition < firstVisiblePosition || mAdapterPosition > lastVisiblePosition) {
L.verbose("ansen666", "Ks View在屏幕中不可见---->");
return;
}
}
mCountDownText.setText(TimeUtils.formatCoutDown(l,
false, true));
}
在RecycleView中,通过findFirstVisibleItemPosition 和 findLastVisibleItemPosition来判断该组件在列表中是否可见。不可见就不setText()。
mPauseRenderCountDown 即为1)中提到的列表此时是否在滚动的标志位。
mIsBelongPageVisible 即为3)中要提到的,拥有定时器的页面是否可见的标志位。
3) 如果当前页面不可见时,也不刷新定时器的UI。
首页可能会存在大量的定时器,切换到其他页面时其仍然在运行并且渲染UI,首页一直在onLayout 和onMeasure,比较耗费资源,而且也会影响到其他页面的流畅性和性能。所以当首页不可见时,首页的定时器就没有渲染UI的必要,且最好也不再进行UI的渲染。
8、首页列表卡顿问题的性能优化
上面也说过,首页存在着十几个模块,有些模块的布局是比较复杂的,所以在部分机器上就出现了一个很令人头痛的性能问题: 滑动卡顿。
没办法,出现了性能问题就需要排查解决啊,打开开发者选项中的Debug Gpu Overdraw,发现确实存在过度绘制的问题。
如上图所示,是优化后的版本,可以看到部分模块绘制还是存在可以优化的空间。但是相对于优化之前,
严重过度绘制的情况,性能上已经好了很多,也满足了我们这边一些低端机型(5 6年前手机)流畅滑动的需要。
针对首页的列表卡顿的优化,主要做了以下几点保证了性能:
1) 针对过度绘制的问题 : 对列表中的各种组件View,尤其是卡顿严重的商品模块(ViewPager+Panel +商品view)进行了布局层级的优化,减少所有可以减少的Layout布局层级。包括在ViewHolder中可以new出来的View,直接new出来,而不是通过xml inflater的方式(View 通过 xml inflater时 比new View(Context) 耗时增加很多,消耗也更大)。
2)经过网上查阅的资料 Android 不同布局类型measure、layout、draw耗时对比(未做详细验证,仅供参考,原文链接https://blog.csdn.net/a740169405/article/details/79037191)
在不影响布局层级深度的情况下,应该尽量使用LinearLayout 和FrameLayout 来替换掉原来布局中的RelativeLayout 和ConstraintLayout(有时RelativeLayout是会减少布局层级的,作者本人在调试在售商品模块时,发现两层LinearLayout的性能竟然比一层ConstraintLayout的性能还要高,涉及到层级取舍问题)。而本人在使用LinearLayout和FrameLayout后,配合减少布局层级,使布局 在layout draw measure上时间大大减少,整个首页列表的性能和流畅性也提升了很多。
3)除了上述在布局层面的优化,还有就是上文提到过的在adapter的onBindViewHolder中,进行preUpdate的判断,如果本次ViewHolder接收到的数据和上次拿到的数据一致,说明数据并未刷新(比如列表Item只是从不可见到可见),此时不需要继续直接View的UI刷新操作,直接return就好,这也节省无谓的刷新导致的内存占用。
4) 还有就是上文提到过的定时器的优化。列表中有定时器时,每当定时器触发UI的更新时,RecycleView都需要进行onLayout 和OnMeasure的执行操作。
在拥有定时器的组件不可见,以及列表滚动时,或者拥有定时器的页面不可见时,就停止定时器的渲染UI操作(定时器可以继续运行,只是不更新UI)。这样就减少了因定时器更新UI而导致RecycleView onLayout onMeasure的执行操作,减少了计算时间和性能上的消耗。
5)还有就是商品列表那里调试性能时发现的问题。每次下拉刷新后,force garbage collection之后,
增加的内存并不能降下来。后来经过分析之后,发现是因为商品列表使用ViewPager+ PanelView实现。在每次下拉刷新后,上次生成的List<PanelView>是直接抛弃了,重新生成了一份新的List<PanelView>。这样就导致了每下拉刷新一次,就重新生成了很多歌PanelView。解决方法就是将第一次生成的List<PanelView> 缓存下来,供下一次下拉刷新后使用。这样force garbage collection之后,增加的内存的就可以降到初始值了。
所以在构建组件时,注意进行View的复用也可以极大的节省内存占用和提升性能。
9、内存泄漏问题的排查和解决。
如果说上文提到的卡顿问题,我们可以归根于手机性能太差了(3 4 年前甚至5 6年前的手机)。那么如果代码存在内存泄漏的问题,我们就不能再找任何借口了。除了心里暗暗骂Google不能帮开发者减少内存泄漏的风险之外,我们只能逐个排查泄漏根源所在。
是的,这次内存泄漏的问题,好巧不巧我们也遇到了(其实应该之前版本就存在,只是之前版本因功能原因等内存占用较低,此问题不突出)。测试同学反馈近期应用经常出现闪退的问题(不是崩溃,是直接闪退应用),经过初步分析和确认,有很大几率是内存泄漏的问题。打开Android Profiler进行调试时发现:
打开应用进入首页,不断切换App首页的几个tab,然后退出应用。然后再进入应用,重复上述步骤。然后运行Force Garbage Collection 执行垃圾回收操作,然后执行Dump Java Heap。然后发现首页MainActivity仍然存在多个实例无法被释放掉,那么很明显了,内存泄漏了,有对象持有了Activity的引用,无法被释放掉,造成了多个MainAcitivty的实例存在,也就引发了一连串相关内存无法释放的问题,造成内存泄漏和闪退。
明确了问题根源,接下来就是漫长而枯燥的排查工作,没办法,自己挖的坑,无论如何也要填完。
一般涉及到类似无法定位的问题排查时,经常使用二分查找法。而这次为了更准确的定位是哪个模块的问题,我们采用了逐个模块进行排查的方式(每次屏蔽其他模块,只展示要排查的模块),整个排查的过程比较漫长和枯燥,这里不再赘述,直接给出排查后的结论。
内存泄漏的原因有很多,这里只给出这次排查涉及到的点:
在全局变量比如单例中,如果使用到Context ,一定不要持有Activity的Context,尽量使用Application 的Context。
单例在应用程序退出时,一定要记得销毁,普通的退出应用时,单例并不会自动销毁,需要手动进行释放。如果单例中存在大量数据,不主动销毁单例在应用程序退出时仍然会占用大量内存。
Listener Callback等如果持有View的引用,Activity退出时,要记得取消相应回调等,防止因Listener未释放,导致View没有被释放而引发的内存占用问题。
经组内测试发现,Lambda表达式,在部分使用场景下,会生成static的数据变量,很难释放和销毁,后续会针对这块做相应处理,目前要尽力避免使用Lambda表达式(我们用的是apply plugin: 'me.tatarka.retrolambda' 这个库)。
定时器启动后一定要注意销毁,因其不销毁造成的内存泄漏问题是必然存在的。
在使用List<Activity> List<view> List<Fragment>时,使用完成后,需要手动clear掉,防止出现内存泄漏的问题,尤其是List<Activity>,以前是发现过因为List<Activity> 未妥善处理导致的泄漏问题。
应用退出时,引发多个Activity实例存在且无法销毁的最直接原因:
1)某个模块在页面退出时定时器未销毁(很低级且致命的问题)
2) Listener持有view引用且Listener如果不能够得到释放的话,就会存在泄漏问题。 比如Listener被保存在Map中未被释放(一个错误的调用导致的很极端的泄漏问题)
而上述所列的其他原因,也会对内存占用造成极大负担,且加大了内存泄漏的风险,在开发中一定要小心谨慎,并严格按照规范要求来使用。
结语:
本文主要针对在本次版本迭代使用RecycleView构建首页时产生的一系列问题的回顾与总结。本次迭代主要涉及到了后台接口数据不确定时的数据映射方案的实现;RecycleView中组件View的复用,状态数据的保存和恢复,脏数据的清理;列表中定时器创建和销毁生命周期的管理;列表中定时器相关的性能优化;列表卡顿相关的性能优化,内存泄漏问题的定位和排查等。希望其中涉及到的问题能对其他人有所启发和帮助。
因时间关系文章难免有疏漏,欢迎提出指正,谢谢。