反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析

卻把清梅嗅發表於2019-12-01

本文是Android Jetpack Paging系列的第二篇文章;強烈建議 讀者將本系列作為學習Paging 閱讀優先順序最高的文章,如果讀者對Paging還沒有系統性的認識,請參考:

前言

Paging是一個非常優秀的分頁元件,與其它熱門的分頁相關庫不同的是,Paging更偏向注重服務於 業務 而非 UI 。——我們都知道業務型別的開源庫的質量非常依賴程式碼 整體的架構設計(比如RetofitOkHttp);那麼,如何說服自己或者同事去嘗試使用Paging?顯然原始碼中蘊含的優秀思想更具有說服力。

反過來說,若從Google工程師們設計、研發和維護的原始碼中有所借鑑,即使不在專案中真正使用它,自己依然能受益匪淺。

本文章節如下:

反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析

架構設計與原理解析

1、通過建造者模式進行依賴注入

建立流程毫無疑問是架構設計中最重要的環節。

作為元件的門板,向外暴露的API對於開發者越簡單友善方便呼叫越好,同時,作為API呼叫者的我們也希望框架越靈活,可配置選項越多越好。

這聽起來似乎有點違反常理—— 如何才能保證既保證 簡單幹淨的介面設計 易於開發者上手,同時又有 足夠多的可配置項 保證框架的靈活呢?

PagingAPI設計中使用了經典的 建造者(Builder)模式,並通過依賴注入將依賴一層層向下傳遞,最終依次構建了各個層級的物件例項。

對於開發者而言,只需要配置自己關心的引數,而不關心(甚至可以是不知道)的引數配置,全交給Builder類使用預設引數:

// 你可以這樣複雜地配置
val pagedListLiveData =
    LivePagedListBuilder(
            dataSourceFactory,
            PagedList.Config.Builder()
                    .setPageSize(PAGE_SIZE)                         // 分頁載入的數量
                    .setInitialLoadSizeHint(20)                     // 初始化載入的數量
                    .setPrefetchDistance(10)                        // 預載入距離
                    .setEnablePlaceholders(ENABLE_PLACEHOLDERS)     // 是否啟用佔位符
                    .build()
    ).build()

// 也可以這樣簡單地配置
val pagedListLiveData =
    LivePagedListBuilder(dataSourceFactory, PAGE_SIZE).build()
複製程式碼

需要注意的是,分頁相關功能配置物件的構建可觀察者物件的構建 是否是兩個不同的職責?顯然是有必要的,因為:

LiveData<PagedList> = DataSource + PagedList.Config(即 分頁資料的可觀察者 = 資料來源 + 分頁配置)

因此,這裡Paging的配置使用到了2個Builder類,即使是決定使用 建造者模式 ,設計者也需要對Builder類的定義有一個清晰的認知,這裡也是設計過程中 單一職責原則 的優秀體現。

最終,Builder中的所有配置都通過依賴注入的方式對PagedList進行了例項化:

// PagedList.Builder.build()
public PagedList<Value> build() {
    return PagedList.create(
            mDataSource,
            mNotifyExecutor,
            mFetchExecutor,
            mBoundaryCallback,
            mConfig,
            mInitialKey);
}

// PagedList.create()
static <K, T> PagedList<T> create(@NonNull DataSource<K, T> dataSource,
            @NonNull Executor notifyExecutor,
            @NonNull Executor fetchExecutor,
            @Nullable BoundaryCallback<T> boundaryCallback,
            @NonNull Config config,
            @Nullable K key) {
    // 這裡我們僅以ContiguousPagedList為例
    // 可以看到,所有PagedList都是將建構函式的依賴注入進行的例項化
    return new ContiguousPagedList<>(contigDataSource,
          notifyExecutor,
          fetchExecutor,
          boundaryCallback,
          config,
          key,
          lastLoad);
}
複製程式碼

依賴注入 是一個非常簡單而又樸實的編碼技巧,Paging的設計中,幾乎沒有用到單例模式,也幾乎沒有太多的靜態成員——所有物件中除了自身的狀態,其它所有通過依賴注入的配置項都是 final (不可變)的:

// PagedList.java
public abstract class PagedList<T> {
  final Executor mMainThreadExecutor;
  final Executor mBackgroundThreadExecutor;
  final BoundaryCallback<T> mBoundaryCallback;
  final Config mConfig;
  final PagedStorage<T> mStorage;
}

// ItemKeyedDataSource.LoadInitialParams.java
public static class LoadInitialParams<Key> {
  public final Key requestedInitialKey;
  public final int requestedLoadSize;
  public final boolean placeholdersEnabled;
}
複製程式碼

上文說到 幾乎沒有用到單例模式,實際上執行緒切換的設計有些許例外,但其本身依然可以通過Builder進行依賴注入以覆蓋預設的執行緒獲取邏輯。

通過 依賴注入 保證了物件的例項所需依賴有跡可循,類與類之間的依賴關係非常清晰,而例項化的物件內部 成員的不可變 也極大保證了PagedList分頁資料的執行緒安全。

2、構建懶載入的LiveData

對於被觀察者而言,只有當真正被訂閱的時候,其資料的更新才有意義。換句話說,當開發者構建出一個LiveData<PagedList>時候,這時立即通過後臺執行緒開始非同步請求分頁資料是沒有意義的。

反過來理解,若沒有訂閱就請求資料,當真正訂閱的時候,DataSource中的資料已經過時了,這時還需要重新請求拉取最新資料,這樣之前的一系列行為就沒有意義了。

真正的請求應該放在LiveData.observe()的時候,即被訂閱時才去執行,筆者這裡更偏向於稱其為“懶載入”——如果讀者對RxJava比較熟悉的話,會發現這和Observable.defer()操作符概念比較相似:

反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析

那麼,如何構建“懶載入”的LiveData<PagedList>呢?Google的設計者使用了ComputableLiveData類對LiveData的資料發射行為進行了包裝:

// @hide
public abstract class ComputableLiveData<T> {}
複製程式碼

這是一個隱藏的類,開發者一般不能直接使用它,但它被應用的地方可不少,Room元件生成的原始碼中也經常可以看到它的身影。

用一句話描述ComputableLiveData的定義,筆者覺得 LiveData的資料來源 比較適合,感興趣的讀者可以仔細研究一下它的原始碼,筆者有機會會為它單獨開一篇文章,這裡不繼續展開。

總之,通過ComputableLiveData類,Paging實現了訂閱時才執行非同步任務的功能,更大程度上減少了做無用功的情況。

3、為分頁資料賦予生命週期

分頁資料PagedList理應也有屬於自己的生命週期。

正常的生命週期內,PagedList不斷從DataSource中嘗試載入分頁資料,並展示出來;但資料來源中的資料總有過期失效的時候,這意味著PagedList生命週期走到了盡頭。

Paging需要響應式地建立一個新的DataSource資料快照以及新的PagedList,然後交給PagedListAdapter更新在UI上。

為此,PagedList類中增加了對應的一個mDetached欄位:

public abstract class PagedList<T> extends AbstractList<T> {
  //...
  private final AtomicBoolean mDetached = new AtomicBoolean(false);

  public boolean isDetached() {
      return mDetached.get();
  }

  public void detach() {
    mDetached.set(true);
  }
}
複製程式碼

這個AtomicBoolean型別的欄位是有意義的:我們知道PagedList對分頁資料的載入是非同步的,因此嘗試載入下一頁資料時,若此時mDetached.get()true,意味著此時的分頁資料已經失效,因此非同步的分頁請求任務不再需要被執行:

class ContiguousPagedList<K, V> extends PagedList<V> {

  //...
  public void onPagePlaceholderInserted(final int pageIndex) {
         mBackgroundThreadExecutor.execute(new Runnable() {
             @Override
             public void run() {
                 // 不再非同步載入分頁資料
                 if (isDetached()) {
                     return;
                 }

                 // 若資料來源失效,則將mDetached.set(true)
                 if (mDataSource.isInvalid()) {
                    detach();
                 } else {
                 // ... 載入下頁資料
                 }
             }
         });
     }
}
複製程式碼

通過上述程式碼片段讀者也可以看到,PagedList的生命週期是否失效,則依賴DataSourceisInvalid()函式,這個函式表示當前的DataSource資料來源是否失效:

public abstract class DataSource<Key, Value> {
  private AtomicBoolean mInvalid = new AtomicBoolean(false);
  private CopyOnWriteArrayList<InvalidatedCallback> mOnInvalidatedCallbacks =
          new CopyOnWriteArrayList<>();

  // 通知資料來源失效        
  public void invalidate() {
      if (mInvalid.compareAndSet(false, true)) {
          for (InvalidatedCallback callback : mOnInvalidatedCallbacks) {
              // 資料來源失效的回撥函式,通知上層建立新的PagedList
              callback.onInvalidated();
          }
      }
  }

  // 資料來源是否失效
  public boolean isInvalid() {
      return mInvalid.get();
  }
}
複製程式碼

當資料來源DataSource失效時,則會通過回撥函式,通知上文我們提到的ComputableLiveData<T>建立新的PagedList,並通知給LiveData的觀察者更新在UI上。

