• 【从零开始撸一个App】RecyclerView的使用


    目标

    前段时间打造了一款简单易用功能全面的图片上传组件,现在就来将上传的图片以图片集的形式展现到App上。出于用户体验考虑,加载新图片采用[无限]滚动模式,Android平台上我们优选RecyclerView组件。

    显示图片,用的自然是ImageView,然而它并不支持直接加载网络图片,需要先通过其它网络组件(如HttpURLConnectionokhttp3等)将图片获取到本地,得到BitMap数据,然后通过setImageBitmap()加载。
    ImageView也有setImageURI(Uri uri)方法,这里uri的命名容易让人产生错觉,其实只能是本地文件路径。

    所幸,一些开源组件封装了繁琐的网络操作和缓存策略,提供了易用的API。这里我选择了Glide

    实现

    加载更多

    项布局

    有两个,一个用于列表中各个图片显示,一个显示加载更多/已全部加载放置在列表最末提示用户。

    <!--图片-->
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
    
        <ImageView
            android:id="@+id/thumbnail_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:scaleType="centerCrop"/>
    </LinearLayout>
    
    <!--loadmore-->
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center">
    
        <TextView
            android:id="@+id/tv_load_more"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="正在加载更多" />
    </LinearLayout>
    

    RecyclerView.Adapter

    RecyclerView的设计模式网上资料很多,此处不再赘述。先实现RecyclerView.Adapter

    class ThumbnailListAdapter(
        private val thumbnails: List<Thumbnail>,
        private val totalCount: Long,
        private val context: Context
    ) :
        RecyclerView.Adapter<ThumbnailListAdapter.ThumbnailViewHolder>() {
    
        // 调用若干次
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThumbnailViewHolder {
            // viewType就是通过getItemViewType得到的
            val itemView = LayoutInflater.from(context).inflate(viewType, parent, false)
            return ThumbnailViewHolder(itemView)
        }
    
        // 搞分页/瀑布加载的同学不要把这个和数据库的总数量搞混,这里的itemCount表示现在内存中数据量
        // 我们可以[从后端]获取新数据添加到数据集,以实现loadmore功能
        override fun getItemCount(): Int {
            return if (thumbnails.isNotEmpty())
                thumbnails.size + 1 // +1 是因为除了thumbnails数据集之外,还有个写死的loadmore项
            else
                0
        }
    
        // R.layout.xxx 是Int类型,可以直接返回
        override fun getItemViewType(position: Int): Int {
            return if (position < thumbnails.size)
                R.layout.list_thumbnail_image // 正常图片显示
            else
                R.layout.list_loadmore_footer // 末尾loadmore
        }
    
        // 有屏幕外item进入屏幕时就会调用
        override fun onBindViewHolder(holder: ThumbnailViewHolder, position: Int) {
            if (position < thumbnails.size) {
                Glide.with(context)
                    .load(thumbnails[position].uri)
                    .into(holder.itemView.thumbnail_view)
            } else {
                if (thumbnails.size >= totalCount)
                    holder.itemView.tv_load_more.text = "全部加载完毕"
            }
        }
        
        // 必须这么继承一下
        class ThumbnailViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
    }
    

    滚动监听

    为RecyclerView添加滚动监听,在合适的时候加载新数据到数据集中。

    recyclerview.addOnScrollListener(object : RecyclerView.OnScrollListener() {
        override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
            super.onScrollStateChanged(recyclerView, newState)
            // 已经在加载则跳过
            if (!_thumbnailsLoading) {
                // 找到最后可见项的索引
                val lastPos = layoutManager.findLastVisibleItemPosition()
                val sum = adapter.itemCount
                // 当快接近末尾项时(这里差额10,表示再显示10个item就没数据了)获取新数据
                if (newState == RecyclerView.SCROLL_STATE_IDLE && sum - lastPos <= 10) {
                    vm.thumbnails.addAll(vm.getMoreAlbumCovers()) // 加载新数据到数据集中
                    _thumbnailsLoading = true
                }
            }
        }
    })
    

    不要将上面预加载数据和Glide的预加载图片混淆起来,拿到数据,和通过数据中的uri获取图片并下载,这是两个步骤。Glide专门针对RecyclerView提供了预加载方案,是为了减少滑动时图片还未从网络请求导致的等待加载情况,目前只支持LinearLayoutManager或其子类布局

    布局

    StaggeredGridLayoutManager

    按列瀑布流显示图片。简单地将RecyclerView的layoutManager设为StaggeredGridLayoutManager实例即可,注意StaggeredGridLayoutManager目前还是beta版。

    val sgLayoutManager =
        StaggeredGridLayoutManager(3, StaggeredGridLayoutManager.VERTICAL)
    recyclerview.layoutManager = sgLayoutManager
    

    使用StaggeredGridLayoutManager会发现上下滑动过程中,经常发生图片块重排。根据网上说法,这是因为复用的ViewHolder和该ViewHolder要加载的图片,它们的尺寸不一致导致。比如某个ViewHolder之前加载的图片高度为60,之后被回收,但是尺寸信息仍然保留着,后来被一张高度80的图片复用,由于StaggeredGridLayoutManager是根据ViewHolder的尺寸排序布局,尺寸的变化导致发生多次排序。解决方法是在ViewHolder绑定数据时(在RecyclerView.Adapter.onBindViewHolder()中),就事先设置好本次布局的最终尺寸,如下:

    override fun onBindViewHolder(holder: ThumbnailViewHolder, position: Int) {
        val layoutParams =
            holder.itemView.thumbnail_view.layoutParams as LinearLayout.LayoutParams
        //手动设置ViewHolder高度
        layoutParams.height = thumbnails[position].height
    
        Glide.with(context).load(thumbnails[position].uri)
            .into(holder.itemView.thumbnail_view)
    }
    

    当由下滑回到最顶部时,经常会出现顶部(第一行)的图片相互重排。仔细观察,这是因为第一行初次布局时是按顺序排列而非按空缺插入,往回滑时则是按空缺(哪里最空最先排哪里),这导致顺序可能与初次排序不一致。不过还好,最终仍会按照图片尺寸各自归位。而且这种情况只会出现在第一次由下滑回到顶部时。

    GreedoLayoutManager

    StaggeredGridLayoutManager一共有3k多行代码,又是beta版。代码洁癖的我把目光投向了GreedoLayoutManager,它是500px开源的一个LayoutManager,能在保持图片宽高比例的前提下将多张图片拼接到一行显示,原理很简单,看下面动图:
    在线动图制作brush.ninja-gif裁剪

    替换LayoutManager也相当简单,重新设置下RecyclerView的layoutManager即可。

    val layoutManager =
        GreedoLayoutManager(adapter).also { it.setMaxRowHeight(resources.displayMetrics.heightPixels / 3) }
    recyclerview.layoutManager = layoutManager
    

    GreedoLayoutManager在布局之前需要知道item的宽高比例,只要让Adapter实现SizeCalculatorDelegate接口即可

    override fun aspectRatioForIndex(index: Int): Double {
        val thumbnail = thumbnails[index]
        return thumbnail.width / thumbnail.height.toDouble()
    }
    

    运行界面显示:

    可以看到每张图片都比预期大很多,只能看到一小部分。经研究发现,上面定义的图片展示项的布局(LinearLayout内嵌ImageView),最终呈现后,LinearLayout的尺寸是每个网格的尺寸,而内嵌的ImageView则超出了LinearLayout,似乎其最终尺寸是MeasuredSize——我们在onCreateViewHolder时使用了LayoutInflater.from(context).inflate(viewType, parent, false),这里的parent是RecyclerView,而在布局xml中宽高都设置为match_parent,因此其中ImageView的MeasuredSize同RecyclerView的宽高——然而ImageView最终尺寸应该同样适配网格尺寸才对。

    以width为例:

    期望:ImageView.width == LinearLayout.width == 网格.width
    实际:ImageView.width == ImageView.measuredWith == RecyclerView.width
    

    我们看到每个框格其实是ImageView被截取的左上角那部分。

    经过一番搜索,网上各种对getWidthgetMeasuredWidth区别的阐述,并没有解决我的困惑,直到这篇从源码的角度分析,getWidth() 与 getMeasuredWidth() 的不同之处让我知道,其实Android系统并没有对width下定义,自定义布局时可随意设置子项大小,是否超出屏幕也没有限制。在我们这个场景下,估计GreedoLayoutMananger在处理了最外层控件(这里是LinearLayout)的width后,并没有递归处理内部控件的width,从而导致了这个bug。

    既然如此,那么就不要外围的LinearLayout,直接使用ImageView,反倒省了一点开销。

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThumbnailViewHolder {
        return if (viewType == 0) {
            val imageView = ImageView(parent.context).apply {
                scaleType = ImageView.ScaleType.CENTER_CROP
                layoutParams = ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT
                )
            }
            ThumbnailViewHolder(imageView)
        } else {
            val itemView = LayoutInflater.from(context).inflate(viewType, parent, false)
            ThumbnailViewHolder(itemView)
        }
    }
    

    当然也有ViewHolder重用导致的显示问题,图片只显示一部分,且是按ViewHolder重用前的宽高比例显示,如下:

    懒得深究,使用Glide官方文档建议的waitForLayout()并没有用,override(width, height)提前告知图片尺寸解决。

    Glide.with(context)
        .load(thumbnails[position].uri)
        .override(thumbnails[position].width, thumbnails[position].height)
        .into(holder.itemView as ImageView)
    //                .waitForLayout() //并没有用
    

    下拉刷新

    使用SwipeRefreshLayout,easy,按过不表。最后成品如下

    其它

    一般常用detachAndScrapView,RecyclerView会自动帮我们处理后续重用View[Holder]的逻辑。然而在某些场景下(如只是重排当前显示的Views而不是移除),我们可以使用更轻量级的detachView(detach之后view就不在界面上显示了),不过要记得在下次布局之前手动调用attachView(位置的话,detach之前在哪,attach后就在哪)或removeDetachedView/recycleView
    注意detach之后,RecyclerView.getChildCount()就相应减少。

    真正把 view layout到界面上的是RecyclerView的layoutDecorated方法。

  • 相关阅读:
    ZYNQ xilinx之困惑
    位操作的宏函数实现
    BCG信号的检测【时时更新】
    课题兼申请任务Freescale的K60系列
    SDRAM之持续中。。。。。。
    几款常见的免费网站程序
    常用运放选型
    SDRAM之开始burst length
    谈 数学之美 和 看见
    C语言中的可变参数(...)
  • 原文地址:https://www.cnblogs.com/newton/p/14167187.html
Copyright © 2020-2023  润新知