SparseArray 那些事儿(带给你更细致的分析)

前言

说到Android 经常使用的数据结构,那不得不提一下SparseArray(稀疏数组),咱们在不少业务以及Android源码中能见到java

基本介绍 (Whate)

简单来说就是一个使用int做为Key的 Map ,官网的介绍就是: SparseArrays map integers to Objects数组

继承关系:

它继承自Object,实现了Cloneable:性能优化

image

public class SparseArray<E> implements Cloneable {}
复制代码

其中E就是咱们的泛型参数,即咱们要存入数据的类型markdown

构造方法:

public SparseArray() {
        this(10);
    }
 
    public SparseArray(int initialCapacity) {
    }
复制代码

咱们能够看到,它有两个构造方法,一个参数为容量大小,另外一个无参构造方法,最终调用的是容量为10的的构造方法。数据结构

增删改查:

既然是一个数据结构,固然要从增删改查来介绍它的基本用法:app

使用方法(How)

增:

提供了put和append方法让使用能够放以int 做为key,任何类型做为值的数据。oop

public void put(int key, E value) public void append(int key, E value) 复制代码
删:

固然也就是指定某个key去删除源码分析

public void delete(int key) public void remove(int key) 复制代码

也能够删除某个key以后返回删除那个key的值:性能

public E removeReturnOld(int key) 复制代码

由于SparseArray 内部存储是用数组实现的,因此提供了按照数组下标来移除元素的功能(使用的时候要注意数组越界的问题):优化

public void removeAt(int index) 复制代码

还提供了基于数组下标的范围移除的功能(好比从数组的第1个开始日后移除大小3个的):

public void removeAtRange(int index, int size) 复制代码
改:

还提供了能够修改某个下标对应值的方法

public void setValueAt(int index, E value) 复制代码
查:

根据咱们存入的key找到咱们的值

public E get(int key) public E get(int key, E valueIfKeyNotFound) 复制代码

还能够根据数组下标获取值

public E valueAt(int index) 复制代码

一样能够根据下标获取key:

public int keyAt(int index) 复制代码

也能够根据咱们的key或者value反查出下标:

public int indexOfKey(int key) public int indexOfValue(E value) public int indexOfValueByValue(E value) 复制代码
  • 特别说明:

indexOfValue方法经过value值查下标的话,若是多个key都使用了相同的value,只会返回升序查找的第一个符合要求的下标,

其余功能
//大小
public int size() //清空 public void clear() 复制代码

看完这里基本用法已是都介绍完了,固然了解事物三部曲: 是什么、怎么用都讲了,最后一步为何固然也不能少,下面就来细讲讲它都实现原理:

主要源码分析 (Why)

为了方便理解,咱们先从get方法开始分析:

public E get(int key, E valueIfKeyNotFound) {
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

        if (i < 0 || mValues[i] == DELETED) {
            return valueIfKeyNotFound;
        } else {
            return (E) mValues[i];
        }
    }
复制代码

get方法最终调用的是这个方法,方法第一行首先调用了ContainerHelpers.binarySearch(mKeys, mSize, key)方法,这个方法接受三个参数,第三个是咱们即将存数据所指定的key,第一个和第二个是咱们的全局变量,分别是存咱们全部key的数组和 当前存入数据的大小:

//存放咱们的key
private int[] mKeys;
//存放咱们的值
private Object[] mValues;
//表示存入数据量的大小
private int mSize;
复制代码

从这里咱们也能看到,咱们的存入的键值对分别被存到量两个数组中,而后用一个全局变量表示当前存入数据量的大小,那为什么要单独用一个变量来表示它的大小而不是这两个数组的长度呢?这个咱们后面讲。

如今参数以及含义都知道了,咱们来看看这个方法,这个方法其实就是SparseArray核心的二分查找法,后面存取等操做都会有它到身影,咱们来分析一下:

核心的二分查找法:

/** * 找到目标key的 位置 * * @param keysArray 存key的数组 * @param size 数组的大小 * @param targetKey 要找的key * @return 若是找到了返回相应的key , * 若未找到,则返回这个key应该被存放的位置的取反 ~location */
    static int binarySearch(int[] keysArray, int size, int targetKey) {
        //位置(初始值为0)
        int location = 0;
        //查找上限
        int ceiling = size - 1;
        //二分法查找
        while (location <= ceiling) {
            //除以二取先中间的key
            final int mid = (location + ceiling) >>> 1;
            final int midKey = keysArray[mid];
            if (midKey < targetKey) {
                location = mid + 1;
            } else if (midKey > targetKey) {
                ceiling = mid - 1;
            } else {
                return mid;  // key found
            }
        }
        //此时location的值就是它应该被存储的到数组的位置(0或者length+1)
        return ~location;  // key not present
    }
复制代码

我这边把源码中的命名稍微改了一下,让理解起来更容易一点,那么总体看下来,就是从咱们存key的数组去找咱们的targetKey,若是找到了,则直接返回,没找到返回一个赋值

分析:

一开始定义初始位置为0,上限即整个大小,而后当咱们当位置小于或者等于上限时候开始循环查找,第9行,对初始位置和上限的和作一个无符号右移,也就是除以2,而后取到位于中间的key,经过比较目标key和中间key的大小去肯定肯定下次查找的范围,若是中间的值小,说明在中间范围以上,因此下次开始查找的范围的起始位置就是中间位置+1,再次执行循环体的内容,若是找到了则直接返回,若是始终没找到,返回location的取反,其中取反和无符号涉及到位运算,若是还不是特别了解能够参考这里,不管最终location = 0或者length+1 ,它的取反都是一个负数。

细节:

  • 若是没找到key 此时到location其实就是该值应该被放置到数组中到位置,此时返回到location到取反~ ,是一个负值,再次取反即可还原该值,在put方法中就有还原这个值对操做。
  • 根据二分查找方法可知,咱们key的存储顺序是按key大小升序排列的。

再次回到咱们的get方法,如今看下来就简单了,i就是咱们要找key的位置下标,若是小于0,就表示未找到该key,直接返回未找到,不然返回Value中的第i个元素。

if (i < 0 || mValues[i] == DELETED) {
            return valueIfKeyNotFound;
        } else {
            return (E) mValues[i];
        }
复制代码

再看另外一个条件:mValues[i] == DELETED他其实表示的是存值数组中的第i个元素被删除了,进一步探究,咱们再来看看它的删除方法 (delete方法和Remove其实都是一个方法):

删除
public void delete(int key) {
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

        if (i >= 0) {
            if (mValues[i] != DELETED) {
                mValues[i] = DELETED;
                mGarbage= true;
            }
        }
    }
复制代码

仍是咱们熟悉的二分查找法,首先找到这个key对应的数组下标,若是大于或者等于0,表示存在,此时若是尚未被删除,将该位置负值为DELETE,而后将mGarbage标记为置为ture,表示要进行垃圾回收,而DELETED就是一个Object,用于表示该位置的元素被删除了。

private static final Object DELETED = new Object();
复制代码

那么再回到刚刚的get方法的第二个条件,当存值的数组中被标记为删除以后,即便数组给该下标分配了空间,也会认为key对应的值不存在,而咱们的delete操做只是给值中的元素作了标记操做,并无对数组对象作一些操做,不会像ArrayList 会对数组作移位操做。

思考

到这里再回想一下,一开始为什么要设置全局标记位mSize而不是数组的长度来表示size了吧?由于即便我一个原来有值的某一个元素被删除了,而数组大小并无随之变小,而实际上这个size确定要减小一个,带着思考,咱们来看看size()方法:

public int size() {
        if (mGarbage) {
            gc();
        }
        return mSize;
    }
复制代码

首先第一个判断条件就是咱们删除操做中的标识位,当执行了删除操做之后,执行gc方法,执行完成后返回全局的mSize,那么咱们跟进这个gc方法,看看到底作了什么:

private void gc() {
        //原始大小
        int originSize = mSize;
        //回收以后的大小
        int afterGcSize = 0;
        int[] keys = mKeys;
        Object[] values = mValues;
        for (int i = 0; i < originSize; i++) {
            Object val = values[i];
            //若是该位置的元素没有被删除
            if (val != DELETED) {
                //一旦这两个值不相等(只要一个元素发生了删除,该元素之后这两个值始终不相等,而且afterGcSize始终小于 i)
                if (i != afterGcSize) {
                    //元素移位操做
                    //将第i个的元素移到 上一个位置
                    keys[afterGcSize] = keys[i];
                    values[afterGcSize] = val;
                    values[i] = null;
                }
                //没有删除元素就自增
                afterGcSize++;
            }
        }
        mGarbage = false;
        mSize = afterGcSize;
    }
复制代码

这边把方法命名从新修改了一下,方便阅读,那么整个方法下来,其实就是数组元素移位,将标记为删除元素以后的元素往前移动到该位置,mSize被从新被赋值为为afterGcSize的大小即真正未删除元素的大小,而后将mGarbage重置为false。

例子:

若是为有一个keys为[-1,2,4]对应值为[A,B,C]的SparseArray,为如今将Key为2的删除,下面用动图模拟一下当调用size时候执行gc的过程:

image

其中 i 表示循环执行的次数,注意看afterGcSize 变化的时机,而后最后gc后的状态,请你们记住,后面还能用到。 在这里插入图片描述

gc以后 size = 2, 可是values数组长度仍是3

小结:

gc过程就是把元素前移去填补删除到元素,而后返回真正存在元素到大小做为size,这也再次解释了为何全局会有一个mSize而不是使用数组长度做为size了。

咱们再来继续看看改的方法,第一个方法仍是咱们刚刚看过的对检查并gc的方法,gc事后而后和对相应下标作赋值操做:

public void setValueAt(int index, E value) {
        if (mGarbage) {
            gc();
        }
        mValues[index] = value;
    }
复制代码

看完这些,咱们再回过头来分析咱们“最复杂”的方法-增:

增:

先来看看put:

public void put(int key, E value) {
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

        if (i >= 0) {
            mValues[i] = value;
        } else {
            i = ~i;

            if (i < mSize && mValues[i] == DELETED) {
                mKeys[i] = key;
                mValues[i] = value;
                return;
            }

            if (mGarbage && mSize >= mKeys.length) {
                gc();

                // Search again because indices may have changed.
                i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
            }

            mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
            mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
            mSize++;
        }
    }
复制代码
  1. 第一行先根据以前分析的方法得到下标,若是下标大于0表示该key已经存在,咱们直接将存值数组对应的下标进行赋值。
  2. 重点看else中的逻辑,以前咱们分析过了,若是 i小于0 则表示目前数组中不存在该key,而且对i作~(取反)操做即能获得他在数组中应该被存放对位置,再看到第8行,其实这个逻辑是一个重用操做,若是i应该被存放的位置的元素被标记为删除了,很好,直接把对应下标的key和value替换。
  3. 此时再回想一下此时的状态: 即便删除标记的位置也没匹配上个人下标,so,须要把垃圾清理一下(14行gc),再从新计算一下咱们的下标(由于gc以后,删除标记位以后的元素会移动位置,再次计算可能位置就变了)。
  4. gc以后,咱们便看到了相似于insert的方法,分别对keys和values的数组作了处理,最好mSize增长一位,看上去像是数组扩容?咱们来一探究竟:
public static int[] insert(int[] array, int currentSize, int index, int element) {
        assert currentSize <= array.length;

        if (currentSize + 1 <= array.length) {
            System.arraycopy(array, index, array, index + 1, currentSize - index);
            array[index] = element;
            return array;
        }

        int[] newArray = new int[growSize(currentSize)];
        System.arraycopy(array, 0, newArray, 0, index);
        newArray[index] = element;
        System.arraycopy(array, index, newArray, index + 1, array.length - index);
        return newArray;
    }
复制代码

5.首先咱们以Keys的调用来分析入参:

mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
复制代码

分别传入的是存Key的数组,当前大小,即将存放key的下标、即将被存放的key。

方法中第一个if体:咱们的mSize小于或者等于数组的长度执行一部分逻辑,注意看System.arraycopy方法,这个方法入参的src 和 des 其实就是他自己,那么整段逻辑下来就是必要的时候对数组进行移位操做,移位操做完成后,对index进行赋值操做,因此虽然是insert方法,这里其实没有对数组进行扩容,而是重复利用了空间。那么为何走到这个条件体呢?回顾以前咱们对gc流程,一旦对元素进行删除而且调用了gc以后,存key的数组长度确定是大于mSize的。 6. 再往下就是咱们真正的扩容操做了:

int[] newArray = new int[growSize(currentSize)];
    
public static int growSize(int currentSize) {
return currentSize <= 4 ? 8 : currentSize * 2;
}

复制代码

这里的扩容和Arraylist的自增当前容量一半的扩容方式不一样的是,当小于4直接是扩容到8,不然直接翻倍。

7.这里咱们inser方法分析完成,最终经过这个方法完成了对存键值对数组的互用或者扩,至此put方法分析基本完成。

总结:

  1. SparseArray 因内部使用了int作为key避免了自动装箱操做,相比HashMap是更省内存的,可是另外一方面由于内部是二分查找法,在存储大量数据的状况下,性能是比HashMap差的,可是Android中通常没有特别大量数据的场景,因此Android中尽量更推荐使用SparseArray。
  2. SpareArray虽然内部是数组实现的,可是它是按照Key的大小升序的,因此存数据前后并不能决定它在数组下标中的顺序。
  3. 它除了增长元素可能会有数组扩容操做,其余都是经过标记位,数组元素移位来完成,性能优化更好。
  4. 适用场景:数据量不大,空间比时间重要,key为int的状况,对于咱们客户端来讲通常页面数据不会过千,那么SparseArray相对于HashMap在查询上不会有太大的区别,可是在内存上有很大的优点。
相关文章
相关标签/搜索