动态 | 学习笔记

133 阅读11分钟

继上一篇笔记,继续实现一个简单的用户动态页面,可以浏览、发布动态。

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,将被迫完全重新绑定和重新布局所有可见视图,影响滚动性能:

image.png

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对象,再次发送,便完成了动态的发布。