Google 官方應用架構的最佳實踐指南

Hevin發表於2017-06-19

導語:雖然說 Android 的架構選擇一直都很自由,MVP、MVC、MVVM 各有擁躉。但 Google 最近還是推出了一份關於應用架構的實踐指南,並給出了相當詳盡的步驟和一些指導建議。希望大家都能看一看,學習一下,打造更加優秀易用的 APP,也為 Android 生態的改善做一點貢獻。

最近,官方推出了一份關於應用架構的最佳實踐指南。這裡就給大家簡要介紹一下:

首先,Android 開發者肯定都知道 Android 中有四大元件,這些元件都有各自的生命週期並且在一定程度上是不受你控制的。在任何時候,Android 作業系統都可能根據使用者的行為或資源緊張等原因回收掉這些元件。

這也就引出了第一條準則:「不要在應用程式元件中儲存任何應用資料或狀態,並且元件間也不應該相互依賴」。

最常見的錯誤就是在 Activity 或 Fragment 中寫了與 UI 和互動無關的程式碼。儘可能減少對它們的依賴,這能避免大量生命週期導致的問題,以提供更好的使用者體驗。

第二條準則:「通過 model 驅動應用 UI,並儘可能的持久化」。

這樣做主要有兩個原因:

  1. 如果系統回收了你的應用資源或其他什麼意外情況,不會導致使用者丟失資料。
  2. Model 就應該是負責處理應用程式資料的元件。獨立於檢視和應用程式元件,保持了檢視程式碼的簡單,也讓你的應用邏輯更容易管理。並且,將應用資料置於 model 類中,也更有利於測試。

官方推薦的 App 架構

在這裡,官方演示了通過使用最新推出的 Architecture Components 來構建一個應用。

想象一下,您正在打算開發一個顯示使用者個人資訊的介面,使用者資料通過 REST API 從後端獲取。

首先,我們需要建立三個檔案:

  • user_profile.xml:定義介面。
  • UserProfileViewModel.java:資料類。
  • UserProfileFragment.java:顯示 ViewModel 中的資料並對使用者的互動做出反應。

為了簡單起見,我們這裡就省略掉佈局檔案。

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);
    }
}

注意其中的 ViewModel 和 LifecycleFragment 都是 Android 新引入的,可以參考官方說明進行整合。

現在,我們完成了這三個模組,該如何將它們聯絡起來呢?也就是當 ViewModel 中的使用者欄位被設定時,我們需要一種方法來通知 UI。這就是 LiveData 的用武之地了。

LiveData 是一個可被觀察的資料持有者(用到了觀察者模式)。其能夠允許 Activity, Fragment 等應用程式元件對其進行觀察,並且不會在它們之間建立強依賴。LiveData 還能夠自動響應各元件的宣告週期事件,防止記憶體洩漏,從而使應用程式不會消耗更多的記憶體。

注意: LiveData 和 RxJava 或 Agera 的區別主要在於 LiveData 自動幫助處理了生命週期事件,避免了記憶體洩漏。

所以,現在我們來修改一下 UserProfileViewModel:

public class UserProfileViewModel extends ViewModel {
    ...
    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 -> {
      // update UI
    });
}

獲取資料

現在,我們聯絡了 ViewModel 和 Fragment,但 ViewModel 又怎麼來獲取到資料呢?

在這個示例中,我們假定後端提供了 REST API,因此我們選用 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 直接來獲取資料,這裡我們將工作轉交給一個新的 Repository 模組。

Repository 模組負責資料處理,為應用的其他部分提供乾淨可靠的 API。你可以將其考慮為不同資料來源(Web,快取或資料庫)與應用之間的中間層。

public class UserRepository {
    private Webservice webservice;
    // ...
    public LiveData<User> getUser(int userId) {        
        // This is not an optimal implementation, we'll fix it below
        final MutableLiveData<User> data = new MutableLiveData<>();
        webservice.getUser(userId).enqueue(new Callback<User>() {
            @Override
            public void onResponse(Call<User> call, Response<User> response) {
                // error case is left out for brevity
                data.setValue(response.body());
            }
        });        
        return data;
    }
}

管理元件間的依賴關係

根據上面的程式碼,我們可以看到 UserRepository 中有一個 Webservice 的例項,不要直接在 UserRepository 中 new 一個 Webservice。這很容易導致程式碼的重複與複雜化,比如 UserRepository 很可能不是唯一用到 Webservice 的類,如果每個用到的類都新建一個 Webservice,這顯示會導致資源的浪費。

這裡,我們推薦使用 Dagger 2 來管理這些依賴關係。

現在,讓我們來把 ViewModel 和 Repository 連線起來吧:

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

    @Inject // UserRepository parameter is provided by Dagger 2
    public UserProfileViewModel(UserRepository userRepo) {
        this.userRepo = userRepo;
    }

    public void init(String userId) {        
        if (this.user != null) {         
            // ViewModel is created per Fragment so
            // we know the userId won't change
            return;
        }
        user = userRepo.getUser(userId);
    }

    public LiveData<User> getUser() {        
            return this.user;
    }
}

快取資料

在實際專案中,Repository 往往不會只有一個資料來源。因此,我們這裡在其中再加入快取:

@Singleton  // informs Dagger that this class should be constructed once
public class UserRepository {
    private Webservice webservice;
    // simple in memory cache, details omitted for brevity
    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);        
        // this is still suboptimal but better than before.
        // a complete implementation must also handle the error cases.
        webservice.getUser(userId).enqueue(new Callback<User>() {
            @Override
            public void onResponse(Call<User> call, Response<User> response) {
                data.setValue(response.body());
            }
        });        
        return data;
    }
}

持久化資料

現在當使用者旋轉螢幕或暫時離開應用再回來時,資料是直接可見的,因為是直接從快取中獲取的資料。但要是使用者長時間關閉應用,並且 Android 還徹底殺死了程式呢?

我們目前的實現中,會再次從網路中獲取資料。這可不是一個好的使用者體驗。這時就需要資料持久化了。繼續引入一個新元件 Room。

Room 能幫助我們方便的實現本地資料持久化,抽象出了很多常用的資料庫操作,並且在編譯時會驗證每個查詢,從而損壞的 SQL 查詢只會導致編譯時錯誤,而不是執行時崩潰。還能和上面介紹的 LiveData 完美合作,並幫開發者處理了很多執行緒問題。

現在,讓我們來看看怎麼使用 Room 吧。

首先,在 User 類上面加上 @Entity,將 User 宣告為你資料庫中的一張表。

@Entityclass User {
  @PrimaryKey
  private int id;
  private String name;
  private String lastName; 
 // getters and setters for fields
}

再建立資料庫類並繼承 RoomDatabase:

@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
}

注意 MyDatabase 是一個抽象類,Room 會自動新增實現的。

現在我們需要一種方法來將使用者資料插入到資料庫:

@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 和 Room 搭配的妙用。

現在繼續修改 UserRepository:

@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);        
        // return a LiveData directly from the database.
        return userDao.load(userId);
    }

    private void refreshUser(final String userId) {
        executor.execute(() -> {            
            // running in a background thread
            // check if user was fetched recently
            boolean userExists = userDao.hasUser(FRESH_TIMEOUT);
            if (!userExists) {                
                // refresh the data
                Response response = webservice.getUser(userId).execute(); 
                // TODO check for error etc.
                // Update the database.The LiveData will automatically refresh so
                // we don't need to do anything else here besides updating the database
                userDao.save(response.body());
            }
        });
    }
}

可以看到,即使我們更改了 UserRepository 中的資料來源,我們也完全不需要修改 ViewModel 和 Fragment,這就是抽象的好處。同時還非常適合測試,我們可以在測試 UserProfileViewModel 時提供測試用的 UserRepository。

下面部分的內容在原文中是作為附錄,但我個人覺得也很重要,所以擅自挪上來,一起為大家介紹了。: )

在上面的例子中,有心的大家可能發現了我們沒有處理網路錯誤和正在載入狀態。但在實際開發中其實是很重要的。這裡,我們就實現一個工具類來根據不同的網路狀況選擇不同的資料來源。

首先,實現一個 Resource 類:

//a generic class that describes a data with a status
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 的決策樹:

API 設計:

// ResultType: Type for the Resource data
// RequestType: Type for the API response
public abstract class NetworkBoundResource<ResultType, RequestType> {
    // Called to save the result of the API response into the database
    @WorkerThread
    protected abstract void saveCallResult(@NonNull RequestType item);
    // Called with the data in the database to decide whether it should be
    // fetched from the network.
    @MainThread
    protected abstract boolean shouldFetch(@Nullable ResultType data);

    // Called to get the cached data from the database
    @NonNull @MainThread
    protected abstract LiveData<ResultType> loadFromDb();

    // Called to create the API call.
    @NonNull @MainThread
    protected abstract LiveData<ApiResponse<RequestType>> createCall();    

    // Called when the fetch fails. The child class may want to reset components
    // like rate limiter.
    @MainThread
    protected void onFetchFailed() {
    }    

    // returns a LiveData that represents the resource
    public final LiveData<Resource<ResultType>> getAsLiveData() {
       return result;
    }
}

注意上面使用了 ApiResponse 作為網路請求, ApiResponse 是對於 Retrofit2.Call 的簡單包裝,用於將其響應轉換為 LiveData。

下面是具體的實現:

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();
        // we re-attach dbSource as a new source,
        // it will dispatch its latest value quickly
        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) {
                // we specially request a new live data,
                // otherwise we will get immediately last cached value,
                // which may not be updated with latest results received from network.
                result.addSource(loadFromDb(),
                        newData -> result.setValue(Resource.success(newData)));
            }
        }.execute();
    }
}

現在,我們就能使用 NetworkBoundResource 來根據不同的情況獲取資料了:

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();
    }
}

到這裡,我們的程式碼就全部完成了。最後的架構看起來就像這樣:

最後的最後,給出一些指導原則

下面的原則雖然不是強制性的,但根據我們的經驗遵循它們能使您的程式碼更健壯、可測試和可維護的。

  • 所有您在 manifest 中定義的元件 – activity, service, broadcast receiver… 都不是資料來源。因為每個元件的生命週期都相當短,並取決於當前使用者與裝置的互動和系統的執行狀況。簡單來說,這些元件都不應當作為應用的資料來源。
  • 在您應用的各個模組之間建立明確的責任邊界。比如,不要將與資料快取無關的程式碼放在同一個類中。
  • 每個模組儘可能少的暴露內部實現。從過去的經驗來看,千萬不要為了一時的方便而直接將大量的內部實現暴露出去。這會讓你在以後承擔很重的技術債務(很難更換新技術)。
  • 在您定義模組間互動時,請考慮如何使每個模組儘量隔離,通過設計良好的 API 來進行互動。
  • 您應用的核心應該是能讓它脫穎而出的某些東西。不要浪費時間重複造輪子或一次次編寫同樣的模板程式碼。相反,應當集中精力在使您的應用獨一無二,而將一些重複的工作交給這裡介紹的 Android Architecture Components 或其他優秀的庫。
  • 儘可能持久化資料,以便您的應用在離線模式下依然可用。雖然您可能享受著快捷的網路,但您的使用者可能不會。

相關文章