资讯详情

资讯详情

仿今日头条Android项目实战:资源规划、布局与Adapter优化

我最近把练手项目翻出来重构正好是一个仿今日头条的资讯客户端。做这个项目最大的感触是它看上去界面简单真正动手写的时候资源、布局、Adapter 这三块全都绕不开而且每一块都能踩出一堆坑。如果你也想用 Android Studio 从零实现一个资讯类 App或者项目写到一半觉得代码越来越乱这篇解析值得看完。我会把资源目录怎么规划、首页布局怎么组织、Adapter 怎么应对多类型 item以及列表滑动性能和内存优化这些实测经验一并拆开讲。1. 资源规划先行仿今日头条项目的地基怎么打1.1 为什么先规划资源目录而不是直接写界面很多新手拿到仿今日头条这种项目第一反应是打开布局文件开始堆控件。我先说结论最好不要这样。资讯类 App 的模块非常固定首页频道、新闻列表、详情页、频道管理、我的页面每一个模块都会产生大量 layout、drawable、values 资源文件。如果前期不规划资源目录写到一半你会发现drawable 文件夹里堆了上百个名字相似的文件layout 下各种activity_news_detail2.xml这种重复文件开始出现。我在这个项目里采用的方案是先按照功能模块拆分 res 目录。比如layout/home、layout/news、layout/userdrawable 也按用途命名并分组例如bg_开头放背景、ic_开头放图标、selector_开头放状态选择器。这样做的直接好处是团队协作时每个人只碰自己的模块代码 review 也能快速定位资源。即使你是一个人写三个月后再回来维护也不会看着文件名发呆。我建议把 res 目录规划当作项目架构的一部分来对待而不是散落文件。除了 layout 和 drawablevalues 目录下的 strings、colors、dimens 也要提前分类后面我会专门讲。1.2 mipmap、drawable、values 目录的职责划分仿今日头条项目的资源文件可以分为三类图片图标资源、背景形状资源、配置值资源。它们对应 Android 工程里最常见的三个目录。目录存放内容在仿头条项目中的典型使用mipmap应用图标、启动图标桌面图标、启动页 logodrawable矢量图、selector、shape、layer-list、.9 图新闻标签背景、按钮按压效果、占位图valuesstrings / colors / dimens / styles标题文字、主色、列表间距、主题样式mipmap 目录下我通常会保留不同 dpi 的启动图标。头条项目用到的很多频道图标、收藏按钮、分享按钮我建议尽量用 VectorDrawable 而不是切一套 PNG这样在低端机上清晰度更好安装包体积也小很多。像频道管理里的拖拽图标、搜索框的放大镜图标用 vector 完全够用。drawable 目录里最常用的是 shape 和 selector。新闻列表的标签背景、底部的加载更多按钮都需要圆角矩形和按压反馈。这些如果用多个 PNG 切图来维护换色成本会很高用 drawable 资源反而是一次定义、到处复用。values 目录则需要在项目初期就把颜色和尺寸抽出来。比如头条主色#FF6A00、文字主色#FF222222、列表卡片间距16dp全部定义成资源。否则布局文件里大量硬编码颜色和尺寸后面做深色模式或者尺寸适配时会非常痛苦。1.3 网络与图片加载选型为什么是 Glide Retrofit OkHttp仿今日头条这类资讯项目核心内容是图文列表网络层和图片加载层必须在写列表之前定好。我选择的组合是 Retrofit OkHttp Glide这也是目前社区里最稳妥、资料最多的方案。Retrofit 负责接口定义和数据解析配合 Kotlin 协程或者 RxJava 使用都很顺。OkHttp 负责连接复用、超时控制、日志拦截。这里有一个容易被忽略的点头条列表数据往往是分页的接口参数通常会带channelId和pageToken或者lastId而不是简单的page。在设计 Retrofit 接口时要预留这些字段避免后端返回游标分页时再去改接口。interface NewsApi { GET(news/list) suspend fun getNewsList( Query(channelId) channelId: Int, Query(lastId) lastId: Long? null, Query(pageSize) pageSize: Int 20 ): ApiResponseNewsListResult }Glide 则是列表性能的关键。资讯列表里几乎每个 item 都有图片如果不做图片尺寸压缩直接让后端返回原图内存很快就被打爆。Glide 的override()和centerCrop()是必须配置的这样可以保证只加载 item 需要的尺寸。等到后面我们会再详细说图片加载的缓存策略。2. 图片资源、shape 与 selector资源文件里最容易忽略的细节2.1 图标资源和占位图怎么组织仿头条项目里新闻列表的每条数据通常包含标题、摘要、来源、发布时间、封面图可能还有频道标签和作者头像。头像、图标这类小图我建议放 mipmap 或者 drawable 下的 vector 文件而封面图、资讯大图这种动态资源必须走网络加载不能打进 APK。占位图是一个容易被忽略但很重要的资源。Glide 等图片加载框架都需要设置 placeholder 和 error。头条项目里我常用一个简单的灰色底 图片图标的占位图这个可以用layer-list实现不用单独切图?xml version1.0 encodingutf-8? layer-list xmlns:androidhttp://schemas.android.com/apk/res/android item shape android:shaperectangle solid android:colorcolor/placeholder_bg / /shape /item item android:width36dp android:height36dp android:gravitycenter shape android:shaperectangle solid android:colorcolor/placeholder_icon / /shape /item /layer-list这样做的好处是占位图完全由资源文件控制不依赖外部图片也不占内存。而且加载失败的时候Glide 会展示 error 图两者视觉风格保持一致列表不会显得突兀。2.2 shape 与 selector新闻标签和按钮背景的标准写法资讯类项目里到处是标签和按钮比如热推荐独家这些角标以及底部的加载更多按钮。如果每个页面都写一套背景代码会非常冗长。标准做法是抽成 drawable 资源。我以新闻标签背景为例。新闻标签通常是圆角矩形灰色或浅橙色背景。可以在drawable/news_tag_bg.xml中定义一个 shape?xml version1.0 encodingutf-8? shape xmlns:androidhttp://schemas.android.com/apk/res/android solid android:colorcolor/news_tag_bg / corners android:radius4dp / padding android:left6dp android:right6dp android:top2dp android:bottom2dp / /shape带按压反馈的按钮就要用 selector。比如加载更多按钮按下时颜色变深、松开恢复selector 内部可以叠加两个 shapeselector xmlns:androidhttp://schemas.android.com/apk/res/android item android:state_pressedtrue shape solid android:colorcolor/load_more_pressed / corners android:radius20dp / /shape /item item shape solid android:colorcolor/load_more_normal / corners android:radius20dp / /shape /item /selector把背景逻辑放到资源文件里布局中只要一句android:backgrounddrawable/load_more_selector后面换主题、换颜色都不需要改布局也不会出现按钮状态切换不跟手的问题。这是我强烈建议在新项目里就建立的规范。2.3 colors、dimens、strings 的集中管理项目初期就把颜色抽出来收益非常大。头条项目里我定义了这样一套颜色资源resources color namecolorPrimary#FF6A00/color color nametext_primary#FF222222/color color nametext_secondary#FF999999/color color namedivider#F0F0F0/color color namebg_page#F7F7F7/color color namebg_card#FFFFFF/color /resources文字色不要用black或者#000因为纯黑在部分屏幕上看会比较刺眼。间距和字号则放在 dimens 里。头条首页的 TabLayout 高度、新闻卡片间距、标题字号、来源文字大小我全部抽取为dimen namespacing_card12dp/dimen、dimen nametext_size_title17sp/dimen这样的命名。后面做屏幕适配时只需要在values-sw360dp、values-sw400dp等目录下重写这些 dimens 即可。strings 资源也要提前规划。虽然仿今日头条暂时不做多语言但把文案写在布局里后面一旦要国际化成英语、阿拉伯语布局文件会被改得面目全非。把加载中没有更多了网络异常点击重试这些文案抽到 strings 里是成本最低的国际化准备。2.4 多屏幕尺寸与 .9 图适配Android 碎片化是绕不开的。仿今日头条项目在平板上、全面屏手机上要表现一致资源适配就得做在前面。我建议在res下创建不同values-swNdp目录来适配屏幕宽度。例如values-sw360dp适合绝大多数手机values-sw400dp适合大屏手机和平板这些目录中的 dimens 文件可以覆盖默认值。比如默认spacing_card12dp到了平板可以调成24dp列表卡片在平板上不会因为太窄而显得局促。对于需要拉伸的背景图片比如底部弹窗的圆角背景、聊天气泡、按钮背景一定要用.9.png或者直接用 shape 资源。VectorDrawable 和 shape 在 Android 5.0 之后都能完美缩放所以现代项目里 .9 图的需求已经大大减少更多是历史遗留资源需要维护。我的建议很简单新资源一律用 vector 或 shape不要再用位图切图。3. 首页布局逐层拆解从根容器到列表 item3.1 根界面CoordinatorLayout AppBarLayout TabLayout ViewPager2仿今日头条的首页结构最核心的是顶部频道 Tab 和下方内容列表。很多人的第一反应是写一个 LinearLayout上面放 TabLayout下面放 ViewPager2。这种做法有一个问题当用户滑动列表时顶栏不会跟随滚动也没有联动效果。我采用的是CoordinatorLayout作为根布局内部放AppBarLayout和ViewPager2AppBarLayout 中再放Toolbar和TabLayout。结构大致如下androidx.coordinatorlayout.widget.CoordinatorLayout android:layout_widthmatch_parent android:layout_heightmatch_parent com.google.android.material.appbar.AppBarLayout android:layout_widthmatch_parent android:layout_heightwrap_content androidx.appcompat.widget.Toolbar android:layout_widthmatch_parent android:layout_height?attr/actionBarSize / com.google.android.material.tabs.TabLayout android:idid/tab_layout android:layout_widthmatch_parent android:layout_height48dp app:tabModescrollable app:tabGravitystart / /com.google.android.material.appbar.AppBarLayout androidx.viewpager2.widget.ViewPager2 android:idid/view_pager android:layout_widthmatch_parent android:layout_heightmatch_parent app:layout_behaviorstring/appbar_scrolling_view_behavior / /androidx.coordinatorlayout.widget.CoordinatorLayout这里最关键的代码是app:layout_behaviorstring/appbar_scrolling_view_behavior。它告诉 CoordinatorLayoutViewPager2 应该根据 AppBarLayout 的折叠状态自动调整位置这样列表往上滑时顶栏可以被推出去往下滑时顶栏会回来。头条首页其实没有做强折叠效果但这个结构为后面扩展CollapsingToolbarLayout或者下拉大图 banner 留了空间。在动手写布局之前我建议打开 Android Studio 的 Layout Validation 工具预览多个屏幕尺寸。你会发现 ViewPager2 的高度、TabLayout 的间距在不同屏幕上差异不小提前调整 dimens 比上线后再适配省事得多。3.2 新闻列表 item 布局设计新闻列表是仿今日头条的灵魂。item 通常有几种常见结构单图右侧小图、三图下方三张图、大图顶部一张大图、纯文字。我以最常见的左侧标题 右侧封面图为例子分析布局的写法。androidx.constraintlayout.widget.ConstraintLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:padding12dp TextView android:idid/tv_title android:layout_width0dp android:layout_heightwrap_content android:textstring/title_placeholder android:textColorcolor/text_primary android:textSizedimen/text_size_title android:maxLines2 android:ellipsizeend app:layout_constraintTop_toTopOfparent app:layout_constraintEnd_toStartOfid/iv_cover app:layout_constraintStart_toStartOfparent / ImageView android:idid/iv_cover android:layout_width120dp android:layout_height80dp android:scaleTypecenterCrop app:layout_constraintTop_toTopOfparent app:layout_constraintEnd_toEndOfparent / TextView android:idid/tv_source android:layout_width0dp android:layout_heightwrap_content ... app:layout_constraintBottom_toBottomOfparent app:layout_constraintStart_toStartOfparent / /androidx.constraintlayout.widget.ConstraintLayout这个布局的关键是标题和图片的约束关系。标题宽度设为0dp并让它的 end 约束到图片的 start这样标题不会和图片重叠。封面图用固定宽高scaleTypecenterCrop保证图片无论后端返回什么比例都能裁剪成统一尺寸。图片的 URL 通过 Glide 加载到iv_cover时Glide 的override(120, 80)配合centerCrop()正好和布局尺寸匹配不会产生不必要的内存开销。这里我踩过一个坑RecyclerView item 的根布局宽高如果写成wrap_content且内部存在图片高度不确定的情况item 高度会忽高忽低滑动时视觉会跳。更稳的做法是根布局宽度 match_parent高度 wrap_content并且内部所有控件高度尽量由内容和约束决定不要依赖图片原始高度。3.3 Banner 轮播与 ViewPager2 的布局细节首页每个频道顶部经常放 Banner 轮播图。头条首页的 Banner 一般是一张大图下面几个小圆点指示器。布局上可以用ViewPager2加一个水平LinearLayout做指示器。实现轮播时最容易遇到的问题是 ViewPager2 的高度自适应。直接用wrap_content有时会拿到 0 高度我习惯给外层套一个固定高度的容器比如160dp或者重写onMeasure根据第一页图片比例计算高度。对于仿头条项目固定高度最简单也符合实际场景。指示器的小圆点用两个 drawable 切换即可。选中态是一个橙色圆点未选中态是灰色圆点!-- indicator_selected.xml -- shape xmlns:androidhttp://schemas.android.com/apk/res/android android:shapeoval solid android:colorcolor/colorPrimary / size android:width6dp android:height6dp / /shape !-- indicator_normal.xml -- shape xmlns:androidhttp://schemas.android.com/apk/res/android android:shapeoval solid android:color#80FFFFFF / size android:width6dp android:height6dp / /shape然后在 Activity/Fragment 中根据当前页位置动态切换圆点的 background。画面上 Banner 处于第一屏时指示器圆点在深色图片上要能看清楚所以未选中态用半透明白色选中态用橙色对比度足够。这也是改一个 drawable 就能完成的细节。3.4 流式标签布局频道管理和历史标签的实现仿今日头条的频道管理和搜索历史记录标签都是流式布局——标签从左到右排列排满一行自动换行。Android 自带的布局没有直接提供 FlowLayout所以这个项目里我实现了两种方案。方案一是使用 Google 的 FlexboxLayout 库在 build.gradle 中添加依赖implementation com.google.android.flexbox:flexbox:3.0.0然后在 RecyclerView 中设置 FlexboxLayoutManager。Flexbox 支持flexWrap FlexWrap.WRAP几行代码就能实现流式排列适合快速开发。方案二是自定义一个轻量 FlowLayout适合场景简单、不想引入额外依赖的情况。核心代码不复杂主要是在onMeasure和onLayout中计算每行子 View 的位置class FlowLayout JvmOverloads constructor( context: Context, attrs: AttributeSet? null ) : ViewGroup(context, attrs) { override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { var left paddingLeft var top paddingTop var lineHeight 0 for (i in 0 until childCount) { val child getChildAt(i) if (child.visibility GONE) continue val childWidth child.measuredWidth val childHeight child.measuredHeight if (left childWidth width - paddingRight) { left paddingLeft top lineHeight lineHeight 0 } child.layout(left, top, left childWidth, top childHeight) left childWidth horizontalSpacing lineHeight max(lineHeight, childHeight) } } }自定义 FlowLayout 的难点在于换行逻辑和间距计算不过只要你把onMeasure和onLayout两个方法写对后续扩展标签多选标签删除都会很方便。头条的频道管理中每个频道的拖动排序用的是 RecyclerView ItemTouchHelper和 FlowLayout 没有直接关系但标签的展示样式可以复用这套资源。4. Adapter 与多类型 item 渲染复用、DiffUtil 与分页4.1 自己封装 BaseAdapter还是直接用开源框架做仿头条项目之前我先确定了一个问题直接用 BRVAH 这类开源适配器框架还是自己封装一个 BaseAdapter。我的建议是练手项目一定要自己封装一次哪怕只封装一个支持多类型 item 的最小基类。原因很简单BRVAH 用起来方便但它把 ViewHolder 的创建、绑定、多类型分发都封装在框架内部出了问题你很难快速定位也不利于理解 RecyclerView 的运行机制。我自己封装了一个非常薄的基类思路是BaseAdapterT, VH : RecyclerView.ViewHolder持有数据源MutableListT抽象方法getViewHolder(parent, viewType)负责 inflate 布局抽象方法bindViewHolder(holder, item, position)负责绑定数据通过getItemViewType区分多类型核心代码简化后如下abstract class BaseAdapterT, VH : RecyclerView.ViewHolder( private val items: MutableListT ) : RecyclerView.AdapterVH() { override fun getItemCount() items.size fun submitData(newItems: ListT) { items.clear() items.addAll(newItems) notifyDataSetChanged() } }这个基类虽然简单但让后续所有页面的 Adapter 都保持了统一结构。像头条首页不同的频道 Fragment虽然数据源和 item 布局不同但它们都能复用这套 Adapter 的公共能力比如空状态、加载更多、点击事件。4.2 getItemViewType多类型 item 的正确姿势头条新闻列表最大的特殊之处是多类型 item。同一列表里既有单图新闻又有三图新闻还有大图新闻和广告卡片。如果在onBindViewHolder里写一大堆 if else 判断代码会非常难维护。我的做法是定义一个NewsItemType枚举来表示 item 类型然后在getItemViewType里根据数据返回对应的 viewTypeenum class NewsItemType(val type: Int) { SINGLE_IMAGE(0), THREE_IMAGE(1), BIG_IMAGE(2), NO_IMAGE(3), AD(4) }override fun getItemViewType(position: Int): Int { return when (newsList[position].type) { single - NewsItemType.SINGLE_IMAGE.type three - NewsItemType.THREE_IMAGE.type big - NewsItemType.BIG_IMAGE.type ad - NewsItemType.AD.type else - NewsItemType.NO_IMAGE.type } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { NewsItemType.SINGLE_IMAGE.type - SingleImageHolder( LayoutInflater.from(parent.context) .inflate(R.layout.item_news_single_image, parent, false) ) NewsItemType.THREE_IMAGE.type - ThreeImageHolder( LayoutInflater.from(parent.context) .inflate(R.layout.item_news_three_image, parent, false) ) else - NoImageHolder( LayoutInflater.from(parent.context) .inflate(R.layout.item_news_no_image, parent, false) ) } }ViewHolder 子类各自持有 item 中需要赋值的控件onBindViewHolder里通过when分发到对应的 ViewHolder 去绑定数据。这样新增一种 item 类型只需要增加一个 viewType 和一个 ViewHolder 子类不会污染已有逻辑。需要注意的一点是viewType 必须从 0 开始且不能复用同一个 type否则 RecyclerView 会复用错误类型的 ViewHolder导致布局错乱。4.3 ViewHolder 复用细节图片错乱和状态残留ViewHolder 复用是 RecyclerView 高效率的基石但也是 Bug 高发地。仿头条列表最常见的 Bug 就是图片错乱快速滑动时A 位置的图片突然出现在 B 位置。根因是图片异步加载Glide 返回结果时交给 ImageView 的时候item 已经被复用到新的位置了。解决这个问题我用两个手段。第一正确使用 Glide所有加载图片的地方都调用Glide.with(context).load(url).into(imageView)Glide 内部会为每个 ImageView 设置一个 tag异步返回时会检查 tag 对应关系避免旧图片设置到新复用的控件上。第二在onBindViewHolder中主动清理状态。比如imageView.setImageDrawable(null)、textView.text 尤其像标签这种有默认背景色的控件如果不清空复用时可能残留上一个 item 的内容。另外有一个很容易忽略的问题onBindViewHolder里不要做耗时操作比如解析 JSON、创建大对象。新闻列表每秒钟可能绑定几十个 item任何一个findViewById或者对象创建都会拖慢绑定速度。ViewHolder 持有的控件引用是复用的不需要重新 find。图片的加载则完全交给 Glide 的缓存和异步线程主线程绑定只需要设置 URL。4.4 DiffUtil下拉刷新不闪屏的关键仿今日头条列表有下拉刷新刷新后数据内容可能只有一两处变化。如果直接notifyDataSetChanged()整个列表会重新绑定一遍用户能看到明显的闪烁和跳动尤其当列表在顶部时下拉刷新会让列表突然回到顶部。用 DiffUtil 计算新旧数据集的差异可以实现局部刷新。DiffUtil 是一个官方工具类核心是重写四个方法class NewsDiffCallback( private val oldList: ListNewsItem, private val newList: ListNewsItem ) : DiffUtil.Callback() { override fun getOldListSize(): Int oldList.size override fun getNewListSize(): Int newList.size override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { return oldList[oldItemPosition].id newList[newItemPosition].id } override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { return oldList[oldItemPosition] newList[newItemPosition] } }areItemsTheSame判断两条数据是不是同一条新闻通常用 id 比较areContentsTheSame判断同一条新闻内容是否变化包括标题、摘要、阅读数这些。运行 DiffUtil 的计算可以在后台线程执行但数据量不大时主线程也能接受。结果通过DiffUtil.DiffResult.dispatchUpdatesTo(adapter)应用RecyclerView 会精准增删改 item并自动执行 item 动画。还有一点DiffUtil 和 AsyncListDiffer 官方也提供了封装如果你用 ListAdapter 而不是 RecyclerView.Adapter可以省掉手动加 DiffUtil 的模板代码。相比之下我在仿头条里手动维护 BaseAdapter 更灵活因为列表还要处理加载更多 footer 这种额外 viewType。4.5 上拉加载更多与 footer 状态管理资讯列表的分页加载是刚需。我仿头条项目里没有用现成的刷新加载框架而是自己在 Adapter 里加了一个 footer viewType。这样列表底部在加载时显示加载中失败时显示点击重试没有更多时显示已经到底了。实现思路是数据源MutableListNewsItem里额外放入一个 footer 占位对象或者用独立的footerStatus字段控制。getItemViewType(位置)判断当前位置是不是 footer。onCreateViewHolder 根据 viewType 分别创建内容 item 和 footer item 的 ViewHolder。RecyclerView 的滑动监听判断是否滚动到底部触发加载下一页。sealed class ListStatus { object LoadingMore : ListStatus() data class Error(val message: String) : ListStatus() object NoMore : ListStatus() }滑动监听是加载更多的核心。RecyclerView 的OnScrollListener在onScrolled里计算val lastVisiblePosition layoutManager.findLastVisibleItemPosition() val totalCount layoutManager.itemCount if (lastVisiblePosition totalCount - 3 status NORMAL) { loadMore() }当滚动到最后倒数第 3 个 item 时提前触发加载用户体验较好。这里要避免重复触发所以需要一个状态判断。每次新数据加载完成后把 footer 状态从加载更多改回正常并调用notifyItemInserted插入新数据同时复用旧的 id 比较逻辑确保不会出现重复新闻。这种手动 footer 的方式比单独放一个底部 View 更自然因为列表滚动到底部时 footer 会跟着一起滚上去不会出现footer 固定在页面底部的怪异效果。5. 列表滑动性能与内存优化仿头条项目实测经验5.1 布局层级排查include、merge、ViewStub 怎么用仿今日头条首页性能优化我最先检查的是布局层级。布局嵌套过深会导致测量和绘制阶段变慢尤其是列表 item每个 item 都在主线程上 measure层级多就会掉帧。Android Studio 自带的 Layout Inspector 工具可以直接查看当前页面的视图树。我在重构后发现三图 item 最初嵌套了 4 层每层都用 LinearLayout导致 measure 过程非常慢。后来把外层换成 ConstraintLayout内层用include和merge组合减少层级。include用于复用布局比如新闻卡片底部的来源栏单图、三图、大图 item 都会用到抽出来通过 include 引入。merge则是配合 include 使用去掉多余根节点。比如来源栏如果是一个 LinearLayoutinclude 后会自动叠加一层如果 include 的布局根节点是merge就不会产生额外层级。ViewStub适合布局中默认不可见但后续可能显示的区域。比如新闻详情页的相关推荐模块一开始不加载滚动到特定位置时才显示。用 ViewStub 占位真正需要时再 inflate可以明显缩短首页的启动耗时。5.2 Glide 图片加载的细节配置Glide 是列表性能的大头。仿头条列表图片多如果在低端机上没有做好图片加载策略内存会快速上涨。我全局配置 Glide 有三个关键点。第一统一 RequestOptionsval options RequestOptions() .placeholder(R.drawable.placeholder_image) .error(R.drawable.error_image) .override(300, 200) .centerCrop() .diskCacheStrategy(DiskCacheStrategy.ALL)override(300, 200)不是随便写的。它需要和 item 中 ImageView 的显示尺寸匹配图片加载时 Glide 会把超大原图缩放到这个尺寸很大程度上避免内存溢出。第二列表滑动时暂停加载停止时恢复recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { if (newState RecyclerView.SCROLL_STATE_IDLE) { Glide.with(context).resumeRequests() } else { Glide.with(context).pauseRequests() } } })这个操作是手动优化实际项目里如果用了 Glide 的with(activity/fragment)在页面销毁时会自动处理生命周期但列表滑动暂停可以进一步降低 CPU 和内存压力。第三封面图使用 WebP 或 JPEG 时注意格式Glide 默认支持 WebP。如果 news 列表封面图体积过大跟后端约定返回压缩后的图片同时用override限制采样。5.3 RecyclerView 的这些设置能省不少事RecyclerView 本身有一些设置项对列表性能影响不小。setHasFixedSize(true)表示 item 的数量变化不会影响 RecyclerView 的宽高尺寸可以跳过重新 measure 的步骤。如果列表内容固定高度建议加上。itemAnimator默认是动画实现的如果列表频繁刷新导致闪烁可以改成DefaultItemAnimator并关闭默认动画或者在 DiffUtil 更新时临时禁用动画。头条这种资讯列表用户看重的是快速加载动画稍慢体验反而更差。RecycledViewPool可以在不同 RecyclerView 之间复用 ViewHolder。比如首页多个频道的 ViewPager2 都有列表每个列表都有相同类型的新闻 item如果创建多个 RecyclerView 但共享同一个RecycledViewPool可以减少重复创建 ViewHolder 的过程。这个优化在列表数量多时收益明显。setItemViewCacheSize默认是 2如果列表需要快速回滚可以适当调大比如 5。它和 RecycledViewPool 的区别是缓存池里存的是脱离屏幕的 ViewHolder这里存的是待回收的 ViewHolder两者配合使用效果更好。5.4 内存泄漏自查这些坑我踩过一次就不再犯仿头条项目页面多Fragment 来回切换内存泄漏很容易发生。我排查泄漏主要用 LeakCanary 和 Android Profiler。泄漏场景原因对策Activity 泄漏Glide 加载时传入 Activity 上下文且长时间任务未结束使用Glide.with(fragment)或Glide.with(applicationContext)及时取消Fragment 泄漏Adapter 持有 Fragment 实例Fragment 销毁后 Adapter 未被清理在 Fragment onDestroyView 中把 RecyclerView 的 adapter 置空Handler 泄漏内部 Handler postDelayed 且页面销毁后未移除在 onDestroy 中 removeCallbacksAndMessages回调泄漏网络请求回调持有外层 Activity 引用使用生命周期感知组件或在 onStop 中取消请求头条首页每个频道 Fragment 都会创建自己的 Adapter如果不及时清理切到下一个频道时前一个 Fragment 的 RecyclerView 还被 ViewPager2 缓存Adapter 里的回调继续持有 Fragment页面无法释放。我在 onDestroyView 中统一做清理列表速度快也不卡。有一个最容易被忽略的点Drawable中的setCallback如果持有 Activity也会造成泄漏。我在列表 item 里避免使用带动画的 VectorDrawable 作为背景因为动画在列表滑动时还会触发反复绘制对性能也不友好。写在最后的一点体会如果让我再从头写一遍仿今日头条项目我会在第一天就把资源规范定好把布局层级控制在 3 层以内把多类型 item 的 Adapter 框架搭好。资源、布局、Adapter 这三层看似独立实际上互相影响。资源命名规范了布局文件才不会依赖硬编码布局层级低了Adapter 绑定才能跑得更快Adapter 结构清晰了后面加广告位、加新频道都只是一次新增 viewType 的事情。我重构这个项目的另一个体会是不要一开始追求把所有功能都做完。头条有频道管理、评论、视频模块但核心是图文列表。先把列表的图文混排、下拉刷新、上拉加载、图片缓存做到流畅再谈扩展。很多时候性能问题不是某个库的问题而是从资源到布局再到 Adapter 每一层都差一点累积起来就让列表变得卡顿。按这个顺序逐层检查问题往往比自己想的简单。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →