[譯] Architecture Components 之 Guide to App Architecture

zly394發表於2017-06-07

【目錄】

1. Architecture Components 之 Guide to App Architecture

2. Architecture Components 之 Adding Components to your Project

3. Architecture Components 之 Handling Lifecycles

4. Architecture Components 之 LiveData

5. Architecture Components 之 ViewModel

6. Architecture Components 之 Room Persistence Library

示例程式碼連結


應用程式架構指南

本指南使用於具有構建應用程式基礎並且想了解構建強大、優質的應用程式的最佳實踐和推薦架構的開發人員。

注:本指南假定讀者熟悉 Android Framework。如果你是一個應用程式開發的新手,請參閱入門指南系列培訓,其中包含了本指南先決條件的相關主題。

應用開發者面臨的常見問題

在大多數情況下,桌面應用程式在啟動器快捷方式中有一個單一的入口並且作為單獨的獨立程式執行,與桌面應用程式不同,Android 應用具有更復雜的結構。一個典型的 Android 應用是由多個應用程式元件構成的,包括 activity,fragment,service,content provider 和 broadcast receiver。

這些應用程式元件中的大部分宣告在由 Android OS 使用的應用程式清單中,用來決定如何將應用融入到使用者裝置的整體體驗中。儘管如前所述,傳統的桌面應用程式作為獨立程式執行,但是正確的編寫 Android 應用程式需要更加靈活,因為使用者會同過裝置上不同的應用程式組織成自己的方式不斷切換流程和任務。

例如,考慮下在你喜歡的社交網路應用中分享照片時會發生什麼。該應用會觸發一個啟動相機的 intent,從該 intent 中 Android OS 會啟動一個相機應用來處理這個請求。在此刻,使用者離開社交網路應用但是使用者的體驗是無縫的。相機應用轉而可能會觸發其它的 intent,例如啟動檔案選擇器,這可能會啟動另一個應用。終端使用者回到社交網路應用並且分享照片。此外,在這個過程中的任何時刻使用者都有可能會被一個電話打斷,並且在結束通話後再回來繼續分享照片。

在 Android 中,這種應用切換行為很常見,所以你的應用程式必須正確處理這些流程。記住,移動裝置的資源是有限的,所以在任何時候,作業系統都可能會殺死一些應用為新的應用騰出空間。

其中的重點是應用程式元件可能會被單獨和無序的啟動,並且可能會被使用者或系統在任何時候銷燬。因為應用程式元件是短暫的,並且其宣告週期(什麼時候被建立和銷燬)不受你控制,所以不應該在應用程式元件中儲存任何應用資料或狀態,同時應用程式元件不應該相互依賴。

常見的架構原則

如果不能在應用程式元件中儲存應用資料和狀態,那麼應該如何構建應用?

最重要的是在應用中要專注於關注點分離。一個常見的錯誤是在 ActivityFragment 中編寫所有的程式碼。任何不是處理 UI 或 作業系統互動的程式碼都不應該在這些類中。保持它們儘可能的精簡可以避免許多與生命週期有關的問題。不要忘記你不擁有這些類,它們只是體現了 OS 和 應用之間協議的粘合類。Android OS 可能會因為使用者互動或其他因素(如低記憶體)的原因在任何時候銷燬它們。最好儘量減少對它們的依賴以提供一個穩固的使用者體驗。

第二個重要的原則是應該用 Model 驅動 UI,最好是持久化的 Model。持久化是最佳的原因有兩個:一是如果 OS 銷燬應用釋放資源,使用者不用擔心丟失資料;二是即使網路連線不可靠或者是斷開的,應用仍將繼續執行。Model 是負責處理應用資料的元件。Modle 獨立於應用中的 View 和應用程式元件,因此 Model 和這些元件的生命週期問題隔離開了。保持 UI 程式碼精簡併且摒除應用的邏輯使其更易於管理。基於 Model 類構建的應用程式其管理資料的職責明確,使應用程式可測試並且穩定。

推薦的應用程式架構

在本節中,我們將通過一個用例來演示如何使用 Architecture Components 構建應用程式。

