SparseArray詳解及原始碼簡析

仰簡發表於2019-01-14

一、前言

SparseArray 是 Android 在 Android SdK 為我們提供的一個基礎的資料結構,其功能類似於 HashMap。與 HashMap 不同的是它的 Key 只能是 int 值,不能是其他的型別。

二、程式碼分析

1. demo 及其簡析

首先也還是先通過 demo 來看一看 SparseArray 的基本使用方法,主要就是插入方法以及遍歷方法。這也會後面的程式碼分析打下一個基礎。

        SparseArray<Object> sparseArray = new SparseArray<>();
        sparseArray.put(0,null);
        sparseArray.put(1,"fsdfd");
        sparseArray.put(2,new String("fjdslfjdk"));
        sparseArray.put(3,1);
        sparseArray.put(4,new Boolean(true));
        sparseArray.put(5,new Object());
        sparseArray.put(8,new String("42fsjfldk"));
        sparseArray.put(20,"jfslfjdkfj");
        sparseArray.put(0,"chongfude");

        int size = sparseArray.size();
        for (int i = 0;i < size;i++) {
            Log.d(TAG, "sparseArraySample: i = " + i + ";value = " + sparseArray.get(sparseArray.keyAt(i)) );
        }
複製程式碼

上面程式碼先是 new 了一個 SparseArray,注意宣告時只能指定 value 的型別,而 key 是固定為 int 的。然後再往裡面新增 key 以及 value。這裡注意一下的是 key 為 0 的情況插入了 2 次。遍歷時,是先通過順序的下標取出 key ,再通過 keyAt 來 get 出 value。當然也可以一步到位通過 valueAt() 直接獲取到 value。然後這個 demo 的執行結果如下。

sparseArraySample: i = 0;value = chongfude sparseArraySample: i = 1;value = fsdfd sparseArraySample: i = 2;value = fjdslfjdk sparseArraySample: i = 3;value = 1 sparseArraySample: i = 4;value = true sparseArraySample: i = 5;value = java.lang.Object@b67a0fa sparseArraySample: i = 6;value = 42fsjfldk sparseArraySample: i = 7;value = jfslfjdkfj

然後通過 Debug 來看一看在記憶體中,SparseArray 實際是如何儲存的。如下圖分別是 key 與 value 在記憶體中的形式。可以看出 keys 和 values 的大小都為 13,而且 keys 的值是按從小到大順序排列的。

keys

values

2.原始碼分析

下面是 SparseArray 的類圖結構,可以看到其屬性非常的少,也可以看出其分別用了陣列 int[] 和 object[] 來儲存 key 以及 value。

SparseArray.jpg

  • SparseArray 初始化 SparseArray 的初始化也就是它的構造方法。
    public SparseArray() {
        this(10);
    }

   public SparseArray(int initialCapacity) {
        if (initialCapacity == 0) {
            mKeys = EmptyArray.INT;
            mValues = EmptyArray.OBJECT;
        } else {
            mValues = ArrayUtils.newUnpaddedObjectArray(initialCapacity);
            mKeys = new int[mValues.length];
        }
        mSize = 0;
    }
複製程式碼

其有 2 個構造方法,帶參與不帶參。當然,這個引數就是指定陣列初始大小,也就是 SparseArray 的初始容量。而不帶引數則預設指定陣列大小為 10 個。

  • 插入資料 put() 方法
public void put(int key, E value) {
        // 1.先進行二分查詢
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
        // 2. 如果找到了,則 i 必大於等於 0
        if (i >= 0) {
            mValues[i] = value;
        } else {
        // 3. 沒找到,則找一個正確的位置再插入
            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++;
        }
    }
複製程式碼

這裡呼叫了很多外部的方法以及內部的方法。首先是 ContainerHelpers#binarySearch() 的二分查詢演算法。

    //This is Arrays.binarySearch(), but doesn't do any argument validation.
    static int binarySearch(int[] array, int size, int value) {
        int lo = 0;
        int hi = size - 1;
        while (lo <= hi) {
            // 高位+低位之各除以 2,寫成右移,即通過位運算替代除法以提高運算效率
            final int mid = (lo + hi) >>> 1;
            final int midVal = array[mid];
            if (midVal < value) {
                lo = mid + 1;
            } else if (midVal > value) {
                hi = mid - 1;
            } else {
                return mid;  // value found
            }
        }
        //若沒找到,則lo是value應該插入的位置,是一個正數。對這個正數去反,返回負數回去
        return ~lo;  // value not present
    }
複製程式碼

二分查詢的分析屬於基礎內容,在註釋中了。回到 put() 方法首先通過二分查詢演算法從當前 keys 中查詢是否已經存在相同的 key 了,如果存在則會返回大於等於 0 的下標,然後接下來就會將原下標下的 values 中的舊value 替換成新的 value 值,即發生了覆蓋。

那如果沒有找到,那麼將 i 取反就是要插入的位置了,這一結論正好來自 binarySearch() 的返回結果。可以看到其最後如果沒有找到的話,就會返回 lo 的取反數。那麼這裡再把它取反過來那就是 lo 了。

這裡如果 i 是在大小 mSizes 的範圍內的,且其對應的 values[i] 又剛是被標記為刪除的物件,那麼就可以複用這個物件,否則就還是要依當前的 i 值進一步尋找要插入的位置,再插入相應的 value。

在插入之前,如果由於之前進行過 delete(),remoeAt() 以及 removeReturnOld() 中的某一個方法,那就可能要進行 gc() 操作。當然,這裡不是指的記憶體的 gc()。

private void gc() {
        int n = mSize;
        int o = 0;
        int[] keys = mKeys;
        Object[] values = mValues;
        for (int i = 0; i < n; i++) {
            Object val = values[i];
            if (val != DELETED) {
                if (i != o) {
                    keys[o] = keys[i];
                    values[o] = val;
                    values[i] = null;
                }
                o++;
            }
        }
        mGarbage = false;
        mSize = o;
    }
複製程式碼

通過程式碼很容易分析得出,這裡的 gc ,實際就是壓縮儲存,簡單點說就是讓元素捱得緊一點。

而 gc() 完之後,下標 i 可能會發生變化,因此需要重新查詢一次,以得到一個新的下標 i。

最後就是通過 GrowingArrayUtils.insert() 來進行 key 和 value 的插入。這個 insert() 根據陣列型別過載了多個,這裡只分析 int[] 型別的即可。

public static int[] insert(int[] array, int currentSize, int index, int element) {
        //確認 當前集合長度 小於等於 array陣列長度
        assert currentSize <= array.length;
        //不需要擴容
        if (currentSize + 1 <= array.length) {
            //將array陣列內從 index 移到 index + 1,共移了 currentSize - index 個,即從index開始後移一位,那麼就留出 index 的位置來插入新的值。
            System.arraycopy(array, index, array, index + 1, currentSize - index);
            //在index處插入新的值
            array[index] = element;
            return array;
        }
        //需要擴容,構建新的陣列,新的陣列大小由growSize() 計算得到
        int[] newArray = new int[growSize(currentSize)];
        //這裡再分 3 段賦值。首先將原陣列中 index 之前的資料複製到新陣列中
        System.arraycopy(array, 0, newArray, 0, index);
        //然後在index處插入新的值
        newArray[index] = element;
        //最後將原陣列中 index 及其之後的資料賦值到新陣列中
        System.arraycopy(array, index, newArray, index + 1, array.length - index);
        return newArray;
    }
複製程式碼

上面的演算法中,如果不需要擴容則直接進行移位以留出空位來插入新的值,如果需要擴容則先擴容,然後根據要插入的位置 index,分三段資料複製到新的陣列中。這裡再看看 growSize() 是如何進行擴容 size 的計算的。

    public static int growSize(int currentSize) {
        //如果當前size 小於等於4,則返回8, 否則返回當前size的兩倍
        return currentSize <= 4 ? 8 : currentSize * 2;
    }
複製程式碼

程式碼相對簡單,當前 size 小於等於 4 則為 8 ,否則為 2 倍大小。

  • get() 方法
    public E get(int key) {
        return get(key, null);
    }
    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() 方法就是通過 key 來返回對應的 value,前面在分析 put() 的時候已經分析過了二分查詢。那麼這裡如果找到了,就會通過下標直接從 mValues[] 中返回。

  • delete() 方法
public void delete(int key) {
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
        if (i >= 0) {
            if (mValues[i] != DELETED) {
                mValues[i] = DELETED;
                mGarbage = true;
            }
        }
    }
複製程式碼

delete() 也非常簡單,通過二分查詢演算法定位到下標,然後將對應的 value 標記為 DELETE,並且標記需要進行 gc 了。這裡需要注意的是被標記為 DELETE 的 value 不會在 gc 中被移除掉,而只會被覆蓋掉,從而提高了插入的效率。

三、總結

文章對 SparseArray 進行了簡要的分析,文章也只對主要的幾個方法進行了分析,其他沒有分析到的方法在這個基礎上再進行分析相信也是很簡單的。而總結下來幾點是:

  • 其內部主要通過 2 個陣列來儲存 key 和 value,分別是 int[] 和 Object[]。這也限定了其 key 只能為 int 型別,且 key 不能重複,否則會發生覆蓋。
  • 一切操作都是基於二分查詢演算法,將 key 以升序的方法 “緊湊” 的排列在一起,從而提高記憶體的利用率以及訪問的效率。相比較 HashMap 而言,這是典型的時間換空間的策略。
  • 刪除操作並不是真的刪除,而只是標記為 DELETE,以便下次能夠直接複用。

最後,感謝你能讀到並讀完此文章。受限於作者水平有限,如果存在錯誤或者疑問都歡迎留言討論。如果我的分享能夠幫助到你,也請記得幫忙點個贊吧,鼓勵我繼續寫下去,謝謝。

相關文章