RecyclerView回收和复用机制分析
1 RecyclerView 的刷新回收复用机制
RecyclerView 在layout 子View 时,都通过回收复用机制来管理。RecyclerView 的回收复用机制确实很完善,覆盖到各种场景中,但并不是每种场景的回收复用时都会将机制的所有流程走一遍的。举个例子说,在setLayoutManager 、setAdapter 、notifyDataSetChanged 或者滑动时等等这些场景都会触发回收复用机制的工作。但是如果只是 RecyclerView 滑动的场景触发的回收复用机制工作时,其实并不需要四级缓存都参与的。
问:假设有一个20 个item 的RecyclerView ,每5 个占满一个屏幕,在从头滑到尾的过程中,onCreatViewHolder 会调用多少次?
RecyclerView 的回收复用机制的内部实现都是由Recycler 内部类实现,下面就都以这样一种页面的滑动场景来讲解RecyclerView 的回收复用机制。
这个页面每行可显示5 个卡位,每个卡位的item 布局type 一致。开始分析回收复用机制之前,先提几个问题:
Q1 :如果向下滑动,新一行的5 个卡位的显示会去复用缓存的ViewHolder ,第1 行的5 个卡位会移出屏幕被回收,那么在这个过程中,是先进行复用再回收?还是先回收再复用?还是边回收边复用?也就是说,新一行的5 个卡位复用的ViewHolder 有可能是第1 行被回收的5 个卡位吗?
黑框表示屏幕,RecyclerView 先向下滑动,第3 行卡位显示出来,再向上滑动,第3 行移出屏幕,第1 行显示出来。我们分别在 Adapter 的onCreateViewHolder() 和onBindViewHolder() 里打日志,下面是这个过程的日志:
红框1 是RecyclerView 向下滑动操作的日志,第3 行5 个卡位的显示都是重新创建的ViewHolder ;红框2 是再次向上滑动时的日志,第1 行5 个卡位的重新显示用的ViewHolder 都是复用的,因为没有create viewHolder 的日志,然后只有后面3 个卡位重新绑定数据,调用了onBindViewHolder() ;那么问题来了:
Q2 :在这个过程中,为什么当RecyclerView 再次向上滑动重新显示第1 行的5 个卡位时,只有后面3 个卡位触发了 onBindViewHolder() 方法,重新绑定数据呢?明明5 个卡位都是复用的。
在上面的操作基础上,我们继续往下操作:先向下再向下
在第二个问题操作的基础上,目前已经创建了15 个ViewHolder ,此时显示的是第1 、2 行的卡位,那么继续向下滑动两次,这个过程的日志如下:
红框1 是第二个问题操作的日志,在这里截出来只是为了显示接下去的日志是在上面的基础上继续操作的;红框2 就是第一次向下滑时的日志,对比问题2 的日志,这次第3 行的5 个卡位用的ViewHolder 也都是复用的,而且也只有后面3 个卡位触发了onBindViewHolder() 重新绑定数据;红框3 是第二次向下滑动时的日志,这次第4 行的5 个卡位,前3 个的卡位用的ViewHolder 是复用的,后面2 个卡位的ViewHolder 则是重新创建的,而且5 个卡位都调用了onBindViewHolder() 重新绑定数据;
Q3 :接下去不管是向上滑动还是向下滑动,滑动几次,都不会再有onCreateViewHolder() 的日志了,也就是说RecyclerView 总共创建了17 个ViewHolder ,但有时一行的5 个卡位只有3 个卡位需要重新绑定数据,有时却又5 个卡位都需要重新绑定数据,这是为什么呢?
如果明白RecyclerView 的回收复用机制,那么这三个问题也就都知道原因了;反过来,如果知道这三个问题的原因,那么理解 RecyclerView 的回收复用机制也就更简单了;所以,带着问题,在特定的场景下去分析源码的话,应该会比较容易。
RecyclerView 滑动场景下的回收复用涉及到的结构体两个:mCachedViews 和RecyclerViewPool 。
mCachedViews 优先级高于RecyclerViewPool ,回收时,最新的ViewHolder 都是往mCachedViews 里放,如果它满了,那就移出一个扔到RecyclerViewPool 里好空出位置来缓存最新的ViewHolder 。
复用时,也是先到mCachedViews 里找ViewHolder ,但需要各种匹配条件,概括一下就是只有原来位置的卡位可以复用存在 mCachedViews 里的ViewHolder ,如果mCachedViews 里没有,那么才去RecyclerViewPool 里找。在RecyclerViewPool 里的ViewHolder 都是跟全新的ViewHolder 一样,只要type 一样,有找到,就可以拿出来复用,重新绑定下数据即可。
整体的流程图如下:
最后,解释一下开头的问题
Q1 :如果向下滑动,新一行的5 个卡位的显示会去复用缓存的ViewHolder ,第1 行的5 个卡位会移出屏幕被回收,那么在这个过程中,是先进行复用再回收?还是先回收再复用?还是边回收边复用?也就是说,新一行的5 个卡位复用的ViewHolder 有可能是第1 行被回收的5 个卡位吗?
答:先复用再回收,新一行的5 个卡位先去目前的mCachedViews 和RecyclerViewPool 的缓存中寻找复用,没有就重新创建,然后移出屏幕的那行的5 个卡位再回收缓存到mCachedViews 和RecyclerViewPool 里面,所以新一行5 个卡位和复用不可能会用到刚移出屏幕的5 个卡位。
Q2 :在这个过程中,为什么当RecyclerView 再次向上滑动重新显示第1 行的5 个卡位时,只有后面3 个卡位触发了onBindViewHolder() 方法,重新绑定数据呢?明明5 个卡位都是复用的。
答:滑动场景下涉及到的回收和复用的结构体是mCachedViews 和RecyclerViewPool ,前者默认大小为2 ,后者为5 。所以,当第3 行显示出来后,第1 行的5 个卡位被回收,回收时先缓存在mCachedViews ,满了再移出旧的到RecyclerViewPool 里,所有5 个卡位有2 个缓存在mCachedViews 里,3 个缓存在RecyclerViewPool ,至于是哪2 个缓存在mCachedViews ,这是由LayoutManager 控制。上面讲解的例子使用的是GridLayoutManager ,滑动时的回收逻辑则是在父类LinearLayoutManager 里实现,回收第1 行卡位时是从后往前回收,所以最新的两个卡位是0 、1 ,会放在mCachedViews 里,而2 、3 、4 的卡位则放在RecyclerViewPool 里。
所以,当再次向上滑动时,第1 行5 个卡位会去两个结构体里找复用,之前说过,mCachedViews 里存放的ViewHolder 只有原本位置的卡位才能复用,所以0 、1 两个卡位都可以直接去mCachedViews 里拿ViewHolder 复用,而且这里的ViewHolder 是不用重新绑定数据的,至于2 、3 、4 卡位则去RecyclerViewPool 里找,刚好RecyclerViewPool 里缓存着3 个ViewHolder ,所以第1 行的5 个卡位都是用的复用的,而从RecyclerViewPool 里拿的复用需要重新绑定数据,才会这样只有3 个卡位需要重新绑定数据。
Q3 :接下去不管是向上滑动还是向下滑动,滑动几次,都不会再有onCreateViewHolder() 的日志了,也就是说RecyclerView 总共创建了17 个ViewHolder ,但有时一行的5 个卡位只有3 个卡位需要重新绑定数据,有时却又5 个卡位都需要重新绑定数据,这是为什么呢?
答:有时一行只有3 个卡位需要重新绑定的原因跟Q2 一样,因为mCachedView 里正好缓存着当前位置的ViewHolder ,本来就是它的 ViewHolder 当然可以直接拿来用。而至于为什么会创建了17 个ViewHolder ,那是因为再第4 行的卡位要显示出来时,RecyclerViewPool 里只有3 个缓存,而第4 行的卡位又用不了mCachedViews 里的2 个缓存,因为这两个缓存的是6 、7 卡位的 ViewHolder ,所以就需要再重新创建2 个ViewHolder 来给第4 行最后的两个卡位使用。
2 RecyclerView 的刷新回收复用机制
notifyXxx 后会RecyclerView 会进行两次布局,一次预布局,一次实际布局,然后执行动画操作
dispatchLayoutStep1
查找改变holder ,并保存在mChangedScrap 中;其他未改变的保存到mAttachedScrap 中(mChangedScrap 保存的holder 信息只有预布局时才会被复用)
dispatchLayoutStep2
此步骤会创建一个新的holder 并执行绑定数据,充当改变位置的holder ,其他位置holder 从mAttachedScrap 中获取
3 RecyclerView 为什么要预布局(pre-layout )
列表中有两个表项(1 、2 ),删除2 ,此时3 会从屏幕底部平滑地移入并占据原来2 的位置。
这是怎么做到的?RecyclerView 如何知道表项3 的动画轨迹?虽然动画的终点已经有了(表项2 的顶部),那起点呢?LayoutManager 只加载所有可见表项,在删除表项2 之前,表项3 处于不可见状态,它并不会被layout 。
对于这种情况RecyclerView 的策略是——执行两次layout :为动画前的表项先执行一次pre-layout ,将不可见的表项3 也加载到布局中,形成一张布局快照(1 、2 、3 )。再为动画后的表项执行一次post-layout ,同样形成一张布局快照(1 、3 )。比对两张快照中表项3 的位置,就知道它该如何做动画了。
3.1 预布局生命周期
从RecyclerView.onLayout() 开始:
public class RecyclerView extends ViewGroup implements ScrollingView,
NestedScrollingChild2, NestedScrollingChild3 {
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
TraceCompat.beginSection(TRACE_ON_LAYOUT_TAG);
dispatchLayout();
TraceCompat.endSection();
mFirstLayoutComplete = true;
}
}
RecyclerView.onLayout() 很短,一眼就可以找到其中的关键dispatchLayout() :
public class RecyclerView extends ViewGroup implements ScrollingView,
NestedScrollingChild2, NestedScrollingChild3 {
void dispatchLayout() {
mState.mIsMeasuring = false;
if (mState.mLayoutStep == State.STEP_START) {
dispatchLayoutStep1();
mLayout.setExactMeasureSpecsFrom(this);
dispatchLayoutStep2();
} else if (mAdapterHelper.hasUpdates() || mLayout.getWidth() != getWidth()
|| mLayout.getHeight() != getHeight()) {
mLayout.setExactMeasureSpecsFrom(this);
dispatchLayoutStep2();
} else {
mLayout.setExactMeasureSpecsFrom(this);
}
dispatchLayoutStep3();
}
}
布局分了三个步骤,从第一步骤开始看:
public class RecyclerView extends ViewGroup implements ScrollingView,
NestedScrollingChild2, NestedScrollingChild3 {
private void dispatchLayoutStep1() {
mState.mInPreLayout = mState.mRunPredictiveAnimations;
}
public static class State {
boolean mInPreLayout = false;
}
}
在分发布局第一步中发现了一个布尔变量mInPreLayout ,字面意思是:是否在pre-layout 过程中。
找到一点和pre-layout 沾边的信息,映入脑壳的问题是:mInPreLayout 什么时候被置为true ,什么时候又被置为false ?,回答这个问题就能知道pre-layout 的生命周期了。
全局搜索mInPreLayout 被赋值的地方,除了mState.mInPreLayout = mState.mRunPredictiveAnimations; 其余都被置为 false。想必mState.mRunPredictiveAnimations 一定为true ,怎么验证?看看它在哪里被赋值:
public class RecyclerView extends ViewGroup implements ScrollingView,
NestedScrollingChild2, NestedScrollingChild3 {
private void processAdapterUpdatesAndSetAnimationFlags() {
mState.mRunSimpleAnimations = mFirstLayoutComplete
&& mItemAnimator != null
&& (mDataSetHasChangedAfterLayout
|| animationTypeSupported
|| mLayout.mRequestedSimpleAnimations)
&& (!mDataSetHasChangedAfterLayout
|| mAdapter.hasStableIds());
mState.mRunPredictiveAnimations = mState.mRunSimpleAnimations
&& animationTypeSupported
&& !mDataSetHasChangedAfterLayout
&& predictiveItemAnimationsEnabled();
}
}
mRunPredictiveAnimations 的值由另外N 个布尔变量共同决定,难道我得挨个搜索其他变量才能确定它的值吗?(其实有一个更简单的方法可以验证,下面会提到)
就此打住,mRunPredictiveAnimations 的值一定为true ,否则mInPreLayout 就永远为false 了。
继续走查dispatchLayoutStep1() 剩余的代码:
public class RecyclerView extends ViewGroup implements ScrollingView,
NestedScrollingChild2, NestedScrollingChild3 {
private void dispatchLayoutStep1() {
mState.mInPreLayout = mState.mRunPredictiveAnimations;
if (mState.mRunSimpleAnimations) {
mLayout.onLayoutChildren(mRecycler, mState);
}
}
}
发现了一个很关键的方法LayoutManager.onLayoutChildren() ,它有很长的注释,大意是“该方法用于布局Adapter 中所有的表项。若支持表项动画,则onLayoutChildren() 会被调用2 次,第一次称为pre-layout ,它是真正布局表项之前的一次预布局。”
搜索LayoutManager.onLayoutChildren() 被调用的地方,只有两处,一次在RecyclerView.dispatchLayoutStep1() 中,另一次在RecyclerView.dispatchLayoutStep2() :
public class RecyclerView extends ViewGroup implements ScrollingView,
NestedScrollingChild2, NestedScrollingChild3 {
private void dispatchLayoutStep2() {
mState.mInPreLayout = false;
mLayout.onLayoutChildren(mRecycler, mState);
}
}
布局的第二步中,调用onLayoutChildren() 前,把mInPreLayout 置为了 false,pre-layout 就此结束。
而且mState 作为参数被传入onLayoutChildren() ,在onLayoutChildren() 中一定会读取mInPreLayout 。
看到这里,结合注释和代码走查,可以下一些结论:
RecyclerView 为了实现表项动画,进行了2 次布局,第一次预布局,第二次正真的布局,在源码上表现为LayoutManager.onLayoutChildren() 被调用2 次mState.mInPreLayout 的值标记了预布局的生命周期。预布局的过程始于RecyclerView.dispatchLayoutStep1() ,终于RecyclerView.dispatchLayoutStep2() 。两次调用LayoutManager.onLayoutChildren() 会因为这个标记位的不同而执行不同的逻辑分支。
3.2 预布局填充额外表项
知道了预布局的起点和终点,就为走查代码缩小了范围。只需要定位在LinearLayoutManager.onLayoutChildren() 中,就可以了解预布局做了些什么。
预布局一定做了很多事情,但现在最关心的是“预布局过程中,如何将额外的不可见表项填充进来?”
在RecyclerView缓存机制(咋复用?)中讲述了怎么在源码中一步步找到 “填充表项” 的逻辑,这段逻辑正好就在onLayoutChildren() 中,引用如下:
public class LinearLayoutManager {
public void onLayoutChildren() {
fill() {
while(列表有剩余空间){
layoutChunk(){
addView(view)
}
}
}
}
}
RecyclerView 将布局表项的任务委托给LinearLayoutManager 。LinearLayoutManager 布局表项时,在fill() 方法中循环不断地调用layoutChunk() 逐个将表项填入,直到列表没有空间。
对于填充表项,fill() 和layoutChunk() 是两个关键方法,添加额外表项的逻辑肯定藏在其中:
public class LinearLayoutManager {
int fill(RecyclerView.Recycler recycler, LayoutState layoutState,RecyclerView.State state, boolean stopOnFocusable) {
...
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
...
layoutChunk()
...
}
}
}
LinearLayoutManager 在循环填充表项前会计算剩余空间,计算公式中的mExtraFillSpace 引起了我的注意,它和我关心的问题“额外表项”很匹配,心想 “在 pre-layout 过程中可能是mExtraFillSpace 增大,放宽了循环条件,使得额外表项被填充。” 于是乎,我开始搜索它被赋值的地方,结果显示有 11 处(有点多,好慌):
大部分的赋值都发生在onLayoutChildren() 中:
if (mAnchorInfo.mLayoutFromEnd) {
mLayoutState.mExtraFillSpace = extraForStart;
} else {
mLayoutState.mExtraFillSpace = extraForEnd;
}
而且它们分别处于不同的方向分支中,即对于一种方向的列表只有一个赋值语句被执行,随便找了一个mLayoutState.mExtraFillSpace = extraForEnd; ,继续搜索extraForEnd 被赋值的地方:
public class LinearLayoutManager {
private int[] mReusableIntPair = new int[2];
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
...
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
calculateExtraLayoutSpace(state, mReusableIntPair);
int extraForEnd = Math.max(0, mReusableIntPair[1])
}
}
extraForEnd 的值和mReusableIntPair[1] 有关,而它在calculateExtraLayoutSpace() 中被计算,继续跳转:
public class LinearLayoutManager {
protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state,@NonNull int[] extraLayoutSpace) {
int extraLayoutSpaceStart = 0;
int extraLayoutSpaceEnd = 0;
int extraScrollSpace = getExtraLayoutSpace(state);
if (mLayoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
extraLayoutSpaceStart = extraScrollSpace;
} else {
extraLayoutSpaceEnd = extraScrollSpace;
}
extraLayoutSpace[0] = extraLayoutSpaceStart;
extraLayoutSpace[1] = extraLayoutSpaceEnd;
}
}
calculateExtraLayoutSpace() 这个方法名让我更加坚信这条路没错(额外表项对应着额外空间)。
在这个方法中又调用了getExtraLayoutSpace() 并将结果赋值给extraLayoutSpace[1] ,继续跳:
public class LinearLayoutManager {
protected int getExtraLayoutSpace(RecyclerView.State state) {
if (state.hasTargetScrollPosition()) {
return mOrientationHelper.getTotalSpace();
} else {
return 0;
}
}
}
方法要么返回 0 要么返回mOrientationHelper.getTotalSpace() ,我更愿意相信后者,因为只有返回非0值才能证实猜想。为了验证,我还得跳一次:
public class RecyclerView {
public static class State {
public boolean hasTargetScrollPosition() {
return mTargetPosition != RecyclerView.NO_POSITION;
}
}
}
看到这,我陷入了迷茫,因为删除表项操作并不会发生列表滚动,即hasTargetScrollPosition() 应该返回 false,也就说返回额外空间的方法getExtraLayoutSpace() 应该返回0。我无法接受这个事实。。。
难道列表发生滚动了?
怎么证明滚动了?
继续搜索 mTargetPosition 被赋值的地方?
不。。。我已经跳不动了。。
硬生生地看了一下午源码,也没有看到想要的结果,更致命的是硬看很容易钻牛角尖,有限的生命就耗费在这无穷的细节中。
想知道某个变量的值,最快的办法是断点调试,它也可以用到阅读源码上。写了一个简单的 Demo 模拟删除表项的场景,将断点打在计算剩余空间那一行:
public class LinearLayoutManager {
int fill(RecyclerView.Recycler recycler, LayoutState layoutState,RecyclerView.State state, boolean stopOnFocusable) {
...
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
...
layoutChunk()
...
}
}
}
断点告诉我layoutState.mExtraFillSpace 的确为0!
那layoutState.mAvailable 的值是否在pre-layout 过程中变大?断点告诉我没有!
循环条件没有放宽!那额外的表项是如何被填充的?
我将断点打在了循环条件while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) 上,惊喜地发现了一个新的线索:在正常布局表项时,当第二个表项被填充后remainingSpace 就等于0了,但同样的情况在 pre-layout 阶段,remainingSpace 就不为0,这导致循环可以多走一次,即可以将表项 3 填充进来。
每次循环填充表项后remainingSpace 的值应该变小,难道填充被删除的表项时跳过了这个步骤?
又到了硬看源码发挥作用的时刻:
public class LinearLayoutManager {
int fill(RecyclerView.Recycler recycler, LayoutState layoutState,RecyclerView.State state, boolean stopOnFocusable) {
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
layoutChunkResult.resetInternal();
layoutChunk(recycler, state, layoutState, layoutChunkResult);
...
if (!layoutChunkResult.mIgnoreConsumed || layoutState.mScrapList != null|| !state.isPreLayout()) {
remainingSpace -= layoutChunkResult.mConsumed;
}
}
}
}
循环中唯一一处扣除剩余空间的代码被一个条件表达式包裹着,表达式中有三个条件做或运算,其中一个条件!state.isPreLayout() 对于非pre-layout 阶段来说肯定为 true,即无论其他条件如何,非pre-layout 阶段一定会扣除所有表项消耗的空间,而对于pre-layout 来说,填充某些表项时,可能会跳过扣除。哪些表项会跳过?
***条件表达式中有一个变量 layoutChunkResult.mIgnoreConsumed,字面意思是忽略这次消耗,而且layoutChunkResult被作为参数传入layoutChunk() ***:
public class LinearLayoutManager {
void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,LayoutState layoutState, LayoutChunkResult result) {
View view = layoutState.next(recycler);
...
RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();
if (params.isItemRemoved() || params.isItemChanged()) {
result.mIgnoreConsumed = true;
}
...
}
}
看到这里感觉八九不离十了,用断点调试验证了,的确和猜想的一样:在预布局阶段,循环填充表项时,若遇到被移除的表项,则会忽略它占用的空间,多余空间被用来加载额外的表项,这些表项在屏幕之外,本来不会被加载。
why
这种负责执行动画的View在原布局或新布局中不存在的动画,就是预测动画。
因为RecyclerView 要执行预测动画。比如有A,B,C三个itemView,其中A和B被加载到屏幕上,这时候删除B后, 按照最终效果我们会看到C移动到B的位置;因为我们只知道 C 最终的位置,但是不知道 C 的起始位置在哪里(即C还 未被加载)。
用户有 A、B、C 三个 item,A,B 刚好显示在屏幕中,这个时候,用户把 B 删除了,那么最终 C 会显示在 B 原 来的位置
因为我们只知道 C 最终的位置,但是不知道 C 的起始位置在哪里,无法确定 C 应该从哪里滑动过来。 在其他 LayoutManager 中,它可能是从侧面或者是其他地方滑动过来的。
what
当 Adapter 发生变化的时候,RecyclerView 会让 LayoutManager 进行两次布局。
第一次,预布局,为动画前的表项先执行一次pre-layout,根据 Adapter 的 notify 信息,我们知道哪些 item 即将 变化了,将不可见的表项 3 也加载到布局中,形成一张布局快照(1、2、3)。
第二次,实际布局,也就是变化完成之后的布局同样形成一张布局快照(1、3)。 这样只要比较前后布局的变化,就能得出应该执行什么动画了,就称为预测动画。
4 ListView 与RecyclerView 区别
1.布局效果
`ListView 的布局比较单一,只有一个纵向效果; RecyclerView 的布局效果丰富, 可以在 LayoutMananger 中 设置:线性布局(纵向,横向),表格布局,瀑布流布局
2.局部刷新
RecyclerView中可以实现局部刷新,例如:notifyItemChanged();
如果要在ListView实现局部刷新,依然是可以实现的,当一个item数据刷新时,我们可以在Adapter中,实现一 个notifyItemChanged()方法,在方法里面通过这个 item 的 position,刷新这个item的数据
3.缓存区别 ListView有两级缓存,在屏幕与非屏幕内。 RecyclerView比ListView多两级缓存 ListView缓存View。
RecyclerView缓存RecyclerView.ViewHolder
5 RecyclerView 性能优化
1.数据处理与视图加载分离
简单来说就是在onBindViewHolder()只设置UI显示,不做任何逻辑判断,需要的业务逻辑在得到javabean之前 处理好,
2.布局优化 减少过渡绘制 减少布局层级 3.设置RecyclerView.addOnScrollListener()来在滑动过程中停止加载的操作。
参考
https://www.jianshu.com/p/467ae8a7ca6e
https://www.pianshen.com/article/73691937375/
https://juejin.cn/post/6890288761783975950#heading-0
|