注:不可能有一種應用程式的編寫方式對於每種情況都是最好的。話雖如此,這個推薦的架構應該是大多數用例的良好起點。如果你已經有一種很好的應用程式編寫方式則不需要改變。

假設我們正在構建一個顯示使用者個人資訊的 UI。使用者的個人資訊將使用 REST API 從我們自己的私有後端獲取。

構建使用者介面

UI 包含一個 fragment 檔案 UserProfileFragment.java 和其佈局檔案 user_profile_layout.xml。

為了驅動 UI,資料模型需要持有兩個資料元素。

  • 使用者 ID :使用者的識別符號。最好使用 fragment 的引數將使用者 ID 傳到 fragment 中。如果 Android OS 銷燬程式,該 ID 將會被儲存,以便下次應用重啟時該 ID 可用。

  • 使用者物件:儲存使用者資料的普通 Java 物件(POJO)。

我們將會基於 ViewModel 來建立一個 UserProfileViewModel 來儲存這些資訊。

ViewModel 為指定的 UI 元件(如:fragment 或 activity)提供資料,並且負責與資料處理的業務部分的互動,例如:呼叫其它元件獲取資料或轉發使用者的操作。ViewModel 對於 View 並不瞭解並且不受配置改變(如:由於旋轉導致 activity 的重新建立)的影響

現在有 3 個檔案

  • user_profile.xml:螢幕上的 UI 定義。

  • UserProfileViewModel.java:為 UI 準備資料的類。

  • UserProfileFragment.java:用於在 ViewModel 中顯示資料並對使用者互動做出反應的 UI 控制器。

下面是我們的初始實現(簡單起見省略佈局檔案):

public class UserProfileViewModel extends ViewModel {
    private String userId;
    private User user;

    public void init(String userId) {
        this.userId = userId;
    }
    public User getUser() {
        return user;
    }
}複製程式碼
public class UserProfileFragment extends LifecycleFragment {
    private static final String UID_KEY = "uid";
    private UserProfileViewModel viewModel;

    @Override
    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        String userId = getArguments().getString(UID_KEY);
        viewModel = ViewModelProviders.of(this).get(UserProfileViewModel.class);
        viewModel.init(userId);
    }

    @Override
    public View onCreateView(LayoutInflater inflater,
                @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.user_profile, container, false);
    }
}複製程式碼

注:上面的例子繼承了 LifecycleFragment 而不是 Fragment 類。在 Architecture Components 中的生命週期 API 穩定後, Android 支援包中的 Fragment 類將會實現 LifecycleOwner 介面。

現在我們有了 3 個程式碼模組,怎樣連線它們?最後,當 ViewModel 的使用者欄位被設定時,需要一種方式來通知 UI。這正是 LiveData 的用武之地。

LiveData 是一個可觀察的資料持有者。它允許應用程式中的元件觀察 LiveData 進行改變,而不會在元件之間建立顯示的,固定的依賴。另外,LiveData 還遵守應用程式元件(如:activity,fragment,service)的生命週期狀態,並且防止物件洩漏使應用不會消耗更多的記憶體。

注:如果你已經再使用像 RxJavaAgera 的庫,你可以繼續使用它們而不用換成 LiveData。但是當使用它們或其它的方式時,請確保正確處理生命週期,如:當相關 LifecycleOwner 停止時暫停資料流或在 LifecycleOwner 被銷燬時銷燬資料流。可以新增 android.arch.lifecycle:reactivestreams 工具,和其它的響應流庫(如:RxJava2)一起使用 LiveData。

將 UserProfileViewModel 中的 User 欄位替換為 LiveData ,以便在更新資料時可以通知 fragment。 LiveData 的好處在於它是生命週期感知的,並且可以在不再被需要的時候自動清除引用。

public class UserProfileViewModel extends ViewModel {
    ...
    // private User user;
    private LiveData<User> user;
    public LiveData<User> getUser() {
        return user;
    }
}複製程式碼

修改 UserprofileFragment 來觀察資料並更新 UI。

@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
    super.onActivityCreated(savedInstanceState);
    viewModel.getUser().observe(this, user -> {
      // 更新 UI
    });
}複製程式碼

