关于通讯录以及IM会话列表的优化思考

本文主要结合通讯录刷新以及IM会话场景实例思考列表的更新数据性能优化,另外介绍列表控件如ListView、RecyclerView的局部刷新方法。

通讯录-利用DiffUtil优化下拉刷新

通常我们加载一个列表,调用mAdapter.notifyDataSetChanged();进行粗暴的刷新,这种方式刷新整个列表控件,并且无法响应RecyclerView的动画,用户体验不是很好。
而今天要介绍的DiffUtil将解决这些痛点,自动帮我们调用下面的方法,达到优雅的刷新效果。

mAdapter.notifyItemRangeInserted(position, count);
mAdapter.notifyItemRangeRemoved(position, count);
mAdapter.notifyItemMoved(fromPosition, toPosition);
mAdapter.notifyItemRangeChanged(position, count, payload);

本身RecyclerView的适配器提供一些增删移动的局部刷新方法的,但是很多时候我们不确定新的数据和旧的数据差异性,导致有些小伙伴还是粗暴的removeAll,然后addAll新的数据(实际上就是一次replace动作)。比方说刷新动作时,原始数据20条,新的数据来了30条,可能存在有相同的数据,但是仅仅某些字段发生改变。此时无法单纯的在末尾添加新数据,通常移除旧数据,然后添加所有新的数据。对于用户来说,我可见的只有一个屏幕,有些数据可能在当前屏幕没有出现或者发生改变,上面这种方式会让用户感觉整个界面闪动,并且重新加载了一遍,用户体验不是很好。

通讯录往往是更新字段为主,新数据在使用应用时间较长后会达到一个稳定值,不会频繁有新的数据进入。按照常规逻辑,一般进入界面拉取本地数据库的数据,更新界面,然后等待网络数据到达时,进行替换。在网络数据替换的过程中,很多时候屏幕内的数据可能没有发生变化,但是进行replace操作会导致闪动,尤其是头像被重新加载(如果本地没做缓存的话,又是流量的损耗)。所以优雅的只更新数据且屏幕内的列表item只刷新特定item,甚至于单个item的某个控件,这样对于用户来说,这是一次很不错的文艺青年体验。下面开始介绍DiffUtils的文艺使用,来替换传统的屌丝刷新。

DiffUtil结构说明:
1、DiffUtil:核心类,做新旧数据的对比,以及回调更新接口,其中calculateDiff方法用来进行新旧数据集的比较。
2、DiffUtil.Callback:DiffUtil里的一个接口,用来判断新旧数据是否相等,或者更新了什么内容。实际使用过程时,需要编写一个该接口的实现类。

        public abstract int getOldListSize();//老数据集size

        public abstract int getNewListSize();//新数据集size

        public abstract boolean areItemsTheSame(int oldItemPosition, int newItemPosition);//新老数据集在同一个postion的Item是否是一个对象?(可能内容不同,如果这里返回true,会调用下面的方法)

        public abstract boolean areContentsTheSame(int oldItemPosition, int newItemPosition);//这个方法仅仅是上面方法返回ture才会调用,我的理解是只有notifyItemRangeChanged()才会调用,判断item的内容是否有变化

        /*此方法返回值不为空(不是null)时,
          *判定是否整个item刷新,还是更新item里的某一个控件
          *adapter可以通过onBindViewHolder(ViewHolder holder, int position,      List payloads)三参数方法来更新,
          *通过返回对应的字段值,让界面刷新特定控件
          */
        @Nullable
        public Object getChangePayload(int oldItemPosition, int newItemPosition) {
            return null;
        }
    }
 
 

3、ListUpdateCallback:提供一个刷新的接口
4、BatchingListUpdateCallback:ListUpdateCallback的实现类,处理局部刷新业务逻辑

使用方式:
1、按照传统写法写好ViewHolder、Adapter以及RecyclerView绑定代码。
2、开始植入DiffUtil,先编写一个DiffUtil.CallBack的实现类。getChangePayload方法只有areItemsTheSame返回true、areContentsTheSame返回false时触发,用来回传给Adapter一些更新后的字段值

public class DiffCallBack extends DiffUtil.Callback {
    private List mOldDatas, mNewDatas;//看名字

    public DiffCallBack(List mOldDatas, List mNewDatas) {
        this.mOldDatas = mOldDatas;
        this.mNewDatas = mNewDatas;
    }

    //老数据集size
    @Override
    public int getOldListSize() {
        return mOldDatas != null ? mOldDatas.size() : 0;
    }

