iOS-UITableviewCell的重用机制和常见问题
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了iOS-UITableviewCell的重用机制和常见问题相关的知识,希望对你有一定的参考价值。
参考技术A UITableViewCell的重用机制体现在-(UITableViewCell)dequeueReusableCellWithIdentifier:(NSString)identifier这个方法中,他的基本意思就是在创建cell的时候为每一个cell都绑定一个identifier的标识。当cell从我们的视觉范围中消失的时候,这个绑定了cell的标识就会被放到缓存池中。当tableView需要新的cell的时候,直接先去缓存池中寻找有没有携带identifier的cell,若有的话直接复用;没有的话,才去创建新的cell,并绑定标识identifier。所以从理论上讲,倘若一屏最多显示的cell个数为n个,那么需要携带identifier表示的cell最少只需n+1个。查看UITableView头文件,会找到NSMutableArray* visiableCells,和NSMutableDictnery* reusableTableCells两个结构。visiableCells内保存当前显示的cells,reusableTableCells保存可重用的cells。
UITableView显示之初,reusableTableCells为空,那么tableView dequeueReusableCellWithIdentifier:CellIdentifier返回nil。开始的cell都是通过[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier]来创建,而且cellForRowAtIndexPath只是调用最大显示cell数的次数。
比如:有100条数据,iPhone一屏最多显示10个cell。程序最开始显示TableView的情况是:
1. 用[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier]创建10次cell,并给cell指定同样的重用标识(当然,可以为不同显示类型的cell指定不同的标识)。并且10个cell全部都加入到visiableCells数组,reusableTableCells为空。
2. 向下拖动tableView,当cell1完全移出屏幕,并且cell11(它也是alloc出来的,原因同上)完全显示出来的时候。cell11加入到visiableCells,cell1移出visiableCells,cell1加入到reusableTableCells。
3. 接着向下拖动tableView,因为reusableTableCells中已经有值,所以,当需要显示新的cell,cellForRowAtIndexPath再次被调用的时候,tableView dequeueReusableCellWithIdentifier:CellIdentifier,返回cell1。cell1加入到visiableCells,cell1移出reusableTableCells;cell2移出visiableCells,cell2加入到reusableTableCells。之后再需要显示的Cell就可以正常重用了。
使用过程中,并不是只有拖动超出屏幕的时候才会更新reusableTableCells表,还有:
1. reloadData,这种情况比较特殊。一般是部分数据发生变化,需要重新刷新cell显示的内容时调用。在cellForRowAtIndexPath调用中,所有cell都是重用的。我估计reloadData调用后,把visiableCells中所有cell移入reusableTableCells,visiableCells清空。cellForRowAtIndexPath调用后,再把reuse的cell从reusableTableCells取出来,放入到visiableCells。
2. reloadRowsAtIndex,刷新指定的IndexPath。如果调用时reusableTableCells为空,那么cellForRowAtIndexPath调用后,是新创建cell,新的cell加入到visiableCells。老的cell移出visiableCells,加入到reusableTableCells。于是,之后的刷新就有cell做reuse了。
1、重取出来的cell是有可能已经捆绑过数据或者加过子视图的,所以,如果有必要,要清除数据(比如textlabel的text)和remove掉add过的 子视图(使用tag)
2、删除重用的cell的所有子视图,从而得到一个没有特殊格式的cell,供其他cell重用。
//删除cell的所有子视图
while ([cell.contentView.subviews lastObject] != nil)
[(UIView*)[cell.contentView.subviews lastObject] removeFromSuperview];
3、为每个cell指定不同的重用标识符(reuseIdentifier)来解决。重用机制是根据相同的标识符来重用cell的,标识符不同的cell不能彼此重用。
NSString *identifier = [NSString stringWithFormat:@"TimeLineCell%d%d",indexPath.section,indexPath.row];
if (cell == nil)
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:identifier];
4、重用机制调用的就是dequeueReusableCellWithIdentifier这个方法,方法的意思就是“出列可重用的cell”,因而只要将它换为cellForRowAtIndexPath(只从要更新的cell的那一行取出cell),就可以不使用重用机制,因而问题就可以得到解决,但会浪费一些空间
RecyclerView中ViewHolder重用机制理解(解决图片错乱和闪烁问题)
RecyclerView中ViewHolder重用机制理解(解决图片错乱和闪烁问题)
对于使用ViewHolder引起的图片错乱问题,相信大部分人都有遇到过,我也一样,对于解决方法也有所了解,但一直都是知其然不知其所以然。
所以,这次直接把ViewHolder的工作原理,通过简单的demo代码来验证一次,验证后对于图片错乱和闪烁这种问题的成因就很清楚了。
下面先上一副图
这幅图就比较清晰的画出了ViewHolder的工作原理。
可以看到,图中左上角item1上面有一条蓝色的线,item7下面也有一条蓝色的线,这两条线就是屏幕的上下边缘,我们在屏幕中能看到的内容就是item1~item7。
当我们控制屏幕向下滚动时,屏幕上的变化是,item1离开了屏幕,紧接着item8进入了屏幕,这是我们看到的。在item1离开,item8进入的过程中,还有一个我们看不到的过程。当item1离开屏幕时,它会进入Recycler(反复循环器)构件,然后被放到了item8的位置,成为了我们看到的item8。
通过代码来验证这个变化过程
下面是MainActivity的代码
初始化了12条数据( 这真的是正经数据 ╮( ̄▽ ̄”)╭ )
初始化Adapter并设置到RecyclerView
public class MainActivity extends AppCompatActivity {
private RecyclerView recyclerView;
private List<String> mData;
private MyRecyclerAdapter recycleAdapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
recyclerView = (RecyclerView) findViewById(R.id.id_recyclerView);
initData();
recycleAdapter = new MyRecyclerAdapter(MainActivity.this, mData);
// ...
recyclerView.setAdapter(recycleAdapter);
// ...
}
private void initData() {
mData = new ArrayList<>();
mData.add("HODV-21194"); //0
mData.add("TEK-080"); //1
mData.add("IPZ-777"); //2
mData.add("MIMK-045"); //3
mData.add("HODV-21193"); //4
mData.add("MIDE-339"); //5
mData.add("IPZ-780"); //6
mData.add("VEC-205"); //7
mData.add("VEMA-113"); //8
mData.add("IPZ-776"); //9
mData.add("MIAD-923"); //10
mData.add("ARM-513"); //11
}
}
下面是Adapter部分,为了更方便验证,代码非常简单,ViewHolder里面只有一个TextView。
public class MyRecyclerAdapter extends RecyclerView.Adapter<MyRecyclerAdapter.MyViewHolder> {
private static final String TAG = "MyRecyclerAdapter";
private List<String> mData;
private Context mContext;
private LayoutInflater inflater;
public MyRecyclerAdapter(Context context, List<String> data) {
this.mContext = context;
this.mData = data;
inflater = LayoutInflater.from(mContext);
}
@Override
public int getItemCount() {
return mData.size();
}
@Override
public void onViewRecycled(MyViewHolder holder) {
super.onViewRecycled(holder);
Log.d(TAG, "onViewRecycled: "+holder.tv.getText().toString()+", position: "+holder.getAdapterPosition());
}
//填充onCreateViewHolder方法返回的holder中的控件
@Override
public void onBindViewHolder(final MyViewHolder holder, final int position) {
Log.d(TAG, "onBindViewHolder: 验证是否重用了");
Log.d(TAG, "onBindViewHolder: 重用了"+holder.tv.getTag());
Log.d(TAG, "onBindViewHolder: 放到了"+mData.get(position));
holder.tv.setText(mData.get(position));
holder.tv.setTag(mData.get(position));
}
//重写onCreateViewHolder方法,返回一个自定义的ViewHolder
@Override
public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
Log.d(TAG, "onCreateViewHolder");
View view = inflater.inflate(R.layout.item_layout, parent, false);
return new MyViewHolder(view);
}
static class MyViewHolder extends RecyclerView.ViewHolder {
TextView tv;
public MyViewHolder(View view) {
super(view);
tv = (TextView) view.findViewById(R.id.id_num);
}
}
}
简单了解上面代码的运行逻辑,并关注onCreateViewHolder()、onBindViewHolder()、onViewRecycled()三个方法打印的Log日志,下面通过打印的Log分析验证ViewHolder的创建、释放与复用。
当第一次打开应用加载RecyclerView时,可以观察到在屏幕中我们看到的每一个item都经过onCreateViewHolder()创建了一个ViewHolder对象,textView中的tag都为null。下图中红色框框中的Log可以验证。
这时候我们往下滚动RecyclerView,再看Log。可可以看到,位置0的数据HODV-21194和位置2的数据IPZ-777所在的ViewHolder被释放,位置10和位置11的数据分别被加载,这个时候,由于onBindViewHolder()在为TextView设置数据前先打印了TextView里面的数据,恰恰就是刚才被回收掉的数据,所以可以验证新绑定的两个ViewHolder对象就是刚才被回收掉的两个ViewHolder。
同理,当我们把屏幕再次往上滚动时,在屏幕下面超出显示范围的item会被回收,并重用到上面的item中。下图Log可以看出,位置11和位置9的数据被回收并重用。
查找ViewHolder出现图片错乱的原因
通过上面的内容解释,了解了ViewHolder的重用机制,接下来看一段会出现图片错乱的代码示例。
public class MyRecyclerAdapter extends RecyclerView.Adapter<MyRecyclerAdapter.MyViewHolder> {
private static final String TAG = "MyRecyclerAdapter";
private List<String> mData;
private Context mContext;
private LayoutInflater inflater;
public MyRecyclerAdapter(Context context, List<String> data) {
this.mContext = context;
this.mData = data;
inflater = LayoutInflater.from(mContext);
}
@Override
public int getItemCount() {
return mData.size();
}
@Override
public void onViewRecycled(MyViewHolder holder) {
super.onViewRecycled(holder);
Log.d(TAG, "onViewRecycled: "+holder.imageView.getTag().toString()+", position: "+holder.getAdapterPosition());
}
@Override
public void onBindViewHolder(final MyViewHolder holder, final int position) {
Log.d(TAG, "onBindViewHolder: 验证是否重用了");
Log.d(TAG, "onBindViewHolder: 重用了"+holder.imageView.getTag());
Log.d(TAG, "onBindViewHolder: 放到了"+mData.get(position));
holder.imageView.setTag(mData.get(position));
new AsyncTask<Void, Void, Bitmap>() {
@Override
protected Bitmap doInBackground(Void... params) {
try {
URL url = new URL(mData.get(position));
Bitmap bitmap = BitmapFactory.decodeStream(url.openStream());
return bitmap;
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
@Override
protected void onPostExecute(Bitmap bitmap) {
super.onPostExecute(bitmap);
holder.imageView.setImageBitmap(bitmap);
}
}.execute();
}
@Override
public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
Log.d(TAG, "onCreateViewHolder");
View view = inflater.inflate(R.layout.item_layout, parent, false);
return new MyViewHolder(view);
}
static class MyViewHolder extends RecyclerView.ViewHolder {
ImageView imageView;
public MyViewHolder(View view) {
super(view);
imageView = (ImageView) view.findViewById(R.id.id_img);
}
}
}
这段代码相对于上一段Adapter的代码改动也比较少,只是把TextView改成了ImageView,并在onBindViewHolder()时异步加载一张网络图片,当加载完毕把图片放置到ImageView中显示。
在不了解ViewHolder重用机制之前,这段代码看似没有什么问题,但事实上这段代码由于ViewHolder重用机制的存在,并不能如期运行。
下面使用这段代码来分析一下场景。
场景A:
1.第一次运行,RecyclerView载入,不做任何触摸操作
2.Adapter经过onCreateViewHolder()创建了上面我们能看到的8个ViewHolder对象,并且在onBind时启动了8条线程加载图片
3.8张图片全部加载完毕,并且显示到对应的ImageView上
4.控制屏幕向下滚动,第1、第2个item离开屏幕可视区域,第9、第10个item进入屏幕可视区域
5.第1、第2个item被回收,重用到第9、第10个item。第9、第10个item显示的图片是第1和第2个item的图片!!!
6.开启了两条线程,加载第9、第10张图片。等待几秒,第9、第10个item显示的图片突然变成了正确的图片!
以上过程是场景A,经过拆分细化,非常容易看出问题所在。如果当前网络速度很快,第6个步骤的加载速度在1秒甚至0.5秒内,就会造成人眼看到的图片闪烁问题出现,第9、第10个item的图片闪了一下变成了正确的图片。
场景B:
1.第一次运行,RecyclerView载入
2.Adapter经过onCreateViewHolder()创建了上面我们能看到的8个ViewHolder对象,并且在onBind时启动了8条线程加载图片
3.7张图片加载完毕,还有1张未加载完(已知图片一加载速度异常慢)
4.控制屏幕向下滚动,第1、第2个item离开屏幕可视区域,第9、第10个item进入屏幕可视区域
5.第1、第2个item被回收,重用到第9、第10个item。闪烁问题不再重复说,第9、第10张图片加载完毕(看上去一切正常)
6.等待几秒,第一张图片终于加载完成,第9个item突然从正确的图片九变成不正确的图片一 !!!
以上过程是场景B,问题出现在加载第一张图片的线程T,持有了item1的ImageView对象引用,而这张图片加载速度非常慢,直到item1已经被重用到item9后,过了一段时间,线程T才把图片一加载出来,并设置到item1的ImageView上,然而线程T并不知道item1已经不存在且变成了item9,于是,图片发生错乱了。
场景C:
1.第一次运行,RecyclerView载入
2.Adapter经过onCreateViewHolder()创建了上面我们能看到的8个ViewHolder对象,并且在onBind时启动了8条线程加载图片
3.忽略图片加载情况,直接向下滚动,再向上滚动,再向下滚动,来回操作
4.由于离开了屏幕的item是随机被回收并重用的,所以向下滚动时我们假设item1、item3被回收重用到item9、item10,item2、item4被回收重用到item11、item12
5.向上滚动时,item9、item12被回收重用到item1、item2,item10、item11被回收重用到item3、item4
6.多次上下滚动后,停下,最后发现某一个item的图片在不停变化,最后还不一定是正确的图片
以上过程是场景C,问题出现在ViewHolder的回收重用顺序是随机的,回收时会从离开屏幕范围的item中随机回收,并分配给新的item,来回操作数次,就会造成有多条加载不同图片的线程,持有同一个item的ImageView对象,造成最后在同一个item上图片变来变去,错乱更加严重。
解决方法:
解决方法其实有很多种,这里列出两种情况:
- 当item还在加载图片的过程中,被移出屏幕可视范围,不需要继续加载这张图片了,可以在onRecycled中取消图片的加载。这样就不会造成图片加载完成设置到其他item的ImageView中了。
- 每一个经过屏幕可视区域的item,加载的图片都要放进缓存中,即使item离开了可视区域,也要加载完毕并放入缓存中,方便下次浏览时能快速加载。每次onBind时对ImageView设置Tag标记,如果Tag标记已经被更改,旧线程加载好的图片不再设置到ImageView中。
当然以上两种情况都别忘了先设置图片占位符,防止回收item的图片直接显示到新item中。
解决方式1 demo代码:
public class MyRecyclerAdapter extends RecyclerView.Adapter<MyRecyclerAdapter.MyViewHolder> {
private static final String TAG = "MyRecyclerAdapter";
private List<String> mData;
private Context mContext;
private LayoutInflater inflater;
public MyRecyclerAdapter(Context context, List<String> data) {
this.mContext = context;
this.mData = data;
inflater = LayoutInflater.from(mContext);
}
@Override
public int getItemCount() {
return mData.size();
}
@Override
public void onViewRecycled(MyViewHolder holder) {
super.onViewRecycled(holder);
AsyncTask asyncTask = (AsyncTask) holder.imageView.getTag(1);
asyncTask.cancel(true);
}
@Override
public void onBindViewHolder(final MyViewHolder holder, final int position) {
//先设置图片占位符
holder.imageView.setImageDrawable(mContext.getDrawable(R.mipmap.ic_launcher));
AsyncTask asyncTask = new AsyncTask<Void, Void, Bitmap>() {
@Override
protected Bitmap doInBackground(Void... params) {
try {
URL url = new URL(mData.get(position));
Bitmap bitmap = BitmapFactory.decodeStream(url.openStream());
return bitmap;
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
@Override
protected void onPostExecute(Bitmap bitmap) {
super.onPostExecute(bitmap);
holder.imageView.setImageBitmap(bitmap);
}
};
holder.imageView.setTag(1,asyncTask);
asyncTask.execute();
}
@Override
public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view = inflater.inflate(R.layout.item_layout, parent, false);
return new MyViewHolder(view);
}
static class MyViewHolder extends RecyclerView.ViewHolder {
ImageView imageView;
public MyViewHolder(View view) {
super(view);
imageView = (ImageView) view.findViewById(R.id.id_img);
}
}
}
解决方式2 demo代码:
public class MyRecyclerAdapter extends RecyclerView.Adapter<MyRecyclerAdapter.MyViewHolder> {
private static final String TAG = "MyRecyclerAdapter";
private List<String> mData;
private Context mContext;
private LayoutInflater inflater;
public MyRecyclerAdapter(Context context, List<String> data) {
this.mContext = context;
this.mData = data;
inflater = LayoutInflater.from(mContext);
}
@Override
public int getItemCount() {
return mData.size();
}
@Override
public void onViewRecycled(MyViewHolder holder) {
super.onViewRecycled(holder);
}
@Override
public void onBindViewHolder(final MyViewHolder holder, final int position) {
//先设置图片占位符
holder.imageView.setImageDrawable(mContext.getDrawable(R.mipmap.ic_launcher));
final String url = mData.get(position);
//为imageView设置Tag,内容是该imageView等待加载的图片url
holder.imageView.setTag(url);
AsyncTask asyncTask = new AsyncTask<Void, Void, Bitmap>() {
@Override
protected Bitmap doInBackground(Void... params) {
try {
URL url = new URL(mData.get(position));
Bitmap bitmap = BitmapFactory.decodeStream(url.openStream());
return bitmap;
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
@Override
protected void onPostExecute(Bitmap bitmap) {
super.onPostExecute(bitmap);
//加载完毕后判断该imageView等待的图片url是不是加载完毕的这张
//如果是则为imageView设置图片,否则说明imageView已经被重用到其他item
if(url.equals(holder.imageView.getTag())) {
holder.imageView.setImageBitmap(bitmap);
}
}
}.execute();
}
@Override
public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view = inflater.inflate(R.layout.item_layout, parent, false);
return new MyViewHolder(view);
}
static class MyViewHolder extends RecyclerView.ViewHolder {
ImageView imageView;
public MyViewHolder(View view) {
super(view);
imageView = (ImageView) view.findViewById(R.id.id_img);
}
}
}
上面的解决方式,是最简单的使用异步线程加载图片,对于加载图片有很多第三方库可以使用,如Picasso、Fresco、Glide等,我们也可以使用这些第三方库来加载图片,但使用第三方库加载的本质还是异步加载,所以如果处理不当也会出现图片闪烁等问题,大家可以使用上面的场景ABC等细化分解的步骤来分析错误,相信很容易就能找到问题。
注意内存泄漏的风险
对于上面的Demo代码,其实是存在内存泄漏风险的,如果需要使用建议把AsyncTask写成静态内部类,以及Adapter初始化时使用ApplicationContext作为参数传入,不要使用Activity作为Context参数。
对于内存泄漏的相关内容,在另一篇文章有详细的解析,有兴趣可以点链接了解。
以上是关于iOS-UITableviewCell的重用机制和常见问题的主要内容,如果未能解决你的问题,请参考以下文章
RecyclerView中ViewHolder重用机制理解(解决图片错乱和闪烁问题)