每次更新使用者資料時,將會呼叫 onChange 回撥並且更新 UI。

如果你熟悉其它庫的可觀察回撥的使用,可能已經意識到我們沒有重寫 fragment 的 onStop() 方法來停止觀察資料。這對於 LiveData 來說是不必要的,因為 LiveData 是證明週期感知的,這意味著除非 fragment 處於活動狀態(收到了 onStart() 但還沒有收到 onStop()),否則它不會呼叫回撥。當 fragment 收到 onDestroy() 時 LiveData 會自動移除觀察者。

我們沒有做任何事情來特別是處理配置的變化(如:使用者旋轉螢幕)。當配置發生變化時 ViewModel 將會自動恢復,所以,只要新的 fragment 啟動,它將會收到屬於 ViewModel 的相同例項,並且使用最新的資料立即呼叫回撥。這就是為什麼 ViewModel 不應該直接引用 View,ViewModel 可能存活的比 View 的生命週期長。請參閱 ViewModel 的生命週期

獲取資料

我們已經將 ViewModel 連結到了 fragment,但是 ViewModel 怎樣獲取資料呢?這個例子中,假設我們的後端提供了一個 REST API。我們將會使用 Retrofit 庫來訪問後端,你也可以自由的使用其它庫來達到同樣的目的。

這是 retrofit 的 Webservice 類,用於和後端通訊:

public interface Webservice {
    /**
     * @GET declares an HTTP GET request
     * @Path("user") annotation on the userId parameter marks it as a
     * replacement for the {user} placeholder in the @GET path
     */
    @GET("/users/{user}")
    Call<User> getUser(@Path("user") String userId);
}複製程式碼

一個簡單的 ViewModel 實現可以直接呼叫 Webservice 獲取資料並將其分配給使用者物件。即使這樣可以使用,但是應用程式將會隨著增長而難以維護。將太多的職責交給 ViewModel 這違反了我們前面提到的關注點分離的原則。此外,ViewModel 的作用域依賴於 ActivityFragment 的生命週期,因此在 ActivityFragment 的生命週期結束時丟失所有的資料是一種不好的使用者體驗。故而,我們的 ViewModel 將把這項工作委託給一個新的 Repository 模組。

Repository 模組負責處理資料操作。它們為應用程式的其它部分提供了一個乾淨的 API。它們知道在資料更新時從哪裡獲取資料和呼叫哪些 API。可以將其視為不同資料來源(持久化模型,Web 服務,快取等)之間的中間層。

下面的 UserRepository 類使用 WebService 來獲取使用者資料。

public class UserRepository {
    private Webservice webservice;
    // ...
    public LiveData<User> getUser(int userId) {
        // 這是最佳的實現,下面會有解釋
        final MutableLiveData<User> data = new MutableLiveData<>();
        webservice.getUser(userId).enqueue(new Callback<User>() {
            @Override
            public void onResponse(Call<User> call, Response<User> response) {
                // 為了簡單起見省略錯誤的情況
                data.setValue(response.body());
            }
        });
        return data;
    }
}複製程式碼

雖然 Repository 模組看起來是不必要的,但是它起著一個重要的作用;它抽象了應用程式其它部分的資料來源。現在 ViewModel 不知道資料是由 Webservice 獲取的,這意味著可以根據需求將其切換為其它實現。

注:為了簡單起見,我們忽略了網路錯誤的情況。有關於暴露錯誤和載入狀態的可選實現方式,請參閱附錄:暴露網路狀態

管理元件之間的依賴

上面的 UserRepository 類需要一個 Webservice 的例項來完成其工作。可以簡單的建立 Webservice,但是這需要知道 Webservice 的依賴來構造它。這將會顯著的使程式碼複雜和重複(例如:需要 Webservice 例項的每個類都需要知道如何使用它的依賴來構造它)。另外,UserRepostory 可能不是唯一需要 Webservice 的類。如果每個類都建立一個新的 Webservice,這將會造成非常大的資源負擔。

