前言
当我们在安卓开发过程中需要使用到键值对映射的时候,自然而然的会想到使用HashMap
,但是当我们指定key
的泛型为Int
型时,IDE却会提示我们使用SparseArray
来替换HashMap
,这是为什么呢?难道SparseArray相较于HashMap有什么优势吗?带着疑问我们去探寻一下SparseArray
的源码。
官方简介
SparseArray
类位于android.util这个包下,官方对SparseArray
简介如下:
SparseArrays map integers to Objects. Unlike a normal array of Objects, there can be gaps in the indices. It is intended to be more memory efficient than using a HashMap to map Integers to Objects, both because it avoids auto-boxing keys and its data structure doesn't rely on an extra entry object for each mapping.
翻译过来就是:SparseArray
用于将整形映射到对象,对于这样的键值对映射,它有着比HashMap
更高的内存效率,因为其避免了整形自动拆装箱的过程,并且不需要额外的Entry
结构。
大致存储结构
分析源码之前,大家先根据图大概看一下SparseArray
是怎么存储键值对的。 其大致存储结构如下图:
- 使用
int
数组mKeys
存储所有的key
- 使用
Obeject
数组mValues
存储所有的value
key
和value
根据下标一一对应,也就是说想得到某个键值对,只需要根据某个下标index
利用mKeys[index]
中取出key
值,再利用mValues[index]
取出value
,就得到了一个key->value
键值对了。
还有几个比较关键的点图中没有展示:
mKeys
是有序数组,按照key
的大小由小到大排列- 某个键值对的
value
值可能为DELETE(SparseArray中的一个静态Object变量)
,方便起见,下文中我会称这种键值对为脏数据。出现原因是,当你想删除某个键值对时,SparseArray
不会立即删除该键值对,而是先将要删除的key
对应的value
值替换成DELETE
,然后在必要的时候才会删除。 下面开始分析源码。属性
SparseArray
内部的属性很少,如下:
// 当移除一个元素时,并不是立即删除数据,而是将key->value 变成key->DELETED
private static final Object DELETED = new Object();
// 用来标记当前内部是否有 key->DELETED 这样的脏数据
private boolean mGarbage = false;
private int[] mKeys; // 用于存放keys的数组
private Object[] mValues; // 用于存放values数组
private int mSize; // 键值对的数量
复制代码
一共就五个属性,后面三个比较好理解,就不说了。而前面两个主要是用于协助 SparseArray
的删除操作,当调用delete(int key)
方法删除某个元素的时候,它不会立即删除元素,只是将原来的key->value
映射变成key->DELETED
,并用mGarbage
置为true
,表示当前数组中存在脏数据,然后在某种必要的时间才清除这些键值对。
构造函数
public SparseArray(int initialCapacity) {
if (initialCapacity == 0) {
mKeys = EmptyArray.INT;
mValues = EmptyArray.OBJECT;
} else {
// initialCapacity不为0时,根据initialCapacity初始化mKeys和mValues
mValues = ArrayUtils.newUnpaddedObjectArray(initialCapacity);
mKeys = new int[mValues.length];
}
mSize = 0;
}
public SparseArray() {
this(10); // 内部调用了上面的方法,设置初始数组长度为10
}
复制代码
SparseArray
有两个构造函数,无参构造函数内部调用了带初始容量的构造函数,该构造函数内部进行了key,value
数组的初始化。我们得知如果使用无参构造函数,key,value
数组的初始容量均为10,比较简单. 接下来看一看关键的put
方法。
put(int key,int value) 增加(刷新)键值对
public void put(int key, E value) {
// 先尝试着找一下是否mKeys中已经存在目标key了,如果找到了,只需要替换value的就好了,而如果没找到,则需要新增一组键值对
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
if (i >= 0) { // 找到了
mValues[i] = value; // 替换对应的value值
} else {
// 没找到,先将i的值取反得到应该插入的下标
i = ~i;
// 如果不是插在最后一个,并且要插入的地方已经被标记删除了,则直接将key,value替换成新的键值对,插入完成
if (i < mSize && mValues[i] == DELETED) {
mKeys[i] = key;
mValues[i] = value;
return;
}
// 当数组中存在key->DELETE键值对,并且数组已经不能容下新的键值对时,进行gc()操作,清除无意义键值对
if (mGarbage && mSize >= mKeys.length) {
gc();
// 由于清除了键值对,所以需要插入的下标的位置可能有变化,所以重新获取一下
// Search again because indices may have changed.
i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
}
// 在i处利用工具类插入 key和value,从GrowingArrayUtils名字可以看出,当key和value数组容量不足时,会先对数组扩容,在插入。
mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
mSize++; // 插入成功 刷新容量
}
}
// ------------------binarySearch(int[] array, int size, int value)-----
static int binarySearch(int[] array, int size, int value) {
int lo = 0; // 左边起始位
int hi = size - 1; // 右边起始位
// 二分查找key对应的下标
while (lo <= hi) {
final int mid = (lo + hi) >>> 1; // 类似于 mid = (lo+hi)/2,不过这里使用了位运算提升了运算效率
final int midVal = array[mid];
if (midVal < value) {
lo = mid + 1;
} else if (midVal > value) {
hi = mid - 1;
} else {
return mid; // value found
}
}
// 到了这一步,说明未查到key。这里记住一个规律,当二分查找在数组中未找到某个值,则此时lo的值为最适合插入数组的下标,这里进行取反然后再返回,这样方便让外层根据正负判断是否找到对应key的下标。
return ~lo; // value not present
}
//---------------- gc() ---------------------
private void gc() {
// Log.e("SparseArray", "gc start with " + mSize);
int n = mSize; // 键值对数量
int o = 0; // 用于记录有意义(不是key->DELETE)的键值对的数量
int[] keys = mKeys; // 获取key数组引用
Object[] values = mValues; // 获取value数组引用
// 开始清除
for (int i = 0; i < n; i++) {
Object val = values[i];
// 每遇到一个有意义的键值对,将他存入到key和value数组中,并且更新键值对个数
if (val != DELETED) {
if (i != o) {
keys[o] = keys[i];
values[o] = val;
values[i] = null;
}
o++;
}
}
mGarbage = false; // 数组中不再有key->DELETE 键值对
mSize = o; // 恢复正确的键值对容量
// Log.e("SparseArray", "gc end with " + mSize);
}
复制代码
put
方法是SparseArray
中比较难的方法了,流程如下:
binarySearch(int[] array, int size, int value)
方法比较巧妙,当在mkeys
数组中未找到要插入的key
时,返回值是一个负数,当我们对这个负数进行取反便能得到最合适的插入位置index
。- 当要插入的地方的
value
值为DELETE
时,我们可以采用直接替换掉脏数据而不是插入
,提升了效率。 - 采用二分法获取下标插入,因此,
mKeys
是按照key
的大小从小到大排列的。 - 插入
key,value
的方式为mkeys和mValue
s在同一下标存入对应的key
值和value
值,并且GrowingArrayUtils
会智能的对数组进行扩容。
下面看get()方法
get(int key) & get(int key,E valueIfKeyNotFound) 根据键获取值
public E get(int key) {
// 重载了下面的方法
return get(key, null);
}
public E get(int key, E valueIfKeyNotFound) {
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
// 还是使用二分查找查找key的下标,当未找到key值或者找到的key值对应的value值为DELETE时,返回传入的valueIfKeyNotFound
if (i < 0 || mValues[i] == DELETED) {
return valueIfKeyNotFound;
} else {
// 找到key的下标,返回value值
return (E) mValues[i];
}
}
复制代码
get
方法比较简单,第二个get
方法类似于HashMap
中的getOrDefault()
方法,找不到对应的value
时会返回一个传入的默认值。 下面看我们的delete
方法。
delete(int key) & remove(int key) 删除某个键值对
public void delete(int key) {
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
// 当key存在于mKeys中时,将对应的value赋值为DELETE,并用mGarbage标记当前存在脏数据
if (i >= 0) {
if (mValues[i] != DELETED) {
mValues[i] = DELETED;
mGarbage = true;
}
}
}
// 内部调用了delete方法
public void remove(int key) {
delete(key);
}
复制代码
我们看到,调用delete
或者remove
方法删除数据时并不是真正的删除了数据,只是将key->value
变成了key->DELETE
的形式,看过put
方法之后,不难发现,在某种情况下,比如这种:
SparseArray sparseArray = new SparseArray();
sparseArray.put(5,obj1); // 存入5 ->obj1
sparseArray.delete(5); // 删除 key 为 5的键值对,根据删除机制,此时键值对情况为 5 ->DELETE
sparseArray.put(1,obj2); // 此时,欲存入1->obj2 键值对,根据二分查找,应该插入下标为0 ,此时mValues[0] = DELETE,因此,直接替换mKeys[0]为1,mValues[0]为obj2,键值对情况更新为 1->obj2。
复制代码
可以发现,SparseArray
的删除机制在某些情况下能免去一次删除操作和一次插入操作,提升了效率。
size() 获取键值对的个数
public int size() {
if (mGarbage) {
gc();
}
return mSize;
}
复制代码
因为可能存在脏数据,所以return
之前进行了gc()
判断。
indexOfValue(E value) & indexOfValueByValue(E value) 获取某个值得下标
public int indexOfValue(E value) {
// 如果存在脏数据,进行gc(),防止下标返回有误
if (mGarbage) {
gc();
}
for (int i = 0; i < mSize; i++) {
if (mValues[i] == value) { // 使用 “==” 条件判断
return i;
}
}
return -1;
}
public int indexOfValueByValue(E value) {
// 如果存在脏数据,进行gc(),防止下标返回有误
if (mGarbage) {
gc();
}
for (int i = 0; i < mSize; i++) {
if (value == null) {
if (mValues[i] == null) {
return i;
}
} else {
if (value.equals(mValues[i])) {// 使用 “equals方法” 条件判断
return i;
}
}
}
return -1;
}
复制代码
两个方法的作用都是获取某个value
在values
数组的下标,但是有一点小区别。indexOfValue(E value)
使用 “==” 来进行条件判断,表示查找的值和容器中的value
为同一对象时,才返回下标,而indexOfValueByValue(E value)
则没有那么严格,使用equals方法
进行条件判断。需要使用者根据不同的需求调用对应的方法。
还有一些零零闪闪的方法就不一一列出来了,知道了原理,都相对简单。
在什么时候我们应该使用SparseArray?
好了,以上就是SparseArray
大致的源码解析了,比起HashMap
,SparseArray
避免了自动拆装箱过程,内部也仅使用了两个数组来存储键值对,比较轻巧简单,对内存更加友好,所以在项目中,如果是遇到Key
的类型为int
的情况,应该尽量使用SparseArray
来替代HashMap
。但是值得注意的是,SparseArray
使用二分查找来获取key
在数组中的下标,其时间复杂度为O(log(N)),当存放的数据量达到千量级以上,存取耗时会比较明显,此时,使用时间复杂度更低的HashMap
会是更好的选择,不过这种情况相对较少。
最后
好了,以上就是我对SparseArray
的理解和解读,如果错误,欢迎指正。好久没写博客啦,希望自己以后能陆陆续续的分享一些东西出来。