Adapter最佳实践

5,267 阅读27分钟

本文会不定期更新,推荐watch下项目

如果喜欢请star,如果觉得有纰漏请提交issue,如果你有更好的点子可以提交pull request。

本文的示例代码主要是基于CommonAdapter这个库编写的,若你有其他的技巧和方法可以参与进来一起完善这篇文章。

本文固定连接:github.com/tianzhijiex…


一、背景

  • 从维护角度看,大量的项目中的adapter都是杂乱不堪的
  • 从扩展角度看,在多个type的情况下,无论是维护还是扩展都变得十分复杂
  • 从设计角度看,我们无法明确定义adapter属于的层级
  • 从性能角度看,adapter的好坏对于list页面的性能有着关键的作用

为了降低项目代码的复杂度,让大家能专注于业务而不是考虑性能,我们必须要对adapter进行一个封装。

二、需求

基础:

  1. item必须是高内聚的,能处理自己的点击事件,它独立于adapter
  2. item本身可以获得当前页面的activity
  3. adapter不应是一个独立的类,它更合适作没有复用价值的内部类
  4. adapter能支持多种item类型,仅改动两行代码即可添加一个新的item
  5. adapter能对自身的item进行自动复用,无需手动判断

性能:

  1. adapter对findviewById()应有自动的优化策略,类似于ViewHolder
  2. item自身的setListener应仅设置一次,不在getView时new出多余的listener
  3. adapter应提供item的局部刷新功能
  4. 如果一个item过于复杂,可以将其拆分成多个小的item
  5. 如果item中要加载网络或本地图片,先在线程中加载,加载好后切回主线程显示
  6. 在快速滑动时不加载网络图片或停止gif图和视频的播放
  7. 如果item中文本过多,可以采用textview的预渲染方案
  8. 如果发现item因为measure任务过重,则要通过自定义view来优化此item
  9. 通过判断已经显示的内容和需要显示的新内容是否不同来决定要不要重新渲染view
  10. 适当的使用RecycledViewPool来缓存item对象
  11. 使用recycleView的预取(Prefetch)

扩展:

  1. listview的adapter应在修改一两行代码后支持recyclerView
  2. 一个adapter中的不同item可以接收不同的数据对象
  3. adapter应支持数据绑定,数据变了后界面应自动刷新

设计:

  1. adapter应该有明确的层级定位,数据不应知道adapter和view的存在

其他:

  1. 根据项目的结构封装一个统一的item的基类,它可以减少大量的基础代码
  2. 多个type的时候item通常都可以再抽出一个父类,布局也可以用include标签
  3. 能知道当前RecycleView的滑动距离和滑动方向
  4. adapter能支持添加hearder和footer,对于有/无header时的空态有不同的处理
  5. 允许用viewpager的notifyDataSetChanged()来更新界面

三、实现

本篇会大量利用CommonAdapter这个库和其余的工具类进行实现,下文会直接使用CommonAdapter的api。

基础

item高内聚

item要能独立的处理自身的逻辑和事件,让自身成为一个独立的ui模块。假设你的item就是一个textView:

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/text"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    />

现在只需要这么写:

public class TextItem implements AdapterItem<JsonModel> {

    private TextView text;

    public int getLayoutResId() {
        return R.layout.demo_item_text;
    }

    public void bindViews(View root) {
        text = (TextView) root.findViewById(R.id.textView);
    }

    public void setViews() {}

    public void handleData(JsonModel model, int position) {
        text.setText(model.content);
    }

}

现在,你可以将它放入不同的界面,只需要给他同样的数据模型即可。在一个item被多个页面用的情形中还可以做更多的优化,比如设置全局的缓存池等等。
分离后的item可以更加易于维护,并且我们可以针对listview和item二者进行独立的性能优化,比如做一个通用的list页面组件,item通过插拔的方式接入,item自身的数据进行diff优化等等。

我强烈建议不要用ItemOnListener做点击的判断,而是在每个item中做判断。在item中可以通过root.getContext()来得到当前页面的activity,这样就可以处理各种页面的跳转了。

    private Activity mActivity;

    @Override
    public void bindViews(View root) {
        mActivity = (Activity) root.getContext();
    }

    public Activity getActivity() {
        return mActivity;
    }

好处:
item自身能知道自己的所有操作,而ListView仅仅做个容器。现在RecyclerView的设计思路也是如此的,让item独立性增加。而且如果要带数据到别的页面,也可以直接拿到数据。
坏处:
外部对内部完全不知情,对于统一的事件没办法做到很好的统一处理。

将adapter变成内部类

为了说明,我建立了一个数据模型:

public class DemoModel {
    public String content;
    public String type;
}

它就是一个POJO,没有任何特别之处,它完全不知道其他对象的存在。

adapter做的事情是将数据和ui进行绑定,不同页面的adapter基本是不可复用的状态,而且现在主要的事情在item中处理了,所以adapter就通常是以一个内部类的形式出现,如:

listView.setAdapter(new CommonAdapter<DemoModel>(data) {
    @Override
    public AdapterItem<DemoModel> createItem(Object itemType) {
        return new Item();
    }
});

支持多种item类型