有兩種模式可以解決這個問題:

  • 依賴注入:依賴注入允許類定義其依賴而不構造它們。在執行時,另一個類負責提供這些依賴。推薦使用 Google 的 Dagger 2 庫在 Android 應用中實現依賴注入。Dagger 2 通過遍歷依賴關係樹自動構建物件併為依賴提供編譯時保障。

  • 服務定位:服務定位提供了一個登錄檔,類可以從中獲取它們的依賴關係,而不是構造它們。與依賴注入(DI)相比,服務定位實現起來相對容易,所以如果不熟悉 DI,請使用服務定位代替。

這些模式允許你擴充套件程式碼,因為它們提供清晰的模式來管理依賴關係,而不會重複程式碼或增加複雜性。兩者都允許替換實現進行測試;這是使用它們的主要好處之一。

在這個例子中,我們將使用 Dagger 2 來管理依賴。

連線 ViewModel 和 Repository

修改 UserProfileViewModel 以使用 Repository。

public class UserProfileViewModel extends ViewModel {
    private LiveData<User> user;
    private UserRepository userRepo;

    @Inject // UserRepository 引數由 Dagger 2 提供
    public UserProfileViewModel(UserRepository userRepo) {
        this.userRepo = userRepo;
    }

    public void init(String userId) {
        if (this.user != null) {
            // ViewModel 是由 Fragment 建立的
            // 所以我們知道 userId 不會改變
            return;
        }
        user = userRepo.getUser(userId);
    }

    public LiveData<User> getUser() {
        return this.user;
    }
}複製程式碼

快取資料

上述 Repository 的實現對於抽象呼叫 Web 服務是很好的,但是因為它僅依賴於一個資料來源所以不是很實用。

上述 UserRepository 的實現的問題是在獲取到資料之後沒有把資料儲存下來。如果使用者離開 UserProfileFragment 然後返回回來,應用將會重新獲取資料。這樣是很不好的,原因有兩個:一是浪費了寶貴的網路頻寬;二是迫使使用者等待新的查詢完成。為了解決這個問題,我們將在 UserRepository 中新增一個新的資料來源,用以在記憶體中快取 User 物件。

@Singleton  // 告訴 Dagger 這個類只應該構造一次
public class UserRepository {
    private Webservice webservice;
    // 簡單的記憶體快取,為了簡單忽略相關細節
    private UserCache userCache;
    public LiveData<User> getUser(String userId) {
        LiveData<User> cached = userCache.get(userId);
        if (cached != null) {
            return cached;
        }

        final MutableLiveData<User> data = new MutableLiveData<>();
        userCache.put(userId, data);
        // 這還不是最好的但比以前的好。
        // 一個完整的實現必須處理錯誤的情況。
        webservice.getUser(userId).enqueue(new Callback<User>() {
            @Override
            public void onResponse(Call<User> call, Response<User> response) {
                data.setValue(response.body());
            }
        });
        return data;
    }
}複製程式碼

持久化資料

在當前的實現中,如果使用者旋轉螢幕或離開並返回應用,已存在的 UI 將會立即可見,因為 Repository 從記憶體快取中取回資料。但是,如果使用者離開應用並在幾個小時後(Android OS 已經殺死程式後)返回又會發生什麼?

如果是目前的實現,將會需要再次從網路獲取資料。這不僅是一個不好的使用者體驗,並且也是浪費,因為這將會使用移動資料重新獲取同樣的資料。可以通過快取 Web 請求來簡單的解決這個問題,但是這會導致新的問題。如果相同的使用者資料來自另一種請求並顯示(例如:獲取朋友列表)將會怎樣?這時應用會顯示不一致的資料,這是最令人困惑的使用者體驗。例如:相同使用者的資料可能會顯示的不同,因為朋友列表的請求和使用者個人資訊的請求可能會在不同的時間執行。應用程式需要合併它們以避免顯示不一致的資料。

解決這個問題的正確方法是使用持久化模型。這就是持久化庫 Room 的用武之地了。

