继上一篇笔记,继续实现一个简单的用户动态页面,可以浏览、发布动态。
1 分页加载策略
现在我们要为动态页面实现一种灵活的分页加载策略:
- 当用户慢慢往下滑,滑到当前分页的一半时,完成下一页的预加载。滑动到两个分页的连接处时,不会显示“正在加载”动画。将这个需求称为“预加载”。
- 如果用户不间断快速下滑,滑到当前分页最后一项时,则需要显示“正在加载”并完成数据加载。将这个需求称为“手动加载”。
因为使用了两种加载方式,为了避免两者同时触发,我们还需要一些优化手段,比如手动“加锁”等。
1.1 SmartRefreshLayout
首先来看下要使用的第三方库,SmartRefreshLayout。这里使用它完成下拉刷新,以及快速滚动到最后一项时加载下一页:
// 设置下拉刷新监听器
binding.refresh.setOnRefreshListener(refreshLayout -> loadData());
// 设置上拉加载更多监听器
binding.refresh.setOnLoadMoreListener(refreshLayout -> {
// 标记为手动触发的加载(快速滑动时)
isAutoLoading = false;
loadMore();
});
其实这两个监听器在这里主要就两个功能:显示动画,回调时调用loadData()或loadMore()。
对于setOnRefreshListener,顾名思义下拉刷新监听器,它拥有回调方法onLoadMore。它监听顶部下拉行为并调用loadData()刷新数据,数据加载完成后在Rxjava的onSucceeded里调用finishRefresh结束“下拉刷新”动画:
binding.refresh.finishRefresh(2000, true, false);
finishRefresh:
public RefreshLayout finishRefresh(final int delayed, final boolean success, final Boolean noMoreData) {...}
对于setOnLoadMoreListener,顾名思义加载更多监听器,在滑动到列表(RecyclerView)最后一项时回调onLoadMore:
refreshLayout -> {
// 标记为手动触发的加载(快速滑动时)
isAutoLoading = false;
loadMore();
}
它配对的结束方法(delayed为动画加载时机):
public RefreshLayout finishLoadMore(final int delayed, final boolean success, final boolean noMoreData) {...}
1.2 预加载
这里需要实现的是,用户慢速下滑过程中,滚动到总item数的倒数第5项以内时,实现下一页的预加载,并取消“正在加载”动画。
上面提到setOnLoadMoreListener的触发时机是,滑动到列表(RecyclerView)最后一项时触发。所以我们这里不能使用它,而是使用RecyclerView的OnScrollListener,它可以监听RecyclerView的滚动状态。它有一个回调方法onScrolled,当滚动停止时被调用。用户慢慢往下滑动时,总会有停下来的时刻,我们就在这时计算当前屏幕最下面的动态,是否属于总item数(RecyclerView从Adapter加载的动态数)的倒数第5项以内,也就是预加载的阈值,是的话触发预加载:
binding.list.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
assert layoutManager != null;
int lastVisible = layoutManager.findLastCompletelyVisibleItemPosition();
int total = adapter.getItemCount();
// 当滑动到倒数第5项时自动触发预加载
if (dy > 0 && !isLoading && (total - lastVisible) <= 5) {
isAutoLoading = true; // 标记为自动加载
loadMore();
}
}
});
比如,假设后端返回的第一个分页有10条动态,用户滚动到第6条时,total - lastVisible:10 - 6 = 4 < 5,触发预加载第二条分页,total = adapter.getItemCount()变为20。20 - 6 = 14 > 5,即便用户在第6条动态不断徘徊,也不会多次触发预加载。
注意到这里有两个自定义变量,isLoading和isAutoLoading。isAutoLoading是预加载的标记,触发预加载时用来取消“正在加载”动画。isLoading是自定义的锁,把loadMore锁住,防止多个加载线程同时调用loadMore,以及防止网络延迟大时上一个加载还未完成,下一个就进入loadMore。
1.3 手动加载
手动加载严格来说就是触发setOnLoadMoreListener,当用户滚动到RecyclerView的最后一项时就会触发OnLoadMoreListener。可以理解为用户不停下滑屏幕,此时无法触发onScrolled,也就无法完成预加载,到最后一项时就会显示“正在加载”,并触发loadMore:
binding.refresh.setOnLoadMoreListener(refreshLayout -> {
// 标记为手动触发的加载(快速滑动时)
isAutoLoading = false;
loadMore();
});
如果用户在第一分页完成了预加载,滚动到第二个分页衔接处时也不会触发OnLoadMoreListener,因为RecyclerView的最后一项变成了第二个分页的最后一项。
1.4 数据加载
数据加载模型为:
private void loadData() {
isRefresh = true;
pageMeta = null;
loadMore();
}
private void loadMore() {...}
下拉刷新时会调用loadData,标记isRefresh为true,清空分页元数据pageMeta接收新的数据。
上面所说的预加载和手动加载调用loadMore:
private void loadMore() {
// 500毫秒内不重复加载
long MIN_LOAD_INTERVAL = 500;
if (isLoading || System.currentTimeMillis() - lastLoadTime < MIN_LOAD_INTERVAL)
return;
lastLoadTime = System.currentTimeMillis();
// 加锁
isLoading = true;
HashMap<String, String> param = new HashMap<>();
// 添加分页
param.put(Constant.PAGE, String.valueOf(Meta.nextPage(pageMeta)));
// 如果是查看用户个人主页动态,只显示用户个人的动态
if (StringUtils.isNotBlank(userId)) {
param.put(Constant.USER_ID, userId);
}
DefaultRepository.getInstance()
.feeds(param)
.subscribe(new HttpObserver<ListResponse<Feed>>() {
@Override
public void onSucceeded(ListResponse<Feed> data) {
// 解锁
isLoading = false;
pageMeta = data.getData();
// 结束下拉刷新动画
binding.refresh.finishRefresh(2000, true, false);
// 如果是自动触发的预加载,隐藏加载动画
if (isAutoLoading) {
binding.refresh.finishLoadMore(0, true, pageMeta.getNext() == null);
isAutoLoading = false;
} else {
// 手动加载,显示加载动画
binding.refresh.finishLoadMore(500, true, pageMeta.getNext() == null);
}
if (isRefresh) {
isRefresh = false;
// 替换为新的引用
adapter.setNewInstance(data.getData().getData());
} else {
// 追加新的分页
// 获取当前数据
List<Feed> newData = new ArrayList<>(adapter.getData());
// 追加新分页数据
newData.addAll(data.getData().getData());
// 使用合并后的数据更新
adapter.setDiffNewData(newData);
}
}
@Override
public void onError(Throwable e) {
super.onError(e);
isLoading = false;
isAutoLoading = false;
binding.refresh.finishLoadMore(false);
}
});
}
通过isLoading加锁和500毫秒内不重复加载,可以防止多个线程同时进入loadMore。
2 Adapter
如何使动态页面快速滚动时保持流畅呢?这里嵌套了两个RecyclerView,第一层动态页面的,第二层是每个动态的图片列表。所以这里有两个Adapter,FeedAdapter和ImageAdapter。我们先来看第一个。
FeedAdapter
这个Adapter里,主要的优化方式为缓存布局管理器和ImageAdapter,使用DiffUtil防止全量刷新,等等。
首先把嵌套滚动和边缘效果都禁用了,避免内外层滚动冲突,提升滑动流畅性:
// 禁止嵌套滚动
mediaList.setNestedScrollingEnabled(false);
// 禁用边缘效果
mediaList.setOverScrollMode(View.OVER_SCROLL_NEVER);
接着,把GridLayoutManager和ImageAdapter缓存起来,避免每次调用convert都新建这两个对象,影响性能。使用SparseArray缓存池,缓存不同列数(spanCount)的布局管理器和适配器。
// 布局管理器和适配器缓存池(解决嵌套RecyclerView复用问题)
private final SparseArray<GridLayoutManager> layoutManagerCache = new SparseArray<>();
private final SparseArray<ImageAdapter> adapterCache = new SparseArray<>();
根据图片数量计算列数后,优先从缓存获取对象,避免重复创建。从缓存获取或创建布局管理器:
// 从缓存获取或创建布局管理器
GridLayoutManager layoutManager = layoutManagerCache.get(spanCount);
if (layoutManager == null) {
layoutManager = new GridLayoutManager(appContext, spanCount);
layoutManagerCache.put(spanCount, layoutManager);
}
// 检查现有布局管理器是否匹配
if (listView.getLayoutManager() != layoutManager) {
listView.setLayoutManager(layoutManager);
// 移除旧分割线
while (listView.getItemDecorationCount() > 0) {
listView.removeItemDecorationAt(0);
}
// 添加新分割线
listView.addItemDecoration(createDivider(spanCount));
}
从缓存获取或创建适配器:
// 从缓存获取或创建适配器
ImageAdapter adapter = adapterCache.get(spanCount);
if (adapter == null) {
adapter = new ImageAdapter(R.layout.item_image);
adapterCache.put(spanCount, adapter);
}
// 绑定适配器
if (listView.getAdapter() != adapter) {
listView.setAdapter(adapter);
}
最后是DiffUtil。在动态页面Fragment中,为adapter加载数据分为下拉刷新和加载下一页两种情况,如果为下一页加载数据使用adapter.addData(data.getData().getData()),会导致调用notifyDataSetChanged,将被迫完全重新绑定和重新布局所有可见视图,影响滚动性能:
DiffUtil使用Eugene W. Myers的差异算法,以最小时间复杂度(接近线性时间)找到两个列表之间的差异,比较新旧数据集,生成 DiffResult。通过DiffResult.dispatchUpdatesTo(adapter) 触发局部更新,而非全局刷新。
在这里,我们重写它的回调方法:
首先判断两个Feed对象是否代表同一个逻辑项(同一条动态)。使用唯一标识符(数据库主键id)进行比较:
/**
* 判断是否为同一数据项
*/
@Override
public boolean areItemsTheSame(@NonNull Feed oldItem, @NonNull Feed newItem) {
return oldItem.getId().equals(newItem.getId());
}
判断两个Feed的内容否完全相同。当areItemsTheSame()返回true时才会调用此方法,若返回false,则触发该位置的局部更新:
/**
* 判断内容是否相同
*/
@Override
public boolean areContentsTheSame(@NonNull Feed oldItem, @NonNull Feed newItem) {
return Objects.equals(oldItem.getContent(), newItem.getContent())
&& Objects.equals(oldItem.getMedias(), newItem.getMedias());
}
ImageAdapter
这个Adapter负责为每条动态的嵌套RecyclerView加载动态图片,也可以为发表动态的Avtivity加载要发表的图片。
这里的优化主要针对于Glide。
固定图片尺寸:将图片加载尺寸固定为120dp对应的像素值,避免Glide动态计算图片尺寸,减少测量开销。可以防止原始大图(如4000x3000)直接解码到内存,降低内存峰值。统一所有图片的显示尺寸后,减少 GPU渲染时的波动:
imageSize = (int) (Resources.getSystem().getDisplayMetrics().density * 120);
统一Glide的配置管理:使用统一的Glide配置,避免每次加载重复创建配置对象(减少GC压力);打开智能选择缓存策略DiskCacheStrategy.AUTOMATIC;使用统一的占位图尺寸,override(300,300),提前固定布局尺寸,防止图片加载时布局抖动:
private static final RequestOptions DEFAULT_OPTIONS = new RequestOptions()
.placeholder(R.drawable.placeholder)
.error(R.drawable.placeholder_error)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.override(300, 300);
生命周期管理
在视图被回收时主动取消Glide请求,防止异步加载完成后更新已回收的View,同时清空ImageView的Drawable引用,帮助GC及时回收Bitmap内存:
@Override
public void onViewRecycled(@NonNull BaseViewHolder holder) {
super.onViewRecycled(holder);
ImageView imageView = holder.getView(R.id.icon);
// 主动取消Glide请求
Glide.with(imageView).clear(imageView);
imageView.setImageDrawable(null);
}
在视图离开屏幕时立即终止加载,减少无效网络/磁盘IO:
@Override
public void onViewDetachedFromWindow(@NonNull BaseViewHolder holder) {
super.onViewDetachedFromWindow(holder);
ImageView imageView = holder.getView(R.id.icon);
Glide.with(imageView).clear(imageView);
}
3 发布动态
点击动态页面上的“+”号,就跳转到发布页面的Activity。主要由一个文本框,和显示候选发布图片的RecyclerView组成。
这里的关键步骤是,从相册选择图片并处理,然后上传图片到服务器,从后端返回图片的url后,再和文本封装在一起发布出去。
3.1 图片选择
这里使用第三方库PictureSelector,它可以从相册选择图片或拍照,也可以多选、预览、裁剪等。
用户点击“+”后,调用PictureSelector选择图片,接着使用Luban进行压缩,Luban是腾讯开源的图片压缩库,压缩率高且质量损失小。压缩后的图片存储在应用缓存目录,压缩后的路径(compressPath)封装到LocalMedia对象,当然还包括原始路径等等。
这一系列动作完成后,PictureSelector返回一个LocalMedia列表,把这个列表加载到显示候选图片的RecyclerView的Adapter中,就可以显示图片了。接着点击发送,完成上传任务。
3.2 文件上传
现在我们要将用户刚刚选择的图片上传到后端。
刚刚把LocalMedia列表加载到Adaper中,现在把它取出来:
private List<LocalMedia> getSelectedImages() {
List<Object> data = adapter.getData();
List<LocalMedia> datum = new ArrayList<>();
for (Object o : data) {
if (o instanceof LocalMedia) {
datum.add((LocalMedia) o);
}
}
return datum;
}
接着,将本地图片文件(LocalMedia对象)封装在MultipartBody,才能通过HTTP上传。
先来看几个概念:
-
MultipartBody是OkHttp中RequestBody的子类,专用于构建符合multipart/form-data格式的复合请求体,它可以实现HTTP中多文件上传,或混合文件与文本的表单提交。MultipartBody聚合多个MultipartBody.Part,形成完整的复合请求体。
-
MultipartBody.Part是MultipartBody的子部分(Part),表示一个独立的表单字段,比如一个文件或一个文本。它由字段名name、文件名filename,文件/文本RequestBody组成:
// 创建文件字段 Part
MultipartBody.Part filePart = MultipartBody.Part.createFormData(
"file", // 字段名(与服务端参数名一致)
"image.jpg", // 文件名
fileBody // RequestBody(文件数据)
);
// 创建文本字段 Part
MultipartBody.Part textPart = MultipartBody.Part.createFormData(
"description", // 字段名
null, // 文件名(文本无需文件名)
textBody // RequestBody(文本数据)
);
- RequestBody是OkHttp 中表示 HTTP 请求体(Request Body)的基类,用于封装任意类型的单一数据实体(如文本、文件、JSON 等),可以作为普通POST/PUT请求的请求体(如发送 JSON、文件上传),也可以作为 MultipartBody.Part的数据源。 它由数据内容(通过writeTo(BufferedSink)方法序列化,和数据的媒体类型(Media Type)组成:
// 发送纯文本
RequestBody textBody = RequestBody.create("Hello", MediaType.get("text/plain"));
// 发送文件
File file = new File("image.jpg");
RequestBody fileBody = RequestBody.create(file, MediaType.get("image/jpeg"));
现在,我们要把每一个LocalMedia封装到RequestBody,再把RequestBody封装到MultipartBody.Part,Retrofit的@Multipart注解会将所有标记为@Part的参数自动合并为一个完整的MultipartBody:
创建文件的RequestBody,再RequestBody封装为MultipartBody.Part:
ArrayList<MultipartBody.Part> parts = new ArrayList<>();
for (LocalMedia it : datum) {
File file = new File(it.getCompressPath());
RequestBody fileBody = RequestBody.Companion.create(file, MediaType.parse("image/*"));
MultipartBody.Part filePart = MultipartBody.Part.createFormData(
"file", // 字段名
file.getName(), // 文件名
fileBody // RequestBody(文件数据)
);
parts.add(filePart);
}
将所有Part添加到请求中,发送到服务端:
@Multipart
@POST("files/upload")
Observable<ListResponse<String>> uploadFiles(@Part List<MultipartBody.Part> file);
从后端返回图片的url后,把它们封装到Feed对象,再次发送,便完成了动态的发布。