listView.setAdapter(new CommonAdapter<DemoModel>(data) {
    @Override
    public Object getItemType(DemoModel demoModel) {
        // 返回item的类型,强烈建议是string,int,float之类的基础类型,也允许class类型
        return demoModel.type;
    }

    @Override
    public AdapterItem<DemoModel> createItem(Object type) {
        switch ((String) type) {
            case "text":
                return new TextItem();
            case "button":
                return new ButtonItem();
            case "image":
                return new ImageItem();
        }
    }
});

现在如果加了新的需求,要多支持一个item类型,你只需要在switch-case语句块中新增一个case就行,简单且安全。

自动复用内部的item

我们之前对adapter的优化经常是需要在getView中判断convertView是否为null,如果不为空就不new出新的view,这样来实现item复用。先来看看上面已经出现多次的AdapterItem是个什么。

public interface AdapterItem<T> {

    /**
     * @return item布局文件的layoutId
     */
    @LayoutRes
    int getLayoutResId();

    /**
     * 初始化views
     */
    void bindViews(final View root);

    /**
     * 设置view
     */
    void setViews();

    /**
     * 根据数据来设置item的内部views
     *
     * @param model    数据list内部的model
     * @param position 当前adapter调用item的位置
     */
    void handleData(T model, int position);

}
方法 描述 做的工作
getLayoutResId 你这个item的布局文件是什么 返回一个R.layout.xxx
bindViews 在这里做findviewById的工作 btn = findViewById(R.id.xx)
setViews 在这里初始化view各个参数 setcolor ,setOnClickListener...
handleData 数据更新时会调用(类似getView) button.setText(model.text)

其实这里就是view的几个过程,首先初始化布局文件,然后绑定布局文件中的各个view,接着进行各个view的初始化操作,最后在数据更新时进行更新的工作。

分析完毕后,我去源码里面翻了一下,发现了这个库对item复用的优化:

LayoutInflater mInflater;

@Override
public View getView(int position, View convertView, ViewGroup parent) {
    // 不重复创建inflater对象,无论你有多少item,我都仅仅创建一次
    if (mInflater == null) {
        mInflater = LayoutInflater.from(parent.getContext());
    }

    AdapterItem<T> item;
    if (convertView == null) {
        // 当convertView为null,说明没有复用的item,那么就new出来
        item = getItemView(mType);
        convertView = mInflater.inflate(item.getLayoutResId(), parent, false);
        convertView.setTag(R.id.tag_item, item);
        // 调用bindView进行view的findview,仅仅是新new出来的view才会调用一次
        item.onBindViews(convertView); 
        // findview后开始setView。将绑定和设置分离,方便整理代码结构
        item.onSetViews(); 
    } else {
        // 如果这个item是可以复用的,那么直接返回
        item = (AdapterItem<T>) convertView.getTag(R.id.tag_item);
    }
    // 无论你是不是复用的item,都会在getView时触发updateViews方法,更新数据
    item.onUpdateViews(mDataList.get(position), position);
    return convertView;
}

关键代码就是这一段,所以只需要明白这一段代码做的事情,无论在使用这个库时遇到了什么问题,你都可以不必惊慌,因为你掌握了它的原理。

明白了第三方库的原理,才可以放心大胆的使用

性能

对findviewById方法的优化

通过上述对源码的分析,现在只需要在bindViews中写findview的代码即可让这个库自动实现优化。如果你用了databinding,一行代码解决问题:

private DemoItemImageBinding b;

@Override
public void bindViews(View root) {
    b = DataBindingUtil.bind(root);
}

传统做法:

TextView textView;

@Override
public void bindViews(View root) {
    textView = (TextView) root.findViewById(R.id.textView);
}

item自身的setListener应仅设置一次

我们之前会图省事在listview的getView中随便写监听器,以至于出现了new很多多余listener的现象。

public View getView(int positon, View convertView, ViewGroup parent){
    if(null == convertView){
        convertView = LayoutInflater.from(context).inflate(R.layout.item, null);
    }

    Button button = ABViewUtil.obtainView(convertView, R.id.item_btn);
    button.setOnClickListener(new View.OnClickListener(){ // 每次getView都会new一个listener
        @Override
        public void onClick(View v){
            Toast.makeText(context, "position: " + position, Toast.LENGTH_SHORT).show();
        }
    });

}

现在,我们在setViews中写上监听器就行。

public class ButtonItem implements AdapterItem<DemoModel> {

    /**
     * 因为这个方法仅仅在item建立时才调用,所以不会重复建立监听器。
     */
    @Override
    public void setViews() {
        btn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // ...
            }
        });
    }

    @Override
    public void handleData(DemoModel model, int position) {
        // 这里避免做耗时的操作
    }

}

这样setViews()保证了item只会new一次监听器,在handleData()中如果要加载图片,请在线程中加载,加载好了后切回主线程显示(一般图片库都做了这样的处理)。

如果我们要在每次点击的时候得到当前item中的data和positon就比较麻烦了,所以只能写几个getXxxx()。

    private T data;

    private int pos;

    @Override
    public void handleData(T t, int position) {
        data = t;
        pos = position;   
    }

    public T getData() {
        return data;
    }

    public int getPos() {
        return pos;
    }