Room 是一個以最少的樣板程式碼提供本地資料持久化的物件對映庫。在編譯時,它會根據模式驗證每個查詢,所以損壞的 SQL 查詢只會導致編譯時錯誤,而不是執行時崩潰。Room 抽象出一些使用原始 SQL 表查詢的底層實現細節。它還允許觀察資料庫資料(包括集合和連線查詢)的變化,並通過 LiveData 物件暴露這些變化。另外,它明確定義了執行緒約束以解決常見問題(如在主執行緒訪問儲存)。

注:如果你熟悉其它的持久化解決方案,如:SQLite ORM 或像 Realm 等其他的資料庫,你不需要用更換為 Room,除非 Room 的功能對你的用例更加適用。

使用 Room,需要定義我們的區域性模式。首先,用 @Entity 註釋 User 類,將其標記為資料庫中的一個表。

@Entity
class User {
  @PrimaryKey
  private int id;
  private String name;
  private String lastName;
  // 欄位的 get 和 set 方法
}複製程式碼

然後,通過繼承 RoomDatabase 為應用建立一個資料庫。

@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
}複製程式碼

請注意,MyDatabase 是抽象類,Room 會自動提供其實現類。詳細資訊請參閱 Room 的文件。

現在需要一種方法來將使用者資料插入資料庫。為此,我們將建立一個資料庫訪問物件( DAO )

@Dao
public interface UserDao {
    @Insert(onConflict = REPLACE)
    void save(User user);
    @Query("SELECT * FROM user WHERE id = :userId")
    LiveData<User> load(String userId);
}複製程式碼

然後,在資料庫類中引用 DAO。

@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}複製程式碼

請注意,load 方法的返回值是 LiveData。Room 知道資料庫何時被修改,當資料發生變化時,它會通知所有處於活動狀態的觀察者。使用 LiveData 是高效的是因為它只有在至少有一個處於活動狀態的觀察者時才會更新資料。

注:從 alpha 1 版本開始,Room 基於表修改的檢查無效,這意味著它可能會傳送錯誤的通知。

現在修改 UserRepository 來整合 Room 的資料來源。

@Singleton
public class UserRepository {
    private final Webservice webservice;
    private final UserDao userDao;
    private final Executor executor;

    @Inject
    public UserRepository(Webservice webservice, UserDao userDao, Executor executor) {
        this.webservice = webservice;
        this.userDao = userDao;
        this.executor = executor;
    }

    public LiveData<User> getUser(String userId) {
        refreshUser(userId);
        // 直接從資料庫返回一個 LiveData。
        return userDao.load(userId);
    }

    private void refreshUser(final String userId) {
        executor.execute(() -> {
            // 在後臺執行緒中執行
            // 檢查最近是否獲取過 user
            boolean userExists = userDao.hasUser(FRESH_TIMEOUT);
            if (!userExists) {
                // 重新整理資料
                Response response = webservice.getUser(userId).execute();
                // TODO 檢查錯誤等。
                // 更新資料庫。LiveData 將會自動重新整理
                // 所以除了更新資料庫外不需要任何操作。
                userDao.save(response.body());
            }
        });
    }
}複製程式碼

請注意,即使我們更改了 UserRepository 中的資料來源,我們也不需要更改 UserProfileViewModel 或 UserProfileFragment。這是抽象帶來的靈活性。這樣也非常易於測試,因為在測試 UserProfileViewModel 可以提供一個假的 UserRepository。

現在我們的程式碼是完整的,如果使用者日後再回到相同的 UI,他們會立即看到使用者資訊,因為我們已經將其持久化了。同時,如果資料過期,Repository 將會在後臺更新資料。當然,根據你的用例,如果持久化的資料太舊你可能不希望顯示它們。

在一些用例中,如下拉重新整理,在進行網路操作時顯示使用者資料對於 UI 來說非常重要。將 UI 操作從實際資料中分離是一個很好的做法,因為其可能由於各種原因而被更新(例如:如果獲取一個朋友列表,可能會再次獲取到相同的 user 並觸發 LiveData 更新)。從 UI 的角度來看,動態請求實際上只是另一個資料點,類似其它任何資料片段(如:User 物件)

