ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ListView与RecyclerView复用机制对比:仿今日头条项目实战解析

ListView与RecyclerView复用机制对比:仿今日头条项目实战解析 做Android开发这些年我见过太多人一上来就问“仿今日头条项目用ListView还是RecyclerView”其实这个问题背后藏着的是很多人对列表控件底层复用机制的一知半解。今天我就拿一个完整的仿今日头条练手项目把这两种列表控件从原理到实战讲透。这篇内容适合刚学完四大组件、准备做第一个像样项目的Android新手也适合那些会写Adapter但从来没深究过复用原理的同学。看完之后你不仅能自己搭出一个可运行的信息流页面还能在面试时把“ListView和RecyclerView的区别”回答得滴水不漏。1. 项目整体思路仿今日头条到底在仿什么1.1 拆解今日头条信息流的核心需求仿今日头条的项目核心就四个字信息流。顶部是标题栏下面是可切换的TabTab底下是无限加载的新闻列表。列表里既有纯文字新闻也有带封面图的图文新闻滑到底部还要自动加载下一页。看起来不复杂真做起来知识点一个不少列表适配器、item复用、滚动性能、图片异步加载、下拉刷新、上拉加载、多类型item混排、复杂布局嵌套。很多人觉得“不就是个ListView嘛”结果做完才知道性能问题一堆。今日头条这种信息流的本质其实是“高频增删 快速滑动 多类型item”。高频增删对应的是加载更多和下拉刷新快速滑动对应的是图片异步加载和内存管理多类型item对应的是Adapter里对ViewType的处理。这些点ListView和RecyclerView都会遇到但处理方式有差别。我把两种实现都做一遍对比着看你对列表控件的理解会比我单讲任何一个都深。1.2 为什么不要纠结“哪个更好”要看场景网上很多教程喜欢把RecyclerView吹上天把ListView踩到土里。我的看法是没有绝对的好坏只有合不合适。先看一张对比表对比维度ListViewRecyclerView布局能力只能纵向滚动列表线性、网格、瀑布流自定义LayoutManager随意扩展复用机制convertView复用需要手动ViewHolder优化强制使用ViewHolder复用由框架保证Item动画基本没有内置需要自己写在ListView里内置DefaultItemAnimator增删改直接带动画局部刷新靠Adapter自己维护position刷新粒度为整行notifyItemChanged DiffUtil精准刷新学习成本简单直接适合新手入门概念多有LayoutManager、ViewHolder、ItemDecoration等适用场景简单列表、老项目维护、快速原型复杂信息流、需要动画和局部刷新的场景我的建议是练手项目两个都写一遍别只写其中一个。理由很直接面试官问你两者区别时你没亲手写过ListView答出来的全是从博客背下来的话一追问就露馅。而且老项目维护里ListView仍然大量存在会读老代码也是一种能力。2. 环境准备与项目基建先把坑填平再写代码2.1 Android Studio Hedgehog与AGP 8版本匹配有同学问“Android Studio Hedgehog | 2023.1.1 Patch 2支持AGP 8版本吗”答案是支持的。Hedgehog这个版本对应AGP 8.2新建项目选Gradle 8.2或8.3配AGP 8.2.x跑起来非常稳定。但AGP 8和之前的老版本有个明显区别老项目迁移过来时很多配置都变了。比如build.gradle里必须显式配置namespacecompileSdk如果没写AGP会按其默认要求来第三方库没升级到兼容AGP 8的版本编译时会报各种奇怪的依赖错误。我在做这个项目时用的是Android Studio Hedgehog Patch 2AGP版本8.2.2Gradle版本8.2实测没有任何问题。项目根目录的build.gradle配置如下// 根目录 build.gradle plugins { id com.android.application version 8.2.2 apply false }app模块下的build.gradleplugins { id com.android.application } android { namespace com.example.toutiao compileSdk 34 defaultConfig { applicationId com.example.toutiao minSdk 21 targetSdk 34 versionCode 1 versionName 1.0 } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro } } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } }这里要特意提醒一下Java版本的问题。AGP 8默认要求JDK 17你电脑上如果装的是JDK 8或11编译时会直接报“Unsupported class file major version”的错误。所以开发前先把JDK切到17Android Studio里的Gradle JDK也要对应设置成17第一关才不会卡住。2.2 依赖引入与目录规划仿今日头条项目我建议不要上来就引一堆框架先把原生列表写熟再考虑换成第三方库。图片加载这一步可以提前引Glide因为后面测试真实网络图片时自己手写下载器太浪费时间。下拉刷新我用了SwipeRefreshLayout这是官方库不需要额外依赖。依赖配置如下dependencies { implementation androidx.appcompat:appcompat:1.6.1 implementation androidx.recyclerview:recyclerview:1.3.2 implementation androidx.swiperefreshlayout:swiperefreshlayout:1.1.0 implementation com.github.bumptech.glide:glide:4.16.0 implementation androidx.coordinatorlayout:coordinatorlayout:1.2.0 }目录规划上我按功能分包adapter包放两个列表的Adapterbean包放新闻实体类activity放主页Activityutil放工具类。两个版本共用一个Activity只是切换列表视图时用不同的Adapter这样对比起来很方便。NewsBean这个实体类要包含title、source、coverUrl、commentCount这些字段后面多类型item也要用到它。3. 核心细节解析Adapter、ViewHolder与复用机制3.1 ListView的Adapter写法与getView机制ListView的Adapter核心方法是getView()。很多人第一次写这个方法的反应是每次调用都inflate一个View然后返回不是很简单吗是简单但性能非常差。因为ListView滑动时getView()会被疯狂调用每划过一个item就执行一次inflate和findViewByIdCPU和时间全耗在这上面了。所以必须做复用。ListView的复用机制是这样的屏幕上划出去的item会被回收进一个缓存池当需要展示新item时系统把它传回来就是getView()里的convertView参数。如果convertView不为null说明这是个回收来的View你可以直接复用不必再inflate。在此基础上再配一个ViewHolder把itemView内部的子控件引用缓存起来View.setTag()存进去。下一次拿到旧的convertView时getTag()把ViewHolder取出来直接用这些控件引用改数据彻底避免重复findViewById。这是ListView性能优化的标准做法代码长这样public class NewsListAdapter extends BaseAdapter { private Context mContext; private ListNewsBean mData; public NewsListAdapter(Context context, ListNewsBean data) { this.mContext context; this.mData data; } Override public int getCount() { return mData null ? 0 : mData.size(); } Override public Object getItem(int position) { return mData.get(position); } Override public long getItemId(int position) { return position; } Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView LayoutInflater.from(mContext).inflate(R.layout.item_news, parent, false); holder new ViewHolder(); holder.ivCover convertView.findViewById(R.id.iv_cover); holder.tvTitle convertView.findViewById(R.id.tv_title); holder.tvSource convertView.findViewById(R.id.tv_source); convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } NewsBean bean mData.get(position); holder.tvTitle.setText(bean.getTitle()); holder.tvSource.setText(bean.getSource()); Glide.with(mContext) .load(bean.getCoverUrl()) .placeholder(R.drawable.ic_placeholder) .into(holder.ivCover); return convertView; } static class ViewHolder { ImageView ivCover; TextView tvTitle; TextView tvSource; } }这里有个核心逻辑必须理解if (convertView null)只负责创建View和ViewHolderelse分支是纯数据绑定。滑动过程中大部分getView()走的是else分支inflate和findViewById只发生有限几次。这就是ListView性能优化的全部秘密。3.2 RecyclerView的Adapter写法与DiffUtilRecyclerView的Adapter把“创建View”和“绑定数据”拆成了两个方法onCreateViewHolder()和onBindViewHolder()。onCreateViewHolder只负责inflate并创建ViewHolder系统会把它放进RecycledViewPool缓存onBindViewHolder只负责把数据绑到holder对应的控件上。这个拆分的本质就是把ListView里混在一起的逻辑拆干净系统也强制你按复用思路来写。拿这个仿今日头条项目举例我的RecyclerView Adapter支持两种item类型纯文字和带图。getItemViewType()根据数据有无封面图来区分onCreateViewHolder()再根据viewType inflate不同的布局。多类型item的写法是RecyclerView进阶必须掌握的。public class NewsRecyclerAdapter extends RecyclerView.AdapterRecyclerView.ViewHolder { private static final int TYPE_TEXT 0; private static final int TYPE_IMAGE 1; private ListNewsBean mData; private OnItemClickListener mListener; public interface OnItemClickListener { void onItemClick(int position, NewsBean bean); } public void setOnItemClickListener(OnItemClickListener listener) { this.mListener listener; } public void setData(ListNewsBean data) { this.mData data; notifyDataSetChanged(); } public void addData(ListNewsBean data) { if (this.mData null) { this.mData new ArrayList(); } this.mData.addAll(data); notifyItemRangeInserted(this.mData.size() - data.size(), data.size()); } Override public int getItemViewType(int position) { if (mData.get(position).getCoverUrl() null) { return TYPE_TEXT; } return TYPE_IMAGE; } NonNull Override public RecyclerView.ViewHolder onCreateViewHolder(NonNull ViewGroup parent, int viewType) { if (viewType TYPE_TEXT) { View view LayoutInflater.from(parent.getContext()).inflate(R.layout.item_news_text, parent, false); return new TextHolder(view); } else { View view LayoutInflater.from(parent.getContext()).inflate(R.layout.item_news_image, parent, false); return new ImageHolder(view); } } Override public void onBindViewHolder(NonNull RecyclerView.ViewHolder holder, int position) { NewsBean bean mData.get(position); if (holder instanceof TextHolder) { ((TextHolder) holder).tvTitle.setText(bean.getTitle()); } else if (holder instanceof ImageHolder) { ((ImageHolder) holder).tvTitle.setText(bean.getTitle()); Glide.with(holder.itemView.getContext()) .load(bean.getCoverUrl()) .placeholder(R.drawable.ic_placeholder) .into(((ImageHolder) holder).ivCover); } holder.itemView.setOnClickListener(v - { if (mListener ! null) { mListener.onItemClick(holder.getAdapterPosition(), bean); } }); } Override public int getItemCount() { return mData null ? 0 : mData.size(); } static class TextHolder extends RecyclerView.ViewHolder { TextView tvTitle; public TextHolder(NonNull View itemView) { super(itemView); tvTitle itemView.findViewById(R.id.tv_title); } } static class ImageHolder extends RecyclerView.ViewHolder { TextView tvTitle; ImageView ivCover; public ImageHolder(NonNull View itemView) { super(itemView); tvTitle itemView.findViewById(R.id.tv_title); ivCover itemView.findViewById(R.id.iv_cover); } } }从代码里可以看到RecyclerView在绑定数据时position是onBindViewHolder的入参不需要像ListView那样自己从getView的position参数去拿。但要注意onBindViewHolder没有convertView这个概念了系统已经帮你做了复用你只需要写“拿到holder把数据设进去”。这比ListView的思维负担小很多。DiffUtil是RecyclerView一个非常实用的补充工具。仿今日头条里下拉刷新后如果整个列表调用notifyDataSetChanged()会带来两个问题刷新时列表闪烁、视觉跳跃严重所有item重新绘制性能浪费。用DiffUtil计算新旧列表差异然后调用notifyItemRangeInserted、notifyItemRangeRemoved这些方法精准刷新用户体验完全是另一个级别。DiffUtil用起来并不复杂核心就是写一个继承自DiffUtil.Callback的类实现areItemsTheSame()和areContentsTheSame()两个方法。前者判断是不是同一条数据我用id判断后者判断item内容有无变化我比较title和commentCount字段。3.3 两种复用机制的差异与性能对照理解了上面的写法再回头看两者区别就很清楚了。ListView的复用是“半自动”的系统把缓存的convertView还给你你得自己判断它是null还是非null自己维护ViewHolder并setTag。好处是逻辑透明坏处是容易漏写或写错。RecyclerView的复用是“全自动”的你只管在onCreateViewHolder里创建在onBindViewHolder里绑定系统内部通过RecycledViewPool来管理缓存。好处是强制规范坏处是内部层级比ListView多初次接触时像黑盒。性能上差异其实没有想象中那么大。同样做最简单的单类型列表两者都能跑得很流畅。真正拉开差距的是多类型列表和局部刷新。今日头条的列表一个屏幕里可能同时出现纯文字、单图、三图、视频卡片RecyclerView的缓存池会按viewType分池缓存切换类型时效率更高ListView虽然也有getViewTypeCount和getItemViewType但本质上还是兜底在同一个convertView逻辑里处理起来别扭得多。所以结论很清楚如果你要构建一个现代信息流应用RecyclerView是主战力ListView用来理解复用原理和老项目维护。两种都有必要掌握。4. 仿今日头条首页实操从空壳到可运行的信息流4.1 顶部Tab与轮播Banner的布局设计仿今日头条首页光有列表还不够顶部要有一个可滑动的Tab栏内容区域上方还要有轮播Banner。我在项目里用的是CoordinatorLayout作为根布局AppBarLayout包住顶部标题栏下面放TabLayout再下面才是列表内容。这样AppBarLayout滚动时可以和列表联动产生“标题栏滑出、Tab栏吸顶”的效果这是今日头条典型的交互模式之一。Banner部分用ViewPager2实现轮播每天更新时还能跟TabLayout联动切换Tab时列表数据也跟着切换。我这里的示例简化处理一个Tab对应一个新闻分类当选中不同Tab时重新请求数据并刷新列表。加载逻辑用SwipeRefreshLayout包住列表实现下拉刷新。整体布局的简化结构大概是这样的androidx.coordinatorlayout.widget.CoordinatorLayout com.google.android.material.appbar.AppBarLayout com.google.android.material.appbar.MaterialToolbar / com.google.android.material.tabs.TabLayout / /com.google.android.material.appbar.AppBarLayout androidx.swiperefreshlayout.widget.SwipeRefreshLayout !-- 这里放ListView或RecyclerView -- /androidx.swiperefreshlayout.widget.SwipeRefreshLayout androidx.viewpager2.widget.ViewPager2 / /androidx.coordinatorlayout.widget.CoordinatorLayout需要注意一点ViewPager2的高度不能直接设定死否则Banner会被拉伸变形。我用了自定义的ViewPager2重写onMeasure让它根据图片的比例动态计算高度再在代码里设置Banner数据。这个细节不处理Banner的实际显示效果会很辣眼睛。4.2 ListView版信息流的实现下拉刷新与上拉加载ListView版页面的逻辑相对简单。布局里用SwipeRefreshLayout包住ListView监听SwipeRefreshLayout的刷新回调重新请求第一页数据上拉加载更多我用的是一个很经典的方案给ListView设置OnScrollListener在onScroll方法里判断当第一个可见item的position加上当前可见item数量大于等于总item数量减1时说明滚动到底部了此时触发加载更多。加载更多有两种处理方式一种是在ListView底部添加一个footerView显示“正在加载”文案另一种是滚动到底部时弹出Toast然后加载。为了体验更接近今日头条我在底部加了一个FooterViewlistView.setOnScrollListener(new AbsListView.OnScrollListener() { Override public void onScrollStateChanged(AbsListView view, int scrollState) { // 空闲状态下才判断是否到底避免滑动过程中频繁触发 if (scrollState SCROLL_STATE_IDLE isLastItemVisible !isLoadingMore) { loadMore(); } } Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { isLastItemVisible (firstVisibleItem visibleItemCount totalItemCount); } });这里的关键是isLoadingMore标志位。如果不加这个标志位滚动到底部时会连续触发多次网络请求数据就永远不会收敛还会出现重复item。加载完成后记得把标志位复位然后调用adapter.notifyDataSetChanged()。4.3 RecyclerView版信息流的实现LinearLayoutManager与分页加载RecyclerView版我用LinearLayoutManager管理纵向布局上拉加载用的是RecyclerView.OnScrollListener。因为RecyclerView滚动监听本身就提供了最后一个完全可见item的位置判断逻辑更明确recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { Override public void onScrolled(NonNull RecyclerView recyclerView, int dx, int dy) { super.onScrolled(recyclerView, dx, dy); LinearLayoutManager layoutManager (LinearLayoutManager) recyclerView.getLayoutManager(); int lastVisibleItemPosition layoutManager.findLastVisibleItemPosition(); int totalItemCount layoutManager.getItemCount(); if (lastVisibleItemPosition totalItemCount - 2 !isLoadingMore) { loadMore(); } } });我这里的判断条件是“最后一个可见item位置 总数 - 2”也就是提前两个item触发加载。这么做的好处是滑动到底部时数据几乎已经加载好了用户感知不到“加载中”的等待。加载更多数据回来后用adapter.addData()方法内部调用notifyItemRangeInserted()这样RecyclerView会带动画地插入新item视觉上很顺滑。如果用的是DiffUtil这里会更优雅把新旧数据合并后计算差异精准刷新。4.4 点击事件与item内部控件点击的两种处理方式列表item点击新手最容易写歪。ListView的item点击一般由ListView.setOnItemClickListener()处理但item内部的某个子控件比如点赞按钮想单独响应点击就必须在getView()里给按钮单独绑定并且要阻止事件冒泡到item的点击上。RecyclerView则更灵活常规做法是在Adapter的onBindViewHolder里给holder.itemView设置setOnClickListener把position通过回调传出去。两种方式对比处理方式ListViewRecyclerViewitem整体点击setOnItemClickListenerholder.itemView.setOnClickListeneritem内部子控件点击在getView里对子控件setOnClickListener注意position捕获同样在onBindViewHolder里设置或用接口回调防误触设置clickable为true避免父控件拦截设置itemView的clickable属性在RecyclerView中我用了接口回调的方式Adapter里定义OnItemClickListener接口Activity里实现。这样Adapter不依赖具体Activity便于复用。要注意的是onBindViewHolder里如果绑定点击事件时不小心把旧的监听器残留可能造成内存泄漏所以最好在onViewRecycled()里把控件引用清掉但日常项目里很多同学懒得写我也是在内存排查时才意识到这步不能省。5. 常见问题与性能排查实录这些坑我替你踩过了5.1 item复用导致的数据错乱与点击状态漂移这是ListView和RecyclerView最经典的坑没有之一。很多人写Adapter时只在新创建的View里设置控件的初始状态比如在convertView null时才把图片设置为占位图在绑定数据时却忘了重置。结果滑屏时一个item的图片被错放到另一个item上。更常见的还有“点击状态漂移”。比如item上有一个“关注”按钮用户点了第一个item的关注按钮变成“已关注”滑走再滑回来发现第二个item显示已关注因为hold住的还是那个被点过的按钮View。解决办法是在onBindViewHolder或getView里对所有“可能因复用产生状态残留”的控件做强制重置。比如按钮绑定数据时无条件调用setSelected(isFollow)或setText(isFollow ? 已关注 : 关注)。图片也一样在设置新URL之前先让ImageView显示一个默认占位图防止旧图闪现。图片加载还有一个更隐蔽的问题ImageView复用时Glide异步回调回来的图片可能和张现在显示的URL不匹配。解决办法是在ImageView上setTag(url)加载回调里判断tag是否一致一致才显示图片。Glide本身也提供了override、skipMemoryCache等选项但tag判断是最稳的。5.2 图片加载导致的卡顿与OOM仿今日头条项目里图片是重头戏。你如果直接在onBindViewHolder里用BitmapFactory.decodeStream加载网络图片快速滑两条屏幕直接OOM。真机测试过一屏大概20个item每个图片100KB光ImageView引用的Bitmap缓存就能吃掉几十MB内存。Glide在这方面做得很成熟三级缓存、Bitmap复用池、缩略图、预加载都有。但即使用Glide也要注意加载时机。比如快速滚动时最好用Glide的skipMemoryCache(true)配合内部缓存来控制或者监听滚动状态在飞速滑动时延迟加载。另外我习惯在RecyclerView onScrolled里判断滑动速度如果速度超过阈值就调Glide.with(context).pauseRequests()暂停加载等滚动停止再resumeRequests()。这个细节对流畅度提升非常明显。还有一个容易忽视的点item里的ImageView宽高必须固定或按比例自适应不要让图片加载后再反过来影响ItemView高度。如果高度不定RecyclerView需要反复测量item会引起无谓的layout严重时卡得厉害。5.3 FileProvider与外部存储路径的访问问题有个现象很有意思不少人在分析今日头条App的时候会尝试访问它的应用数据目录会看到类似content://com.ss.android.uri.key/external_root/android/data/com.ss.android.article.app的URI然后疑惑为什么直接拼路径访问不到。这里要澄清一点FileProvider的authority是每个应用自己在Manifest里注册的URI的核心作用不是让你外部直接访问别的应用私有目录。Android从7.0开始File URI曝光已被禁止应用之间共享文件必须通过FileProvider它允许授权访问的是该应用自己目录下或指定路径的文件并不代表一个content URI可以被其他任意应用直接解析。这个知识点在仿今日头条项目里其实用得上如果你要在自己的App里读取外部存储图片然后把Uri传给你自己写的FileProvider接口正确做法是在Manifest里配置匹配自己应用id的authority例如provider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider然后res/xml/file_paths.xml里声明你要共享的路径paths external-path nameexternal_files path. / /paths生成URI时用FileProvider.getUriForFile(context, applicationId .fileprovider, file)这时候你会得到类似content://你的applicationId.fileprovider/external_files/xxx的URI这个才是能被自己的App和其他受到授权的App识别的合法URI。做文件选器或分享功能时这个配置反复被用到值得一次配好。5.4 性能分析用火焰图定位卡顿点仿今日头条项目做到后面如果只是列表滑起来卡分析思路一般是三步先确认是丢帧还是慢函数再精确定位耗时方法最后看是主线程做了太多事还是布局太复杂。Android Studio从Hedgehog版本开始自带Profiler非常方便。我用的是CPU录制里的“Call Stack Sample”录一段滑动列表的过程然后导出一份火焰图。火焰图里每一条横条代表一个方法调用横条越宽表示占用时间越长。一眼扫过去如果方法名里出现BitmapFactory.decodeStream、LayoutInflater.inflate那基本就是图片解码或布局过度inflate导致的。如果出现RecyclerView.recycle那就是复用机制被破坏比如某个item高度频繁变化。我自己的项目里当初卡顿的元凶是Banner图片资源太大一张1920x1080的图直接塞到ViewPager2的ImageView里火焰图中Glide的load流水线占了接近60%的时间。后来全局做了图片尺寸裁剪生成三套不同分辨率的图根据设备宽度动态选择加载哪套卡顿立刻缓解。做信息流项目图片瘦身永远是第一位。5.5 几个容易忽略的小知识点我最后补几个实际踩坑中总结的小经验。第一个RecyclerView嵌套ScrollView或NestedScrollView时要设置NestedScrollingEnabled(false)否则滑动冲突会让你怀疑人生。第二个ListView的divider属性默认高度是0但有时候分隔线消失是样式兼容问题可以手动设置android:divider和android:dividerHeight。第三个Activity里如果用了SwipeRefreshLayout下拉时列表要在顶部才能触发否则手动下拉又想做别的交互会互相冲突。第四个AGP 8之后proguard和资源混淆的写法变了release包如果出现类找不到优先检查混淆规则R8全面默认开启。这些点比较零散但都是真实开发里会遇到的。建议你写项目时也顺手记录一下后面翻笔记时会感谢自己。说实话仿今日头条这个项目我前后写过三遍第一遍用ListView第二遍换RecyclerView第三遍把两者放在同一页面做对比。这几次重写给我最大的体会是列表控件的核心不是API背得熟不熟而是复用机制、数据绑定、状态管理这些思想是不是真吃透了。信息流再怎么变本质还是列表只是列表的形态从ListView进化到了RecyclerView将来可能还会进化到更高级的Compose LazyColumn但底层的复用和异步加载思想不会变。最后再分享一个实操技巧你可以把同一个列表同时实现成ListView版和RecyclerView版中间抽象的接口保持一致然后一键切换列表实现。这个过程会让你对两者差异的理解比看任何教程都深。
返回列表