建议:这块的代码建议抽到baseItem中去写。

提供局部刷新功能

这个功能在recyclerView中就已经提供了,我就不废话了。网上流传比较多的是用下面的代码做listview的单条刷新:

private void updateSingleRow(ListView listView, long id) {  
        if (listView != null) {  
            int start = listView.getFirstVisiblePosition();  
            for (int i = start, j = listView.getLastVisiblePosition(); i <= j; i++)  
                if (id == ((Messages) listView.getItemAtPosition(i)).getId()) {  
                    View view = listView.getChildAt(i - start);  
                    getView(i, view, listView);  
                    break;  
                }  
        }  
    }

其实就是手动调用了对应position的item的getView方法,个人觉得不是很好,现在直接使用recyclerView的notifyItemChanged(index)就行。

    /**
     * Notify any registered observers that the item at <code>position</code> has changed.
     * Equivalent to calling <code>notifyItemChanged(position, null);</code>.
     *
     * <p>This is an item change event, not a structural change event. It indicates that any
     * reflection of the data at <code>position</code> is out of date and should be updated.
     * The item at <code>position</code> retains the same identity.</p>
     *
     * @param position Position of the item that has changed
     *
     * @see #notifyItemRangeChanged(int, int)
     */
    public final void notifyItemChanged(int position) {
        mObservable.notifyItemRangeChanged(position, 1);
    }

上面提到的是对局部的某个item进行刷新,但是如果我们需要对某个item中的某个view进行刷新呢?

    /**
     * Notify any registered observers that the item at <code>position</code> has changed with an
     * optional payload object.
     *
     * <p>This is an item change event, not a structural change event. It indicates that any
     * reflection of the data at <code>position</code> is out of date and should be updated.
     * The item at <code>position</code> retains the same identity.
     * </p>
     *
     * <p>
     * Client can optionally pass a payload for partial change. These payloads will be merged
     * and may be passed to adapter's {@link #onBindViewHolder(ViewHolder, int, List)} if the
     * item is already represented by a ViewHolder and it will be rebound to the same
     * ViewHolder. A notifyItemRangeChanged() with null payload will clear all existing
     * payloads on that item and prevent future payload until
     * {@link #onBindViewHolder(ViewHolder, int, List)} is called. Adapter should not assume
     * that the payload will always be passed to onBindViewHolder(), e.g. when the view is not
     * attached, the payload will be simply dropped.
     *
     * @param position Position of the item that has changed
     * @param payload Optional parameter, use null to identify a "full" update
     *
     * @see #notifyItemRangeChanged(int, int)
     */
    public final void notifyItemChanged(int position, Object payload) {
        mObservable.notifyItemRangeChanged(position, 1, payload);
    }

notifyItemChanged(index, obj)这个方法的第一个参数用来确定刷新的item位置,第二个参数通常用来传递一个标志,来告诉item需要刷新的东西。

《RecyclerView animations done right》一文中通过点赞动画举出了一个很不错的例子。

整个的item是巨大且复杂的,但我们点赞后只需要对一个view进行动画的操作,处理方式就需要重新考虑了。

  • item自己处理点击事件,被点击后findview找到那个view,然后进行动画的操作
  • 通过外部adapter的notifyItemChanged(index, obj)来通知item当前是否要做动画

通常情况下我们都会选择方案一,但是如果要用第二种方式呢?

  1. 外部进行notify
    notifyItemChanged(adapterPosition, ACTION_LIKE_BUTTON_CLICKED);
  2. 判断payload
    @Override
    public void onBindViewHolder(ViewHolder holder, int position, List<Object> payloads) {
     if (payloads.isEmpty()) {
         // payloads为空,说明是更新整个viewHolder
         onBindViewHolder(holder, position);
     } else {
         // payloads 不为空,这只更新需要更新的view即可
         if(payloas.get(0).equals(ACTION_LIKE_BUTTON_CLICKED)) {
             // ...
         }
     }
    }

这里的关键点在于payloads这个参数,往大里说你可以通知某个item产生了某个事件,至于接收到事件后做什么就看你了。
这个的关键思路是外部不应该知道内部的数据,而是产生一个事件,比如“点赞了”,而item内部是根据这个事件来进行自己的操作的,是面向对象的思路。

如果一个item过于复杂,可以将其拆分成多个小的item

关于这点是facebook提出的android优化技巧,后来我了解到ios本身也可以这么做。


如图所示,这个item很复杂,而且很大。当你的item占据三分之二屏幕的时候就可以考虑这样的优化方案了。右图说明了将一个整体的item变成多个小item的效果。在这种拆分后,你会发现原来拆分后的小的item可能在别的界面或别的type中也用到了,这就出现了item模块化的思想,总之是一个挺有意思的优化思路。

详细的文章(中文)请参考《facebook新闻页ListView的优化方案》,十分感谢作者的分享和翻译!

坑!!!