這種用例有兩種常見的解決方案:

  • 更改 getUser 以返回包含網路操作狀態的 LiveData。在附錄:暴露網路狀態部分提供了一個實現例子。

  • 在 Repository 類中提供一個可以返回 User 重新整理狀態的公共方法。如果只是為了響應明確的使用者操作而在 UI 中顯示網路狀態(如:下拉重新整理),則這種方式是更好的選擇。

單一資料來源

不同的 REST API 介面返回相同的資料是很常見的。例如,如果後端有另一個返回朋友列表的介面,相同的使用者物件(也許是不同的粒度)可能來自兩個不同的 API 介面。如果 UserRepository 把從 Webservice 請求獲取到的響應原樣返回,碼麼 UI 可能會顯示不一致的資料,因為在這些請求之間資料可能在服務端發生了改變。這就是為什麼在 UserRepository 的實現中 Web 服務的回撥只是將資料儲存到了資料庫。然後,對資料庫的更改將會觸發處於活動狀態的 LiveData 物件上的回撥。

在這個模型中,資料庫服務作為單一資料來源,應用程式的其它部分通過 Repository 來訪問它。無論是否是否磁碟快取,建議 Repository 指定一個資料來源作為應用程式其它部分的單一資料來源。

測試

我們已經提到分離的好處之一是可測試性。讓我們看看如何測試每個程式碼模組。

  • 使用者介面或使用者互動:這將是唯一一次需要 Android UI Instrumentation test。測試 UI 程式碼的最佳方式是建立一個 Espresso 測試。可以建立一個 fragment 併為其提供一個模擬的 ViewModel。因為 fragment 只和 ViewModel 互動,隨意模擬 ViewModel 足以完全測是 UI。

  • ViewModel:可以使用 JUnit test 來測試 ViewModel。只需要模擬 UserRepository 來測試它。

  • UserRepository:也可以使用 JUnit test 來測試 UserRepository。需要模擬 Webservice 和 DAO。可以測試 UserRepository 是否進行了正確的 Web 服務呼叫,將結構儲存到資料庫,如果資料被快取且是最新的,則不會發起任何不必要的請求。因為 WebService 和 UserDao 都是介面,所以可以模擬它們或者為更復雜的測試用例建立假的實現。

  • UserDao:推薦使用 Instrumentation 測試的方式測試 DAO 類。因為 Instrumentation 測試不需要任何 UI,它們會執行的很快。對於每個測試,可以建立一個記憶體資料庫以確保測試沒有任何副作用(例如:改變磁碟上的資料庫檔案)。

    Room 還允許指定資料庫實現,所以可以通過向其提供 SupportSQLiteOpenHelper 的 JUnit 實現來測試它。通常不推薦這種方式,因為裝置上執行的 SQLite 版本可能和主機上的 SQLite 版本不同。

  • Webservice:重點是使測試相對於外部獨立,所以 Webservice 的測試要避免通過網路呼叫後端。有許多庫可以幫助完成該測試。例如:MockWebServer 是一個很好的庫,可以幫助為測試建立一個假的本地服務。

  • 測試工件架構元件提供了一個 maven 工件來控制其後臺執行緒。在 android.arch.core:core-testing 工件中,有兩個 JUnit 規則:

    • InstantTaskExecutorRule:該規則可用於強制架構元件立即執行呼叫執行緒上的任何後臺操作。

    • CountingTaskExecutorRule:該規則可用於 Instrumentation 測試,以等待架構元件的後臺操作,或將其連線到 Espresso 作為閒置資源。

最終的架構

下圖顯示了推薦架構中的所有模組以及它們如何互相互動:

指導原則

程式設計是一個創作領域,構建 Android 應用也不例外。有許多方法來解決問題,無論是在多個 activity 或 fragment 之間傳遞資料,是獲取遠端資料併為了離線模式將其持久化到本地,還是特殊應用遭遇的其它常見情況。