因此,PagedList作為分頁資料,DataSource作為資料來源,ComputableLiveData<T>作為PagedList的建立和分發者三者形成了一個閉環:

反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析

4、提供Room的響應式支援

我們知道Paging原生提供了對Room元件的響應式支援,當資料庫資料發生了更新,Paging能夠響應到並自動構建新的PagedList,然後更新到UI上。

這似乎是一個神奇的操作,但原理卻十分簡單,上一小節我們知道,DataSource呼叫了invalidate()函式時,意味著資料來源失效,DataSource會通過回撥函式重新構建新的PagedList

Room元件也是根據這個特性額外封裝了一個新的DataSource

public abstract class LimitOffsetDataSource<T> extends PositionalDataSource<T> {

  protected LimitOffsetDataSource(...) {
      // 1.定義一個"命令資料來源失效"的回撥函式
      mObserver = new InvalidationTracker.Observer(tables) {
          @Override
          public void onInvalidated(@NonNull Set<String> tables) {
              invalidate();
          }
      };
      // 2.為資料庫的失效跟蹤器(InvalidationTracker)配置觀察者
      db.getInvalidationTracker().addWeakObserver(mObserver);
  }
}
複製程式碼

這之後,每當資料庫中資料失效,都會自動執行DataSource.invalidate()函式。

現在讀者回顧最初學習Paging的時候,Room中開發者定義的Dao類,返回的DataSource.Factory到底是怎樣的一個物件?

@Dao
interface RedditPostDao {
    @Query("SELECT * FROM posts WHERE subreddit = :subreddit ORDER BY indexInResponse ASC")
    fun postsBySubreddit(subreddit : String) : DataSource.Factory<Int, RedditPost>
}
複製程式碼

答案不言而喻,正是LimitOffsetDataSource的工廠類:

@Override
public DataSource.Factory<Integer, RedditPost> postsBySubreddit(final String subreddit) {
  return new DataSource.Factory<Integer, RedditPost>() {
   // 返回能夠響應資料庫資料失效的 LimitOffsetDataSource
   @Override
   public LimitOffsetDataSource<RedditPost> create() {
     return new LimitOffsetDataSource<RedditPost>(__db, _statement, false , "posts") {
        // ....
     }
}
複製程式碼

原理上講,這些程式碼平淡無奇,但設計者通過註解的一層封裝,大幅簡化了開發者的程式碼量。對於開發者而言,只需要配置一個介面,而無需去了解內部的程式碼實現細節。

中場:更多的困惑

上一篇文章中對DataSource進行了簡單的介紹,很多朋友反應DataSource這一部分的原始碼過於晦澀,對於DataSource的選擇也是懵懵懂懂。

複雜問題的解決依賴於問題的切割細分,本文將其細分成以下2個小問題,並進行一一探討:

  • 1、為什麼設計出這麼多的DataSource和其子類,它們的使用場景各是什麼?
  • 2、為什麼設計出這麼多的PagedList和其子類?

5、資料來源的連續性與分頁載入策略

為什麼設計出這麼多的DataSource和其子類,它們的使用場景各是什麼?

Paging分頁元件的設計中,DataSource是一個非常重要的模組。顧名思義,DataSource<Key, Value>中的Key對應資料載入的條件,Value對應資料集的實際型別, 針對不同場景,Paging的設計者提供了幾種不同型別的DataSource實現類:

反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析

關於這些DataSource的介紹,請參考上一篇文章的這一小節,本文不再贅述。

第一次閱讀這一部分原始碼時,筆者最困惑的是,ContiguousDataSourcePositionalDataSource的區別到底是什麼呢?

翻閱過原始碼的讀者也許曾經注意到,DataSource有這樣一個抽象函式:

public abstract class DataSource<Key, Value> {
  // 資料來源是否是連續的
  abstract boolean isContiguous();
}

class ContiguousDataSource<Key, Value> extends DataSource<Key, Value> {
  // ContiguousDataSource 是連續的
  boolean isContiguous() { return true; }
}

class PositionalDataSource<T> extends DataSource<Integer, T> {
  // PositionalDataSource 是非連續的
  boolean isContiguous() { return false; }
}
複製程式碼

那麼,資料來源的連續性 到底是什麼概念?

對於一般的網路分頁載入請求而言,下一頁的資料總是需要依賴上一頁的載入,這種時候,我們通常稱之為 資料來源是連續的 —— 這似乎毫無疑問,這也是ItemKeyedDataSourcePageKeyedDataSource被廣泛使用的原因。

但有趣的是,在 以本地快取作為分頁資料來源 的業務模型下,這種 分頁資料來源應該是連續的 常識性的認知被打破了。

每個手機都有通訊錄,因此本文以通訊錄APP為例,對於通訊錄而言,所有資料取自於本地持久層,而考慮到手機內也許會有成千上萬的通訊錄資料,APP本身列表資料也應該進行分頁載入。

這種情況下,分頁資料來源是連續的嗎?

讀者仔細思考可以得知,這時分頁資料來源 一定不能是連續的 。誠然,對於滑動操作而言,資料的連續分頁請求沒有問題,但是當使用者從通訊錄頁面的側邊點選Z字母,嘗試快速跳轉Z開頭的使用者時,分頁資料請求的連續性被打破了:

反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析

這便是PositionalDataSource的使用場景:通過特定的位置載入資料,這裡KeyInteger型別的位置資訊,每一條分頁資料並不依賴上一條分頁資料,而是依賴資料所處資料來源本身的位置(Position)。

分頁資料的連續性 是一個十分重要的概念,理解了這個概念,讀者也就能理解DataSource各個子類的意義了:

無論是PositionalDataSourceItemKeyedDataSource還是PageKeyedDataSource,這些類都是不同的 分頁載入策略。開發者只需要根據不同業務的場景(比如 資料的連續性),選擇不同的 分頁載入策略 即可。

6、分頁資料模型與分頁資料副本

為什麼設計出這麼多的PagedList和其子類?

反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析

DataSource相似,PagedList同樣擁有一個isContiguous()介面:

public abstract class PagedList<T> extends AbstractList<T> {
  abstract boolean isContiguous();
}

class ContiguousPagedList<K, V> extends PagedList<V> {
  // ContiguousPagedList 內部持有 ContiguousDataSource
  final ContiguousDataSource<K, V> mDataSource;

  boolean isContiguous() { return true; }
}

class TiledPagedList<T> extends PagedList<T> {
  // TiledPagedList 內部持有 PositionalDataSource
  final PositionalDataSource<T> mDataSource;

  boolean isContiguous() { return false; }
}
複製程式碼

讀者應該理解,PagedList內部持有一個DataSource,而 分頁資料載入 的行為本質上是從DataSource中非同步獲取資料—— 在分頁資料請求的過程中,不同的DataSource也會有不同的引數需求,從而導致PagedList內部的行為也不盡相同;因此PagedList向下匯出了ContiguousPagedListTiledPagedList類,用於不同業務情況的分頁請求處理。

那麼SnapshotPagedList又是一個什麼類呢?

PagedList額外有一個snapshot()介面,以返回當前分頁資料的快照:

public abstract class PagedList<T> extends AbstractList<T> {
  public List<T> snapshot() {
      return new SnapshotPagedList<>(this);
  }
}
複製程式碼

這個snapshot()函式非常重要,其用於儲存分頁資料的前一個狀態,並且用於AsyncPagedListDiffer進行資料集的差異性計算,新的PagedList到來時(通過PagedListAdapter.submitList()),並未直接進行資料的覆蓋和差異性計算,而是先對之前PagedList中的資料集進行拷貝。

篇幅原因不詳細展示,有興趣的讀者可以自行閱讀PagedListAdapter.submitList()相關原始碼。

接下來簡單瞭解下SnapshotPagedList內部的實現:

class SnapshotPagedList<T> extends PagedList<T> {
  SnapshotPagedList(@NonNull PagedList<T> pagedList) {
    // 1.這裡我們看到,其它物件都沒有改變堆內地址的引用
    // 除了 pagedList.mStorage.snapshot(),最終執行 -> 2
      super(pagedList.mStorage.snapshot(),
              pagedList.mMainThreadExecutor,
              pagedList.mBackgroundThreadExecutor,
              null,
              pagedList.mConfig);
      mDataSource = pagedList.getDataSource();
      mContiguous = pagedList.isContiguous();
      mLastLoad = pagedList.mLastLoad;
      mLastKey = pagedList.getLastKey();
  }
}

final class PagedStorage<T> extends AbstractList<T> {
  PagedStorage(PagedStorage<T> other) {
      // 2.對當前分頁資料進行了一次拷貝
      mPages = new ArrayList<>(other.mPages);
  }
}
複製程式碼

此外,mSnapshot還用於狀態的儲存,當差異性計算未執行完畢時,若此時開發者呼叫getCurrentList()函式,則會嘗試將mSnapshot——即之前資料集的副本進行返回,有興趣的讀者可以研究一下。

7、執行緒切換與Paging設計中的"Bug"

Google的工程師們設計Paging的初衷就希望能夠讓開發者 無感知地進行執行緒切換 ,因此大部分執行緒切換的程式碼都封裝在內部:

public class ArchTaskExecutor extends TaskExecutor {
  // 主執行緒的Executor
  private static final Executor sMainThreadExecutor = new Executor() {
      @Override
      public void execute(Runnable command) {
          getInstance().postToMainThread(command);
      }
  };

  // IO執行緒的Executor
  private static final Executor sIOThreadExecutor = new Executor() {
      @Override
      public void execute(Runnable command) {
          getInstance().executeOnDiskIO(command);
      }
  };
}
複製程式碼

有興趣的讀者可以研究ArchTaskExecutor內部的原始碼,其內部sMainThreadExecutor原理依然是通過Looper.getMainLooper()建立對應的Handler並向主執行緒傳送訊息,本文不贅述。

原始碼的設計者希望,使用Paging的開發者能夠在執行資料的分頁載入任務時,內部切換到IO執行緒,而分頁資料載入成功後,則內部切換回到主執行緒更新UI。

從設計上講,這是一個非常優秀的設計,但是開發者真正使用時,卻很難注意到DataSource中對資料載入的回撥方法,本身就是執行在IO執行緒的:

public abstract class PositionalDataSource<T> extends DataSource<Integer, T>{
  // 通過註解提醒開發者回撥在子執行緒
  @WorkerThread
  public abstract void loadInitial(...);

  @WorkerThread
  public abstract void loadRange(...);
}
複製程式碼

回撥本身在子執行緒執行,意味著,開發者對分頁資料的載入最好不要使用非同步方法,否則很可能出問題。

對於OkHttp的使用者而言,開發者應該使用execute()同步方法:

override fun loadInitial(..., callback: LoadInitialCallback<RedditPost>) {
  // 使用同步方法
  val response = request.execute()
  callback.onResult(...)
}
複製程式碼

對於RxJava而言,則應該使用blocking相關的方法進行阻塞操作。

如果說PositionalDataSource還有@WorkerThread提醒,那麼另外的ItemKeyedDataSourcePageKeyedDataSource乾脆就沒有@WorkerThread註解:

public abstract class ItemKeyedDataSource<Key, Value> extends ContiguousDataSource<Key, Value> {
  public abstract void loadInitial(...);

  public abstract void loadAfter(...);
}

// PageKeyedDataSource也沒有`WorkerThread`註解,不贅述
複製程式碼

因此如果沒有注意到這些細節,開發者很可能誤入歧途,從而導致未知的一些問題,對此,開發者可以嘗試參考Google這個示例程式碼

奇怪的是,即使是Google官方的程式碼示例中,對於loadInitialloadAfter兩個函式,也只有loadInitial中使用了同步方法進行請求,而loadAfter中依然是使用enqueue()進行非同步請求。儘管註釋中明確宣告瞭這點,但筆者還是無法理解這種行為,因為這的確有可能令一些開發者誤入歧途。

反思|Android 列表分頁元件Paging的設計與實現:架構設計與原理解析

總之,Paging的設計中,其初衷將執行緒切換的實現細節進行隱藏是好的,但是結果的確沒有達到很好的效果,相反還有可能導致錯誤的理解和使用(筆者踩坑了)。

也許執行緒切換不交給內部的預設引數去實現(尤其是不要交給Builder模式去配置,這太容易被忽視了),而是強制要求交給開發者去指定更好?

歡迎有想法的朋友在本文下方留言,思想的交流會更容易讓人進步。

總結

本文對Paging的原理實現進行了系統性的講解,那麼,Paging的架構設計上,到底有哪些優點值得我們學習?

首先,依賴注入Paging內部所有物件的依賴,包括配置引數、內部回撥、執行緒切換,絕大多數都是通過依賴注入進行的,簡單樸實 ,類與類之間的依賴關係皆有跡可循。

其次,類的抽象和將不同業務的下沉,DataSourcePagedList分工明確,並向上抽象為一個抽象類,並將不同業務情況下的分頁邏輯下沉到各自的子類中去。

最後,明確物件的邊界:設計分頁資料的生命週期,當資料來源無效時,避免執行無效的非同步分頁任務;使用 懶載入的LiveData ,保證未訂閱時不執行分頁邏輯。


參考 & 更多

如果對Paging感興趣,歡迎閱讀筆者更多相關的文章,並與我一起討論:


關於我

Hello,我是 卻把清梅嗅 ,如果您覺得文章對您有價值,歡迎 ❤️,也歡迎關注我的 部落格 或者 Github

如果您覺得文章還差了那麼點東西,也請通過關注督促我寫出更好的文章——萬一哪天我進步了呢?

相關文章