如果你是做论坛的项目,会有各种楼层或者回复嵌套的情况,你可以考虑用这种方式,但肯定会遇到很多坑。下面是《Android ListView中复杂数据流的高效渲染》中提到的一些坑。

  • item的拆分和拼凑是需要自己进行实现的,具体的type肯定和json中的type不同,需要做逻辑屏蔽。很可能会加大同事之间的阅读代码的难度。
  • 由于优化的需求,把逻辑上的一个Item拆分为了多个item,因此每个item上都要设置ItemClick事件。具体实现时可以写一个基类,在基类中对item click进行处理。
  • 在item 点击时,一般需要有按压效果,此时逻辑上的item已经进行了拆分,需要策略实现逻辑上item的整体按压,而不是只有某个拆分后的item被按压。
  • 我们知道listview的item之间是有divider的,此时需要设置divider为null,我们通过添加item的方式来实现divider效果。

在快速滑动时不加载网络图片或停止gif图的播放

这个在QQ空间和微信朋友圈详情页中很常见,目前的小视频列表也是大图加文字的形式。滚动时自动停止的功能我希望交给图片框架做,而不是手动处理,如果你要手动处理,那么你还得考虑不同页面的不同情况,感觉性价比太低。
如果你的图片库没有做这样的处理,可以参考Android-Universal-Image-Loader中的实现方法。

@Override
    public void onScrollStateChanged(AbsListView view, int scrollState) {
        switch (scrollState) {
            case OnScrollListener.SCROLL_STATE_IDLE:
                imageLoader.resume();
                break;
            case OnScrollListener.SCROLL_STATE_TOUCH_SCROLL:
                if (pauseOnScroll) {
                    imageLoader.pause();
                }
                break;
            case OnScrollListener.SCROLL_STATE_FLING:
                if (pauseOnFling) {
                    imageLoader.pause();
                }
                break;
        }
        if (externalListener != null) {
            externalListener.onScrollStateChanged(view, scrollState);
        }
    }

采用textview的预渲染方案

如果你是做bbs或者做新闻的,你会发现item中会有大量的文字,而文字过多或者有着大量的表情和特殊符号的时候,列表肯定会卡顿。textview其实是一个很基本但不简单的view,里面做了大量的判断和处理,所以并非十分高效。

Instagram(现已在facebook旗下)分享了他们是如何优化他们的TextView渲染的效率的,在国内有作者也专门写了一篇文章来说明其原理的。

当你有心想要优化textview的时候,你会发现在我们知道这个item中textview的宽度和文字大小的情况下可以把初始化的配置做个缓存,每个textview只需要用这个配置好的东西进行文字的渲染即可。下面是通过优化得到的结果:

这里测试的机器是MX3,左侧是直接使用StaticLayout的方案,右侧是系统的默认方案,Y轴是FPS,可以看出来,使用优化之后的方案,帧率提升了许多。
我只推荐在measure成为瓶颈的时候才去使用这样的优化策略,不要过度优化

原理

textview支持上下左右的drawable,而且支持超链和emoji表情,每次绘制的时候都会进行检查,效率自然不会十分出众。在Android中,文本的渲染是很慢的。即使在一个像Nexus 5这样的新设备上,一段有十几行复杂文本的图片说明的初始绘制时间可能会达到50ms,而其文本的measure阶段就需要30ms。这些都发生在UI线程,在滚动时会导致app跳帧。

textview的绘制本质是layout的绘制,setText()被调用后,就会选择合适的layout进行绘制工作。textview的onDraw()中可以看到如下方法:

void onDraw() {
        // ...
        if (mLayout == null) {
            assumeLayout();
        }
        Layout layout = mLayout;

        // ....

}
 /**
     * Make a new Layout based on the already-measured size of the view,
     * on the assumption that it was measured correctly at some point.
     */
    private void assumeLayout() {
        int width = mRight - mLeft - getCompoundPaddingLeft() - getCompoundPaddingRight();
        if (width < 1) {
            width = 0;
        }
        int physicalWidth = width;
        if (mHorizontallyScrolling) {
            width = VERY_WIDE;
        }
        makeNewLayout(width, physicalWidth, UNKNOWN_BORING, UNKNOWN_BORING,
                      physicalWidth, false);
    }

makeNewLayout(...)是个很长的方法,就补贴出来了。总之我们可以通过自己定义一个layoutView来进行文本的绘制,再配合Android在ICS中引入了TextLayoutCache实现text的预渲染。令人欣喜的是,目前facebook开源了一个相当不错的layout的build,有了它就可以帮助我们快速建立一个高性能的textview了,感兴趣的同学可以用起来了。

扩展阅读:

《Instagram是如何提升TextView渲染性能的》

《TextView预渲染研究》

6. 通过自定义viewGroup来减少重复的measure

fb的的人发现目前项目中有很多稳定的item的绘制效率不高,所以就开始研究measure的耗时。

用linearlayout的时候:

> LinearLayout [horizontal]       [w: 1080  exactly,       h: 1557  exactly    ]
    > ProfilePhoto                [w: 120   exactly,       h: 120   exactly    ]
    > LinearLayout [vertical]     [w: 0     unspecified,   h: 0     unspecified]
        > Title                   [w: 0     unspecified,   h: 0     unspecified]
        > Subtitle                [w: 0     unspecified,   h: 0     unspecified]
        > Title                   [w: 222   exactly,       h: 57    exactly    ]
        > Subtitle                [w: 222   exactly,       h: 57    exactly    ]
    > Menu                        [w: 60    exactly,       h: 60    exactly    ]
    > LinearLayout [vertical]     [w: 900   exactly,       h: 1557  at_most    ]
        > Title                   [w: 900   exactly,       h: 1557  at_most    ]
        > Subtitle                [w: 900   exactly,       h: 1500  at_most    ]