雖然一下建議不是強制性的,但是以我們的經驗,從長遠來看,遵循這些建議將會使程式碼庫更健壯,易測試和易維護。

  • 在 manifest 中定義的入口點,如:acitivy,fragment,broadcast receiver 等,不是資料來源。相反,它們應該只是協調與該入口點相關的資料子集。由於每個應用程式元件的存活時間很短,這取決於使用者與其裝置的互動以及執行時的總體狀況,所以任何入口點都不應該成為資料來源。

  • 嚴格的在應用程式的各個模組之間建立明確的責任界限。例如:不在程式碼庫中的多個類或包中擴散從網路載入資料的程式碼。同樣,不要將無關的責任(如:資料快取和資料繫結)放到同一個類中。

  • 每個模組儘可能少的暴露出來。不要檢視建立暴露模組內部實現細節的“只一個”的快捷方式。你可能會在短期內節省一些時間,但是隨著程式碼庫的發展,你將會多次償還更多的基數債務。

  • 當定義模組間的互動時,請考慮如何讓每個模組可以獨立的測試。例如,擁有一個用於從網路獲取資料且定義良好的 API 的模組,將會使其更易於測試在本地資料庫中持久化資料。相反,如果將兩個模組的邏輯放在一個地方,或者將網路程式碼擴散到整個程式碼庫,測試將會變的非常困難(並非不可能)。

  • 應用程式的核心是使其脫穎而出。不要花費時間重複造輪子或一次又一次的編寫相同的樣板程式碼。相反,將精力集中在使應用程式獨一無二上,讓 Android Architecture Components 和其它的優秀的庫來處理重複的樣板程式碼。

  • 持久化儘可能多的相關最新資料,以便應用程式在裝置處於離線模式時還可以使用。即使你可以享用穩定高速的網路連線,但是你的使用者可能無法享用。

  • Repository 應該指定一個資料來源作為單一資料來源。每當應用程式需要訪問資料時,資料應該始終來源於單一資料來源。有關更多資訊,請參閱單一資料來源

附錄:暴露網路狀態

在上面推薦的應用程式架構部分,為了保持示例簡單我們故意忽略網路錯誤和載入狀態。在本節中,我們演示一種通過 Resource 類暴露網路狀態來封裝資料和其狀態。

以下是一個實現的例子:

// 描述資料和其狀態的類
public class Resource<T> {
    @NonNull public final Status status;
    @Nullable public final T data;
    @Nullable public final String message;
    private Resource(@NonNull Status status, @Nullable T data, @Nullable String message) {
        this.status = status;
        this.data = data;
        this.message = message;
    }

    public static <T> Resource<T> success(@NonNull T data) {
        return new Resource<>(SUCCESS, data, null);
    }

    public static <T> Resource<T> error(String msg, @Nullable T data) {
        return new Resource<>(ERROR, data, msg);
    }

    public static <T> Resource<T> loading(@Nullable T data) {
        return new Resource<>(LOADING, data, null);
    }
}複製程式碼

因為從磁碟中獲取並顯示資料同時再從網路獲取資料是一種常見的用例。我們將建立一個可以在多個地方使用的幫助類 NetworkBoundResource。下面是 NetworkBoundResource 的決策樹。

它通過觀察資源的資料庫。當首次從資料庫載入條目時,NetworkBoundResource 檢查返回結果是否足夠好可以被髮送和(或)應該從網路獲取資料。請注意,他們可能同時發生,因為你可能會希望在顯示快取資料的同時從網路更新資料。

如果網路呼叫成功,則將返回資料儲存到資料庫中並重新初始化資料流。如果網路請求失敗,直接傳送一個錯誤。

注:將新的資料儲存到磁碟後,要從資料庫重新初始化資料流,但是通常不需要這樣做,因為資料庫將會傳送變更。另一方面,依賴資料庫傳送變更會有一些不好的副作用,因為在資料沒有變化時如果資料庫會避免傳送更改將會使其中斷。我們也不希望傳送從網路返回的結果,因為這違背的單一資料來源原則(即使在資料庫中有觸發器會改變儲存值)。我們也不希望在沒有新資料的時候傳送 SUCCESS,因為這會給客戶端傳送錯誤資訊。

以下是 NetworkBoundResource 類為其子類提供的公共 API:

// ResultType: Resource 資料的型別
// RequestType: API 響應的型別
public abstract class NetworkBoundResource<ResultType, RequestType> {
    // 呼叫該方法將 API 響應的結果儲存到資料庫中。
    @WorkerThread
    protected abstract void saveCallResult(@NonNull RequestType item);