    //新数据集size
    @Override
    public int getNewListSize() {
        return mNewDatas != null ? mNewDatas.size() : 0;
    }

    /**
     * Called by the DiffUtil to decide whether two object represent the same Item.
     * 被DiffUtil调用,用来判断 两个对象是否是相同的Item。
     * For example, if your items have unique ids, this method should check their id equality.
     * 例如,如果你的Item有唯一的id字段,这个方法就 判断id是否相等。
     * 本例判断name字段是否一致
     *
     * @param oldItemPosition The position of the item in the old list
     * @param newItemPosition The position of the item in the new list
     * @return True if the two items represent the same object or false if they are different.
     */
    @Override
    public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
        return mOldDatas.get(oldItemPosition).getId()==mNewDatas.get(newItemPosition).getId();
    }

    /**
     * Called by the DiffUtil when it wants to check whether two items have the same data.
     * 被DiffUtil调用,用来检查 两个item是否含有相同的数据
     * DiffUtil uses this information to detect if the contents of an item has changed.
     * DiffUtil用返回的信息(true false)来检测当前item的内容是否发生了变化
     * DiffUtil uses this method to check equality instead of {@link Object#equals(Object)}
     * DiffUtil 用这个方法替代equals方法去检查是否相等。
     * so that you can change its behavior depending on your UI.
     * 所以你可以根据你的UI去改变它的返回值
     * For example, if you are using DiffUtil with a
     * {@link android.support.v7.widget.RecyclerView.Adapter RecyclerView.Adapter}, you should
     * return whether the items' visual representations are the same.
     * 例如,如果你用RecyclerView.Adapter 配合DiffUtil使用,你需要返回Item的视觉表现是否相同。
     * This method is called only if {@link #areItemsTheSame(int, int)} returns
     * {@code true} for these items.
     * 这个方法仅仅在areItemsTheSame()返回true时,才调用。
     *
     * @param oldItemPosition The position of the item in the old list
     * @param newItemPosition The position of the item in the new list which replaces the
     *                        oldItem
     * @return True if the contents of the items are the same or false if they are different.
     */
    @Override
    public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
        TestBean beanOld = mOldDatas.get(oldItemPosition);
        TestBean beanNew = mNewDatas.get(newItemPosition);
        if (!beanOld.getDesc().equals(beanNew.getDesc())) {
            return false;//如果有内容不同,就返回false
        }
        if (beanOld.getPic() != beanNew.getPic()) {
            return false;//如果有内容不同,就返回false
        }
        if (!beanOld.getName().equals(beanNew.getName())) {
            return false;//如果有内容不同,就返回false
        }
        return true; //默认两个data内容是相同的
    }

    /**
     * When {@link #areItemsTheSame(int, int)} returns {@code true} for two items and
     * {@link #areContentsTheSame(int, int)} returns false for them, DiffUtil
     * calls this method to get a payload about the change.
     * 

* 当{@link #areItemsTheSame(int, int)} 返回true,且{@link #areContentsTheSame(int, int)} 返回false时,DiffUtils会回调此方法, * 去得到这个Item(有哪些)改变的payload。 *

* For example, if you are using DiffUtil with {@link RecyclerView}, you can return the * particular field that changed in the item and your * {@link android.support.v7.widget.RecyclerView.ItemAnimator ItemAnimator} can use that * information to run the correct animation. *

* 例如,如果你用RecyclerView配合DiffUtils,你可以返回 这个Item改变的那些字段, * {@link android.support.v7.widget.RecyclerView.ItemAnimator ItemAnimator} 可以用那些信息去执行正确的动画 *

* Default implementation returns {@code null}.\ * 默认的实现是返回null * * @param oldItemPosition The position of the item in the old list * @param newItemPosition The position of the item in the new list * @return A payload object that represents the change between the two items. * 返回 一个 代表着新老item的改变内容的 payload对象, */ @Nullable @Override public Object getChangePayload(int oldItemPosition, int newItemPosition) { //实现这个方法 就能成为文艺青年中的文艺青年 // 定向刷新中的部分更新 // 效率最高 //只是没有了ItemChange的白光一闪动画,(反正我也觉得不太重要) TestBean oldBean = mOldDatas.get(oldItemPosition); TestBean newBean = mNewDatas.get(newItemPosition); //这里就不用比较核心字段了,一定相等 Bundle payload = new Bundle(); if (!oldBean.getDesc().equals(newBean.getDesc())) { payload.putString("KEY_DESC", newBean.getDesc()); } if (oldBean.getPic() != newBean.getPic()) { payload.putInt("KEY_PIC", newBean.getPic()); } if (!oldBean.getName().equals( newBean.getName())) { payload.putString("KEY_NAME", newBean.getName()); } if (payload.size() == 0) {//如果没有变化 就传空 return null; } return payload;// } }

3、下面介绍核心调用入口,用来触发新旧数据的比较以及更新。第一个方法是默认进行检测item的移动,不过会影响算法性能。第二个方法是核心的算法所在。

/**
     * Calculates the list of update operations that can covert one list into the other one.
     *
     * @param cb The callback that acts as a gateway to the backing list data
     *
     * @return A DiffResult that contains the information about the edit sequence to convert the
     * old list into the new list.
     */
    public static DiffResult calculateDiff(Callback cb) {
        return calculateDiff(cb, true);
    }


 /**
     * Calculates the list of update operations that can covert one list into the other one.
     * 

* If your old and new lists are sorted by the same constraint and items never move (swap * positions), you can disable move detection which takes O(N^2) time where * N is the number of added, moved, removed items. * * @param cb The callback that acts as a gateway to the backing list data * @param detectMoves True if DiffUtil should try to detect moved items, false otherwise. * * @return A DiffResult that contains the information about the edit sequence to convert the * old list into the new list. */ public static DiffResult calculateDiff(Callback cb, boolean detectMoves) { //.....do something }

4、然后等待calculateDiff计算出差值DiffUtil.DiffResult(需要一定耗时,所以建议放在子线程),在传统的萌萌哒的notifyDataSetChanged方法处,更换为DiffUtil的dispatchUpdatesTo方法。下面实例是用RxJava在子线程计算,然后主线程更新适配器。

 Observable.create(new Observable.OnSubscribe() {
                @Override
                public void call(Subscriber subscriber) {
                    //放在子线程中计算DiffResult
                    DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffCallBack(mDatas, mNewDatas), true);
                    subscriber.onNext(diffResult);
                    subscriber.onCompleted();
                }
            }).subscribeOn(Schedulers.newThread())
                    .observeOn(AndroidSchedulers.mainThread())
                    .subscribe(new Action1() {
                @Override
                public void call(DiffUtil.DiffResult diffResult) {
                    //利用DiffUtil.DiffResult对象的dispatchUpdatesTo()方法,传入RecyclerView的Adapter,轻松成为文艺青年
                    diffResult.dispatchUpdatesTo(mAdapter);
                    //别忘了将新数据给Adapter
                    mDatas = mNewDatas;
                    mAdapter.setDatas(mDatas);
                }
            });

上述内容已经解决了刷新时,新旧数据集的对比、定向、部分刷新。但是在IM会话界面,我们通常面临着新数据和旧数据的排序性(比如按时间排序),所以还需要进一步优化,以便能快速处理频繁每批次单条IM数据的接收。

IM会话列表-利用SortedList优化新数据插入

在IM会话界面,我们通常面临着同一时间段收到多条不同客户端发来的IM消息的场景。由于IM会话具备有序性特征,单纯使用DiffUtil无法让新消息接收时,插入的消息按发送时间排序。个别情况下,客户端弱网可能由于误发、重发导致多条相同消息,所以IM会话列表还需要具备去重性。所以在这种情况下,新消息的插入场景很适合使用SortedList,来做多条无序新消息插入到原先的有序消息列表。

1、使用准备,引入SortedList替代原有List ,作为数据源

 private SortedList mDatas;
 public SortedAdapter(Context mContext, SortedList mDatas) {
        this.mContext = mContext;
        this.mDatas = mDatas;
        mInflater = LayoutInflater.from(mContext);
    }

2、实现判断是否相同item的callback

ublic class SortedListCallback extends SortedListAdapterCallback {
    /**
     * Creates a {@link SortedList.Callback} that will forward data change events to the provided
     * Adapter.
     *
     * @param adapter The Adapter instance which should receive events from the SortedList.
     */
    public SortedListCallback(RecyclerView.Adapter adapter) {
        super(adapter);
    }

    /**
     * 把它当成equals 方法就好
     */
    @Override
    public int compare(TestSortBean o1, TestSortBean o2) {
        return o1.getId() - o2.getId();
    }

    /**
     * 和DiffUtil方法一致,不再赘述
     */
    @Override
    public boolean areItemsTheSame(TestSortBean item1, TestSortBean item2) {
        return item1.getId() == item2.getId();
    }
    /**
     * 和DiffUtil方法一致,不再赘述
     */
    @Override
    public boolean areContentsTheSame(TestSortBean oldItem, TestSortBean newItem) {
        //默认相同 有一个不同就是不同
        if (oldItem.getId() != newItem.getId()) {
            return false;
        }
        if (!oldItem.getName().equals(newItem.getName())) {
            return false;
        }
        if (oldItem.getIcon() != newItem.getIcon()) {
            return false;
        }
        return true;
    }


}

3、最后直接使用mData调用add方法插入新数据,或者更新旧数据即可。

TestSortBean newBean = new TestSortBean(integer, "我是手动加入的" + mEtId.getText(), getImgId
        (integer % 10));
int index = mDatas.indexOf(newBean);
//从已有数据里寻找是否有该数据了,如果有,就执行更新
if (index<0){
    mDatas.add(newBean);
}else {
    mDatas.updateItemAt(index,newBean);
}
//也可以使用addAll

add方法注释里推荐如果原始列表存在新数据,使用updateItemAt替代add。

/**
     * Adds the given item to the list. If this is a new item, SortedList calls
     * {@link Callback#onInserted(int, int)}.
     * 

* If the item already exists in the list and its sorting criteria is not changed, it is * replaced with the existing Item. SortedList uses * {@link Callback#areItemsTheSame(Object, Object)} to check if two items are the same item * and uses {@link Callback#areContentsTheSame(Object, Object)} to decide whether it should * call {@link Callback#onChanged(int, int)} or not. In both cases, it always removes the * reference to the old item and puts the new item into the backing array even if * {@link Callback#areContentsTheSame(Object, Object)} returns false. *

* If the sorting criteria of the item is changed, SortedList won't be able to find * its duplicate in the list which will result in having a duplicate of the Item in the list. * If you need to update sorting criteria of an item that already exists in the list, * use {@link #updateItemAt(int, Object)}. You can find the index of the item using * {@link #indexOf(Object)} before you update the object. * * @param item The item to be added into the list. * * @return The index of the newly added item. * @see Callback#compare(Object, Object) * @see Callback#areItemsTheSame(Object, Object) * @see Callback#areContentsTheSame(Object, Object)} */

这里add方法是每次add或者update时就会更新数据,然后自动调用下面方法进行局部更新

  @Override
    public void onInserted(int position, int count) {
        mAdapter.notifyItemRangeInserted(position, count);
    }

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

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

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

在每次add时,都会在主线程进行排序等操作,一开始考虑到性能是否会有影响,但是从测试结果来看,不用担心。下面是10000条原始数据时,新增多条数据处理时的耗时。

数据量 DiffUtils SortedList
10000 5ms 1ms

add方法里会判断是否是原始数据已经存在,然后通知对应的方法执行局部刷新


    private int add(T item, boolean notify) {
        int index = findIndexOf(item, mData, 0, mSize, INSERTION);
        if (index == INVALID_POSITION) {
            index = 0;
        } else if (index < mSize) {
            T existing = mData[index];
            if (mCallback.areItemsTheSame(existing, item)) {
                if (mCallback.areContentsTheSame(existing, item)) {
                    //no change but still replace the item
                    mData[index] = item;
                    return index;
                } else {
                    mData[index] = item;
                    mCallback.onChanged(index, 1);
                    return index;
                }
            }
        }
        addToData(index, item);
        if (notify) {
            mCallback.onInserted(index, 1);
        }
        return index;
    }

总结:SortedList的单条add方法会先判断是否有存在数据,如果有就更新,不然就插入数据。add方法主要是用数组拷贝的方法进行进行插入操作。而addAll方法,会先拷贝新数据到数组,然后Arrays.sort(newItems, mCallback)进行排序,然后用duplicate方法进行去重。如果旧集合size为0,则全部插入;否则进行merge合并,然后逐个遍历插入或者更新(此处逻辑类似add单条数据)

DiffUtils和SortedList两者使用场景的对比总结

DiffUtils在做差异对比后,会使用新的datas作为数据源,此时新数据里不存在的旧数据会被移除,所以适用于下拉刷新整个界面的动作。比如一开始提到的通讯录列表,已进入界面时,需要从本地拉取数据以及在网络请求后再次刷新。而SortedList适合用于原始数据稳定且需要继续保留,然后新增多条或者反复新增单条数据的场景,且数据需要有序性。比如上面的IM会话列表,在保持局部刷新的同时还需要维持现有列表的有序性,防止新增加来的多条无序数据打乱列表。如果单纯一个满足不了需求,可以结合一起做,或者使用下面介绍的局部刷新技巧来达到效果。

局部刷新某个item方式

RecyclerView可以使用下面方法,或者使用上面DiffUtils里介绍的payload方式。关于RecyclerView的局部item刷新上面已经介绍,就不贴出来。

---1----
  CouponVH couponVH = (CouponVH) mRv.findViewHolderForLayoutPosition(mSelectedPos);
    if (couponVH != null) {//还在屏幕里
        couponVH.ivSelect.setSelected(false);
    }else {
        //一些极端情况,holder被缓存在Recycler的cacheView里,
        //此时拿不到ViewHolder,但是也不会回调onBindViewHolder方法。所以add一个异常处理
        notifyItemChanged(mSelectedPos);
    }
    mDatas.get(mSelectedPos).setSelected(false);//不管在不在屏幕里 都需要改变数据
    //设置新Item的勾选状态
    mSelectedPos = position;
    mDatas.get(mSelectedPos).setSelected(true);
    holder.ivSelect.setSelected(true);

-----2---
 if (mSelectedPos != position) {
                    //先取消上个item的勾选状态
                    mDatas.get(mSelectedPos).setSelected(false);
                    //传递一个payload 
                    Bundle payloadOld = new Bundle();
                    payloadOld.putBoolean("KEY_BOOLEAN", false);
                    notifyItemChanged(mSelectedPos, payloadOld);
                    //设置新Item的勾选状态
                    mSelectedPos = position;
                    mDatas.get(mSelectedPos).setSelected(true);
                    Bundle payloadNew = new Bundle();
                    payloadNew.putBoolean("KEY_BOOLEAN", true);
                    notifyItemChanged(mSelectedPos, payloadNew);
                }

@Override
    public void onBindViewHolder(CouponVH holder, int position, List payloads) {
        if (payloads.isEmpty()) {
            onBindViewHolder(holder, position);
        } else {
            Bundle payload = (Bundle) payloads.get(0);
            if (payload.containsKey("KEY_BOOLEAN")) {
                boolean aBoolean = payload.getBoolean("KEY_BOOLEAN");
                holder.ivSelect.setSelected(aBoolean);
            }
        }
    }
 
 

ListView的部分刷新策略

 //方法一:局部item整体刷新
 /**
     * 局部更新数据,调用一次getView()方法;Google推荐的做法
     *
     * @param listView 要更新的listview
     * @param position 要更新的位置
     */
    public void notifyDataSetChanged(ListView listView, int position) {
        if (listView == null) {
            return;
        }
        /**第一个可见的位置**/
        int firstVisiblePosition = listView.getFirstVisiblePosition();
        /**最后一个可见的位置**/
        int lastVisiblePosition = listView.getLastVisiblePosition();

        /**在看见范围内才更新,不可见的滑动后自动会调用getView方法更新**/
        if (position >= firstVisiblePosition && position <= lastVisiblePosition) {
            /**获取指定位置view对象**/
            View view = listView.getChildAt(position - firstVisiblePosition);
            getView(position, view, listView);
        }

    }

                 //方法二:定向刷新
                //如果 当前选中的View 在当前屏幕可见,且不是自己,要定向刷新一下之前的View的状态
     if (position != mSelectedPos) {
         int firstPos = mLv.getFirstVisiblePosition() - mLv.getHeaderViewsCount();//这里考虑了HeaderView的情况
         int lastPos = mLv.getLastVisiblePosition() - mLv.getHeaderViewsCount();
            if (mSelectedPos >= firstPos && mSelectedPos <= lastPos) {
                   View lastSelectedView = mLv.getChildAt(mSelectedPos - firstPos);//取出选中的View
                        CouponVH lastVh = (CouponVH) lastSelectedView.getTag();
                        lastVh.ivSelect.setSelected(false);
             }
          //不管在屏幕是否可见,都需要改变之前的data
           mDatas.get(mSelectedPos).setSelected(false);

             //改变现在的点击的这个View的选中状态
             couponVH.ivSelect.setSelected(true);
             mDatas.get(position).setSelected(true);
              mSelectedPos = position;
         }



DEMO地址待更新

借鉴相关文章:
【Android】 RecyclerView、ListView实现单选列表的优雅之路.
http://blog.csdn.net/zxt0601/article/details/52703280
【Android】详解7.0带来的新工具类:DiffUtil
http://blog.csdn.net/zxt0601/article/details/52562770
【Android】你可能不知道的Support(一) 0步自动定向刷新:SortedList http://blog.csdn.net/zxt0601/article/details/53495709

你可能感兴趣的:(关于通讯录以及IM会话列表的优化思考)