用RelativeLayout的时候:

> RelativeLayout                  [w: 1080  exactly,   h: 1557  exactly]
    > Menu                        [w: 60    exactly,   h: 1557  at_most]
    > ProfilePhoto                [w: 120   exactly,   h: 1557  at_most]
    > Title                       [w: 900   exactly,   h: 1557  at_most]
    > Subtitle                    [w: 900   exactly,   h: 1557  at_most]
    > Title                       [w: 900   exactly,   h: 1557  at_most]
    > Subtitle                    [w: 900   exactly,   h: 1500  at_most]
    > Menu                        [w: 60    exactly,   h: 60    exactly]
    > ProfilePhoto                [w: 120   exactly,   h: 120   exactly]

我们都发现了对于menu,title,subtitle的重复测量。fb的工程师最终用自定义的viewgroup手动控制了布局和测量参数,最终实现了每个view仅仅测量一次的优秀结果。优化过后,facebook的工程师讲解了他们对上面这个布局的优化策略,内容翔实,是个很好的分享。

扩展阅读:

原文:《Custom ViewGroups》
中文:《听FackBook工程师讲Custom ViewGroups》

使用RecycledViewPool来缓存item

Recycled view pools allow multiple RecyclerViews to share a common pool of scrap views. This can be useful if you have multiple RecyclerViews with adapters that use the same view types, for example if you have several data sets with the same kinds of item views displayed by a ViewPager.
RecyclerView automatically creates a pool for itself if you don’t provide one.

正如上文所说RecycledViewPool的主要作用是多个页面的item共享,比如是可以滑动的tab页面,每个页面的vh都是一样的,在这种情况下用它就很合适了。

斗鱼的个tab的页面里面的item都是完全一样的,对于首页这个多fragment的结构来说,采用viewpool会大大提性能。

Tips:

  • 因为commonAdapter帮助你将各种类型的type都转换为int知了,所以需要采用自定义的RecyclePool来做这样的操作。
RecycledViewPool pool = new RecycledViewPool();

// ...

recyclerView.setRecycledViewPool(pool);
adapter.setTypePool(pool.getTypePool());
  • RecycledViewPool是依据ItemViewType来索引ViewHolder的,所以不同页面的相同的item的type必须是一样的值才能被准确的复用。

  • RecycledViewPool也可以通过mPool.setMaxRecycledViews(itemViewType, number)来设置缓存数目。

  • RecyclerView可以通过recyclerView.setItemViewCacheSize(number)设置自己所需要的ViewHolder数量,只有超过这个数量的detached ViewHolder才会丢进ViewPool中与别的RecyclerView共享。也就说每个页面可以设置自己不想和别的页面共用的viewholder数目。

  • 在合适的时机,RecycledViewPool会自我清除掉所持有的ViewHolder对象引用,当然你也可以在你认为合适的时机手动调用clear()。

判断已有的数据和新数据的异同

如果是加载图片,我还是希望你去看看你用的图片框架有没有做这样的优化,如果有就请放心,如果没有那就自己处理吧。如果你的item中文字很多,经常有几百个文字。那么也可以先判断要显示的文字和textview中已经有的文字是否一致,如果不一致再调用setText方法。

@Override
public void handleData(DemoModel model, int position) {
    if (b.imageView.getTag() != null) {
        mOldImageUrl = (int) b.imageView.getTag();
    }
    int imageUrl = Integer.parseInt(model.content);

    if (mOldImageUrl == 0 && mOldImageUrl != imageUrl) {
        b.imageView.setTag(imageUrl); // set tag
        b.imageView.setImageResource(imageUrl); // load local image
    }
}

使用Prefetch特性

在滚动和滑动的时候,RecyclerView需要显示进入屏幕的新item,这些item需要被绑定数据(如果缓存中没有类似的item很可能还需要创建),然后把它们放入布局并绘制。当所有这些工作慢吞吞进行的时候,UI线程会慢慢停下来等待其完成,然后渲染才能进行,滚动才能继续。google的工程师看到在需要一个新的item时,我们花了太多时间去准备这个item,但同时UI线程却早早的完成了前一帧的任务,休眠了大量时间,于是修改了建立vh和绘制的工作流程。

详细内容请参考:RecyclerView的新机制:预取(Prefetch) - 泡在网上的日子

扩展

Listview无痛迁移至recyclerView

如今recyclerView大有接替listview的趋势,要知道listview的适配器和recyclerView的适配器的写法是不同的。
listview的写法如下:

listView.setAdapter(new CommonAdapter<DemoModel>(data,1) {

    @Override
    public AdapterItem<DemoModel> getItemView(Object type) {
        return new TextItem();
    }
});

换成recyclerView的适配器应该需要很多步吧?不,改一行足矣。

recyclerView.setAdapter(new CommonRcvAdapter<DemoModel>(data) {

    public AdapterItem<DemoModel> getItemView(Object type) {
        return new TextItem();
    }
});