    // 呼叫該方法判斷資料庫中的資料是否應該從網路獲取並更新。
    @MainThread
    protected abstract boolean shouldFetch(@Nullable ResultType data);

    // 呼叫該方法從資料庫中獲取快取資料。
    @NonNull @MainThread
    protected abstract LiveData<ResultType> loadFromDb();

    // 呼叫該方法建立 API 請求。
    @NonNull @MainThread
    protected abstract LiveData<ApiResponse<RequestType>> createCall();

    // 獲取失敗時呼叫。
    // 子類可能需要充值元件(如:速率限制器)。
    @MainThread
    protected void onFetchFailed() {
    }

    // 返回一個代表 Resource 的 LiveData。
    public final LiveData<Resource<ResultType>> getAsLiveData() {
        return result;
    }
}複製程式碼

請注意,上述類定義了兩個型別引數(ResultType,RequestType),因為從 API 返回的資料型別可能和本地使用的資料型別不同。

還要注意,上述程式碼使用 ApiResponse 作為網路請求,ApiResponse 是對於 Retrofit2.Call 類的簡單封裝,用以將其響應轉換為 LiveData。

以下是 NetworkBoundResource 類的其餘實現部分。

public abstract class NetworkBoundResource<ResultType, RequestType> {
    private final MediatorLiveData<Resource<ResultType>> result = new MediatorLiveData<>();

    @MainThread
    NetworkBoundResource() {
        result.setValue(Resource.loading(null));
        LiveData<ResultType> dbSource = loadFromDb();
        result.addSource(dbSource, data -> {
            result.removeSource(dbSource);
            if (shouldFetch(data)) {
                fetchFromNetwork(dbSource);
            } else {
                result.addSource(dbSource,
                        newData -> result.setValue(Resource.success(newData)));
            }
        });
    }

    private void fetchFromNetwork(final LiveData<ResultType> dbSource) {
        LiveData<ApiResponse<RequestType>> apiResponse = createCall();
        // 重新附加 dbSource 作為新的來源,
        // 它將會迅速傳送最新的值。
        result.addSource(dbSource,
                newData -> result.setValue(Resource.loading(newData)));
        result.addSource(apiResponse, response -> {
            result.removeSource(apiResponse);
            result.removeSource(dbSource);
            //noinspection ConstantConditions
            if (response.isSuccessful()) {
                saveResultAndReInit(response);
            } else {
                onFetchFailed();
                result.addSource(dbSource,
                        newData -> result.setValue(
                                Resource.error(response.errorMessage, newData)));
            }
        });
    }

    @MainThread
    private void saveResultAndReInit(ApiResponse<RequestType> response) {
        new AsyncTask<Void, Void, Void>() {

            @Override
            protected Void doInBackground(Void... voids) {
                saveCallResult(response.body);
                return null;
            }

            @Override
            protected void onPostExecute(Void aVoid) {
                // 指定請求一個最新的實時資料。
                // 否則,會得到最新的快取資料,並且可能不會由從網路獲取的最新資料更新。
                result.addSource(loadFromDb(),
                        newData -> result.setValue(Resource.success(newData)));
            }
        }.execute();
    }
}複製程式碼

現在,可以使用 NetworkBoundResource 在 Repository 中編寫磁碟和網路繫結 User 的實現。

class UserRepository {
    Webservice webservice;
    UserDao userDao;

    public LiveData<Resource<User>> loadUser(final String userId) {
        return new NetworkBoundResource<User,User>() {
            @Override
            protected void saveCallResult(@NonNull User item) {
                userDao.insert(item);
            }

            @Override
            protected boolean shouldFetch(@Nullable User data) {
                return rateLimiter.canFetch(userId) && (data == null || !isFresh(data));
            }

            @NonNull @Override
            protected LiveData<User> loadFromDb() {
                return userDao.load(userId);
            }

            @NonNull @Override
            protected LiveData<ApiResponse<User>> createCall() {
                return webservice.getUser(userId);
            }
        }.getAsLiveData();
    }
}複製程式碼

相關文章