这里换了一个适配器的类名和容器名,其余的都没变。

同一个adapter的不同item可以接收不同的数据对象

我们的adapter是有一个泛型的,item也是有泛型,一般情况下adapter的泛型对象就是item的对象。

return new CommonAdapter<DemoModel>(data, 1) { // DemoModel
    public AdapterItem createItem(Object type) {
        // 如果就一种,那么直接return一种类型的item即可。
        return new TextItem();
    }
};
public class TextItem implements AdapterItem<DemoModel> { // DemoModel
    // ...
}

但这并非是必须的,所以你可以通过adapter的getConvertedData(...)进行数据的转换,让adapter接收的数据和item的数据不同。

/**
 * 做数据的转换,这里算是数据的精细拆分
 */
public Object getConvertedData(DemoModel data, Object type) {
    // 这样可以允许item自身的数据和list数据不同
    return data.content; // model -> string
}

支持数据绑定

CommonAdapter可以结合dataBinding中的ObservableList进行数据的自动绑定操作。源码如下:

protected CommonRcvAdapter(@NonNull ObservableList<T> data) {
        this((List<T>) data);
        data.addOnListChangedCallback(new ObservableList.OnListChangedCallback<ObservableList<T>>() {
            @Override
            public void onChanged(ObservableList<T> sender) {
                notifyDataSetChanged();
            }

            @Override
            public void onItemRangeChanged(ObservableList<T> sender, int positionStart, int itemCount) {
                notifyItemRangeChanged(positionStart, itemCount);
            }

            @Override
            public void onItemRangeInserted(ObservableList<T> sender, int positionStart, int itemCount) {
                notifyItemRangeInserted(positionStart, itemCount);
                notifyItemRangeChanged(positionStart, itemCount);
            }

            @Override
            public void onItemRangeRemoved(ObservableList<T> sender, int positionStart, int itemCount) {
                notifyItemRangeRemoved(positionStart, itemCount);
                notifyItemRangeChanged(positionStart, itemCount);
            }

            @Override
            public void onItemRangeMoved(ObservableList<T> sender, int fromPosition, int toPosition, int itemCount) {
                // Note:不支持一次性移动"多个"item的情况!!!!
                notifyItemMoved(fromPosition, toPosition);
                notifyDataSetChanged();
            }
        });
    }

现在只要我们对list对象进行操作,adapter就会自动去更新界面,再也不用去手动notify了。

我们可能还记得support中的一个新的工具类——diffUtil,它可以配合recycleview进行自动的notify操作,如果我们要用它就需要做一些处理了。

public static abstract class DiffRcvAdapter<T> extends CommonRcvAdapter<T> {

    DiffRcvAdapter(@Nullable List<T> data) {
        super(data);
    }

    @Override
    public void setData(@NonNull final List<T> data) {
        DiffUtil.calculateDiff(new DiffUtil.Callback() {
            @Override
            public int getOldListSize() {
                return getItemCount();
            }

            @Override
            public int getNewListSize() {
                return data.size();
            }

            /**
             * 检测是否是相同的item,这里暂时通过位置判断
             */
            @Override
            public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
                boolean result = oldItemPosition == newItemPosition;
                Log.d(TAG, "areItemsTheSame: " + result);
                return result;
            }

            /**
             * 检测是否是相同的数据
             * 这个方法仅仅在areItemsTheSame()返回true时,才调用。
             */
            @Override
            public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
                boolean result = isContentSame(getData().get(oldItemPosition), data.get(newItemPosition));
                Log.d(TAG, "areContentsTheSame: " + result);
                return result;
            }
        }).dispatchUpdatesTo(this); // 传递给adapter
        super.setData(data);

    }

    protected abstract boolean isContentSame(T oldItemData, T newItemData);
}
final DiffRcvAdapter<DemoModel> adapter = new DiffRcvAdapter<DemoModel>(DataManager.loadData(this, 3)) {
    @NonNull
    @Override
    public AdapterItem createItem(Object type) {
        return new TextItem();
    }

    @Override
    protected boolean isContentSame(DemoModel oldItemData, DemoModel newItemData) {
        return oldItemData.content.equals(newItemData.content);
    }
};

这里需要多做的是手动判断item的数据是否要更新,所以不如用ObservableArrayList比较简单,而且是直接更新,不占cpu。
需要注意的是,如果用diffutil,你的item必须是viewholder,因为它最终调用的是adapter.notifyItemRangeChanged(position, count, payload),所以就会调用adapter中的onBindViewHolder(VH holder, int position, List<Object> payloads)

[diffUtil]

public void dispatchUpdatesTo(final RecyclerView.Adapter adapter) {
    dispatchUpdatesTo(new ListUpdateCallback() {
        @Override
        public void onInserted(int position, int count) {
            adapter.notifyItemRangeInserted(position, count);
        }

        @Override
        public void onRemoved(int position, int count) {
            adapter.notifyItemRangeRemoved(position, count);
        }

        @Override
        public void onMoved(int fromPosition, int toPosition) {
            adapter.notifyItemMoved(fromPosition, toPosition);
        }

        @Override
        public void onChanged(int position, int count, Object payload) {
            adapter.notifyItemRangeChanged(position, count, payload);
        }
    });
}

设计

Adapter不属于UI层

当我们让adapter变成一个内部类的时候,剩下的问题就是adapter应该处于view层还是presenter或model层了。在实际的运用当中,我最终定义adapter是处于presenter层(mvp)或者model层(mvvm)。

是否放在p或vm层有一个简单的原则就是不可复用,p或vm的代码复用性是极其低的,所以当你认为有代码是不可复用的时候,那么你就可以放在里面。况且ui层面有可能会出现复用的情况,而且adapter中还会出现和数据相关的一些操作,所以应该让其与ui层隔离。

当你和ui隔离了,你完全可以实现一个list页面统一的ui,进行空状态等细节的处理,方便复用统一的ui,十分有用。

其他

封装baseItem

item的接口化提供了更大的灵活性,但是就实际项目而言,我强烈推荐去做一个baseItem,这样可以快速得到activity,position,context等等对象。

public abstract class BaseAdapterItem<Bind extends ViewDataBinding, Model> implements AdapterItem<Model> {

    private View root;

    private int pos;

    protected Bind b;

    private Activity activity;

    public BaseAdapterItem(Activity activity) {
        this.activity = activity;
    }

    public BaseAdapterItem() {
    }

    @CallSuper
    @Override
    public void bindViews(View view) {
        root = view;
        b = DBinding.bind(view);
        beforeSetViews();
    }

    protected void beforeSetViews() {

    }

    @CallSuper
    @Override
    public void handleData(Model t, int position) {
        pos = position;
    }

    public View getRoot() {
        return root;
    }

    public int getCurrentPosition() {
        return pos;
    }

    protected static void setVizOrInViz(View view, CharSequence str) {
        if (TextUtils.isEmpty(str)) {
            view.setVisibility(View.INVISIBLE);
        } else {
            view.setVisibility(View.VISIBLE);
        }
    }

    protected static void setVizOrGone(View view, CharSequence str) {
        if (TextUtils.isEmpty(str)) {
            view.setVisibility(View.GONE);
        } else {
            view.setVisibility(View.VISIBLE);
        }
    }

    protected int getColor(@ColorRes int colorResId) {
        return root.getResources().getColor(colorResId);
    }

    protected Context getContext() {
        return root.getContext();
    }

}

我通过上面的base和databinding结合后,快速的实现了findview的操作,十分简洁。

多type的时候抽取父类

如果list页面中有多个type,你肯定会发现不同type的item的有相同的逻辑,最常见的是点击跳转的逻辑。对于这样的情况我建议再抽取一个base来做,以后修改的时候你会发现十分方便。对于ui层面的相似,我也希望可以适当的使用include标签进行复用。
我之前偷懒经常不抽取公共部分,因为觉得做基类复杂,公共部分的代码也不多,但是后面维护的时候到处都要改,所以就给出了这条实践经验。

监听滑动的距离和方向

OnRcvScrollListener是我常用的一个监听类,可以监听滚动方向、滚动距离、是否混动到底。

/**
 * @author Jack Tony
 *         recyle view 滚动监听器
 * @date 2015/4/6
 */
public class OnRcvScrollListener extends RecyclerView.OnScrollListener {

    private static final int TYPE_LINEAR = 0;

    private static final int TYPE_GRID = 1;

    private static final int TYPE_STAGGERED_GRID = 2;

    /**
     * 最后一个的位置
     */
    private int[] mLastPositions;

    /**
     * 最后一个可见的item的位置
     */
    private int mLastVisibleItemPosition;

    /**
     * 触发在上下滑动监听器的容差距离
     */
    private static final int HIDE_THRESHOLD = 20;

    /**
     * 滑动的距离
     */
    private int mDistance = 0;

    /**
     * 是否需要监听控制
     */
    private boolean mIsScrollDown = true;

    /**
     * Y轴移动的实际距离(最顶部为0)
     */
    private int mScrolledYDistance = 0;

    /**
     * X轴移动的实际距离(最左侧为0)
     */
    private int mScrolledXDistance = 0;

    private int mOffset = 0;

    /**
     * @param offset 设置:倒数几个才判定为到底,默认是0
     */
    public OnRcvScrollListener(int offset) {
        mOffset = offset;
    }

    @Override
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        super.onScrolled(recyclerView, dx, dy);
        int firstVisibleItemPosition = 0;
        RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
        // 判断layout manager的类型
        int type = judgeLayoutManager(layoutManager);
        // 根据类型来计算出第一个可见的item的位置,由此判断是否触发到底部的监听器
        firstVisibleItemPosition = calculateFirstVisibleItemPos(type, layoutManager, firstVisibleItemPosition);
        // 计算并判断当前是向上滑动还是向下滑动
        calculateScrollUpOrDown(firstVisibleItemPosition, dy);
        // 移动距离超过一定的范围,我们监听就没有啥实际的意义了
        mScrolledXDistance += dx;
        mScrolledYDistance += dy;
        mScrolledXDistance = (mScrolledXDistance < 0) ? 0 : mScrolledXDistance;
        mScrolledYDistance = (mScrolledYDistance < 0) ? 0 : mScrolledYDistance;
        onScrolled(mScrolledXDistance, mScrolledYDistance);
    }


    /**
     * 判断layoutManager的类型
     */
    private int judgeLayoutManager(RecyclerView.LayoutManager layoutManager) {
        if (layoutManager instanceof GridLayoutManager) {
            return TYPE_GRID;
        } else if (layoutManager instanceof LinearLayoutManager) {
            return TYPE_LINEAR;
        } else if (layoutManager instanceof StaggeredGridLayoutManager) {
            return TYPE_STAGGERED_GRID;
        } else {
            throw new RuntimeException("Unsupported LayoutManager used. Valid ones are "
                    + "LinearLayoutManager, GridLayoutManager and StaggeredGridLayoutManager");
        }
    }

    /**
     * 计算第一个元素的位置
     */
    private int calculateFirstVisibleItemPos(int type, RecyclerView.LayoutManager layoutManager, int firstVisibleItemPosition) {
        switch (type) {
            case TYPE_LINEAR:
                mLastVisibleItemPosition = ((LinearLayoutManager) layoutManager).findLastVisibleItemPosition();
                firstVisibleItemPosition = ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition();
                break;
            case TYPE_GRID:
                mLastVisibleItemPosition = ((GridLayoutManager) layoutManager).findLastVisibleItemPosition();
                firstVisibleItemPosition = ((GridLayoutManager) layoutManager).findFirstVisibleItemPosition();
                break;
            case TYPE_STAGGERED_GRID:
                StaggeredGridLayoutManager staggeredGridLayoutManager = (StaggeredGridLayoutManager) layoutManager;
                if (mLastPositions == null) {
                    mLastPositions = new int[staggeredGridLayoutManager.getSpanCount()];
                }
                mLastPositions = staggeredGridLayoutManager.findLastVisibleItemPositions(mLastPositions);
                mLastVisibleItemPosition = findMax(mLastPositions);
                staggeredGridLayoutManager.findFirstCompletelyVisibleItemPositions(mLastPositions);
                firstVisibleItemPosition = findMax(mLastPositions);
                break;
        }
        return firstVisibleItemPosition;
    }

    /**
     * 计算当前是向上滑动还是向下滑动
     */
    private void calculateScrollUpOrDown(int firstVisibleItemPosition, int dy) {
        if (firstVisibleItemPosition == 0) {
            if (!mIsScrollDown) {
                onScrollDown();
                mIsScrollDown = true;
            }
        } else {
            if (mDistance > HIDE_THRESHOLD && mIsScrollDown) {
                onScrollUp();
                mIsScrollDown = false;
                mDistance = 0;
            } else if (mDistance < -HIDE_THRESHOLD && !mIsScrollDown) {
                onScrollDown();
                mIsScrollDown = true;
                mDistance = 0;
            }
        }
        if ((mIsScrollDown && dy > 0) || (!mIsScrollDown && dy < 0)) {
            mDistance += dy;
        }
    }

    @Override
    public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
        super.onScrollStateChanged(recyclerView, newState);
        RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
        int visibleItemCount = layoutManager.getChildCount();
        int totalItemCount = layoutManager.getItemCount();

        int bottomCount = totalItemCount - 1 - mOffset;
        if (bottomCount < 0) {
            bottomCount = totalItemCount - 1;
        }

        if (visibleItemCount > 0 && newState == RecyclerView.SCROLL_STATE_IDLE
                && mLastVisibleItemPosition >= bottomCount && !mIsScrollDown) {
            onBottom();
        }
    }

    protected void onScrollUp() {

    }

    protected void onScrollDown() {

    }

    protected void onBottom() {
    }

    protected void onScrolled(int distanceX, int distanceY) {
    }

    private int findMax(int[] lastPositions) {
        int max = lastPositions[0];
        for (int value : lastPositions) {
            max = Math.max(max, value);
        }
        return max;
    }
}

支持添加头/底和空状态

CommonAdapter中提供了RcvAdapterWrapper来做头部、底部、空状态的处理,方法也就是setXxx()。值得一提的是,当有头部的时候,空状态的view会自动占用屏幕-头部的空间,不会阻碍到头部的显示。

四、尾声

用不用一个第三方库我有下面的几点建议:

  1. 如果你不了解其内部的实现,那么尽可能少用,因为出了问题无从查找。
  2. 如果你遇到一个很好的库,不妨看下内部的实现,既能学到东西,又可以在以后出问题的时候快速定位问题。
  3. 如果遇到复杂的库,比如网络和图片库。全部知道其原理是很难的,也需要成本,而你自己写也是不现实的,所以需要挑选很有名气的库来用。这样即使遇到了问题,也会有很多资料可以搜到。
  4. 不要抵触国人的库,国人的库更加接地气,说不定还更好,还可以更加方便的提出issue。

探索无止境,优化没底线,我还是希望能有库在库中做好很多的优化操作,降低对程序员的要求,最终希望谁都可以写代码。简单编程,快乐生活。本文的完成离不开朋友们的支持和帮助,感谢:MingleArch、豪哥的批评和建议。

developer-kale@foxmail.com

微博:@天之界线2010

参考文章: