Android App架構指南
該指南針的目標人群是已經知道如何建構簡單的app,並且希望瞭解構建健壯的產品級app的最佳實踐和推薦架構。
app開發者面臨的難題
不同於大部分的傳統桌面應用只有一個入口,並且作為一個整體的程序執行,Android app有更加複雜的結構。一個典型的app由多種元件構成,包括activity,fragment,service,content provider和broadcast receiver。
幾乎所有的元件都在app清單裡面進行宣告,這樣Android系統就能夠決定如何將該app整合到整體的使用者體驗中來。一個優良的app需要在使用者的工作流或者任務切換中應對自如。
當你想要在社交網路app中分享一張照片時會發生什麼?app觸發了一個照相機intent,然後系統啟動相機app來響應。這個時候使用者離開了社交網路app,然而體驗沒有被打斷。相機app也可能觸發其他的intent,比如打開了一個檔案選擇器。終端使用者返回社交app完成了照片分享。當然分享的中途可能會進來一個電話打斷了這個過程,然而在通話結束後還是繼續。
Android中應用跳轉是很常見的,因而你的app要能正確應對。始終要記住的是,移動裝置是資源有限的,因而作業系統可能在任何時候殺死一些app以便給其他app挪位子。
這裡的要點是app中的元件可能被單獨啟動並且沒有固定順序,還可能在任何時候被使用者或者系統銷燬。因為app元件是朝生暮死的,它們的生命週期不受你的控制,因此你不應該把資料或者狀態存到app元件裡面並且元件之間不應該相互依賴。
架構的一般原則
如果app元件不能用來存放資料和狀態,那麼app應該如何組織?
首要的一點就是要做到分離關注點(separation of concerns)。一個常見的錯誤做法是將所有的程式碼都寫到Activity或者Fragment中。任何與UI無關的或者不與系統互動的程式碼都不應該放到這些類裡面。儘量保持這一層程式碼很薄能避免很多跟生命週期相關的問題。請記住你並不擁有這些類,它們僅僅是系統和你的app溝通的膠水類(glue class)。系統根據使用者互動或者低記憶體的情況在任意時刻銷燬它們。因此堅實的使用者體驗絕不應該依賴於此。
其次你應該使用model來驅動UI,最好是持久化的model。首推持久化的原因有二,app在任何時候都不會丟失使用者資料即便是在系統銷燬app釋放資源的時候,並且app在網路狀況不佳的時候也能工作正常。model是app裡面負責處理資料的元件,與app的view和其他元件獨立,因而不受這些元件生命週期的影響。保持UI的程式碼簡單並且不包含應用邏輯使得程式碼更加容易管理。基於定義良好的model來建立app,這樣使得app更具有可測性和一致性。
推薦的app架構
接下來我們將通過一個用例來展示如何使用架構元件來組織一個app。
假設我們要建立一個UI用來展示使用者資訊,使用者資訊通過REST API從後臺獲取。
建立使用者介面
UI由UserProfileFragment.java
和佈局檔案user_profile.xml
構成。
要驅動UI,我們的資料model要持有兩個資料元素。
- 使用者ID:使用者的識別符號。最好是使用fragment引數傳遞該資訊。如果系統銷燬了程序,該資訊會被保留,當app重啟後再次可用。
- 使用者物件:一個持有使用者資料的POJO。
我們將基於ViewModel
類建立一個類UserProfileViewModel
用於保持這些資訊。
一個ViewModel用於給特定的UI元件提供資料,例如fragment或者activity,並且負責和資料處理的業務邏輯部分溝通,如通知其它元件載入資料或者轉發使用者修改。ViewModel並不知道特定的View並且也不受配置更改的影響,比如旋轉裝置引起的activity重啟。
現在我們有三個檔案。
- user_profile.xml
- UserProfileViewModel.java
- UserProfileFragment.java
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類,當架構元件中的lifecycle API穩定後,Android支援庫中的Fragment將會實現LifecycleOwner。
如何連線三個程式碼模組呢?畢竟當ViewModel的user域的資料準備妥當後,我們需要一種方式來通知UI。這就輪到LiveData類上場了。
LiveData是可觀測資料(observable data)的持有者。app中的元件訂閱LiveData的狀態變化,不需要顯示的定義依賴。LiveData同樣能應付app元件(activity,fragment,service)的生命週期從而避免記憶體洩漏。
注意:如果你已經使用了
RxJava
或者Agera
這樣的庫,可以繼續作為LiveData的替代使用。如果使用這些替代庫,請確保在相關LifecycleOwner停止的時候暫停資料流並且在LifecycleOwner被銷燬時銷燬資料流。你同樣可以通過新增android.arch.lifecycle:reactivestreams
依賴將LiveData和其他的響應式流庫配合使用(如RxJava2)。
現在將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 -> {
// update UI
});
}
使用者資料一旦變化,onChanged就會被回撥,UI就能得到重新整理。
如果你熟悉其他庫中的可訂閱式回撥,你可能就會意識到我們沒有在fragment的onStop方法中停止訂閱資料。對於LiveData這是不需要的,因為它能處理生命週期,這意味著只有在fragment處於活動狀態時(onStart和onStop之間)才會被回撥。如果fragment被銷燬(onDestroy),LiveData會自動將之從訂閱列表中移除。
我們同樣也不需要處理系統配置變化(例如旋轉螢幕)。ViewModel會在配置變化後自動恢復,新的fragment會得到相同的一個ViewModel例項並且會通過回撥得到現有的資料。這就是ViewModel不應該直接引用View的原因,它們不受View生命週期的影響。
獲取資料
現在已經將ViewModel連線到了fragment,然後ViewModel從哪裡獲取資料呢?本例中,我們假設後臺提供一個REST API,我們使用Retrofit庫來訪問後臺。
下面是retrofit用來和後臺溝通的Webservice介面:
public interface Webservice {
@GET("/users/{user}")
Call<User> getUser(@Path("user") String userId);
}
簡單的做法就是ViewModel直接通過Webservice獲取資料然後賦值給user物件。這樣做雖說沒錯,然而app會變得不容易維護,因此這違反了單一職責原則。除此之外,ViewModel的作用域緊密聯絡到了activity或者fragment生命週期,生命週期結束後資料也扔掉畢竟不是好的使用者體驗。因此,我們的ViewModel會將這項工作委託給一個新的模組Repository。
Repository 模組負責處理資料操作。它向app提供了簡潔的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;
}
}
repository模組看似沒有必要,然而它給app提供了各種資料來源的抽象層。現在ViewModel並不知道資料是來自Webservice,也就意味著這些實現在必要時可以替換。
管理各元件之間的依賴
UserRepository類需要一個Webservice例項來完成工作。可以直接建立,然而Webservice也有依賴。而且UserRepository可能不是唯一使用Webservice的類,如果每個類都建立一個Webservice,那麼就造成了程式碼重複。
有兩種方式可以解決這個問題:
- 依賴注入:依賴注入可以使得類宣告而不建立依賴。在執行時,另外的一個類負責提供這些以來。我們推薦使用Dagger2來完成依賴注入。Dagger2在編譯時審查依賴樹從而自動建立依賴。
- 服務定位:服務定位提供了一個註冊點,需要任何依賴都可以索取。實現起來比依賴注入簡單,因而如果不熟悉DI,可以使用service locator替代。
這些模式都對程式碼的擴充套件提供了便利,既對依賴進行了清晰的管理又沒有引入重複程式碼和額外的複雜度。還有另外一項好處就是便於測試。
在本例中,我們使用Dagger2進行依賴管理。
連線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實現僅僅抽象了web service的呼叫,依賴單一的資料來源,因而不是很實用。
問題在於UserRepository獲取了資料但是沒有暫存起來,這樣當用戶離開UserProfileFragment再回到該介面時,app要重新獲取資料。這樣做有兩個缺點:浪費了網路頻寬和使用者時間。為了解決這個問題,我們給UserRepository添加了另一個數據源用來在記憶體中快取User物件。
@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;
}
}
持久化資料
在當前的實現中,如果使用者旋轉了螢幕或者暫停並再次進入app,由於有快取的存在,UI會立即渲染出資料。然而當用戶離開app後幾個小時系統殺死了該程序,這個時候使用者返回到app會發生什麼呢?
按照當前的實現,app會再次從網路上獲取資料。這不僅僅是糟糕的使用者體驗,而且也浪費移動資料流量。最合適的做法就是持久化model。現在就輪到Room持久化庫出場了。
Room是一個物件對映庫,提供本地資料的持久化。可以在編譯期檢查sql語句。允許將資料庫資料的變化通過LiveData物件的形式暴露出去,而且對資料庫的訪問做了執行緒限制(不能再主執行緒訪問)。
要使用Room,首先定義schema。將User類通過@Entity註解成資料庫中的表
@Entity
class User {
@PrimaryKey
private int id;
private String name;
private String lastName;
// getters and setters for fields
}
然後建立一個數據庫類
@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
}
請注意MyDatabase是抽象類,Room會自動提供實現。
現在我們要將使用者資料插入到資料庫。建立一個data acess object(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會自動通知所有的活動訂閱者。
現在修改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);
// 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的資料來源,UserProfileViewModel和UserProfileFragment卻毫不知情,這就是抽象帶來的好處。由於我們可以偽造UserRepository,對UserProfileViewModel的測試也更加方便。
現在我們的程式碼完成了。即便是使用者幾天後返回到同一個UI,使用者資訊也是即刻呈現的,因為我們已經做了持久化。與此同時,repository也會在後臺更新資料。當然根據應用場景,太舊的資料你可能不會選擇呈現。
在一些應用場景中,如下拉重新整理,如果有網路操作正在進行,那麼提示使用者是很有必要的。將UI操作和實際的資料分開是有好處的,畢竟資料可能因為多種原因更新。
有兩種方法處理這種狀況:
- 更改getUser方法返回一個包含網路操作狀態的LiveData,詳情見附錄。
- 在repository中提供一個公共方法返回User的重新整理狀態。如果僅僅只想在使用者的顯示動作(如下拉重新整理)後顯示狀態,那麼這種方式更好。
真理的唯一持有者(Single source of truth)
不同的REST API端點返回相同的資料是很常見的。例如,如果後臺有另一個端點返回一個朋友列表,那麼同一個使用者物件可能來自兩個不同的API端點,僅僅是粒度不同而已。如果UserRepository將Webservice的請求結果直接返回那麼UI上呈現的資料可能沒有一致性,畢竟來自後臺的資料可能在前後兩次請求時不同。這也就是為何在UserRepository的實現中,web service的回撥僅僅將資料存到資料庫中。然後資料庫的變化會回撥LiveData物件的活動訂閱者。
在這個模型中,資料庫充當了真理的唯一持有者,app的其他部分通過repository訪問它。
測試
之前我們提過,分離關注點的一個好處就是可測性。讓我們分別看看如何測試各個模組。
- 使用者介面和互動:這是唯一需要Android UI Instrumentation test的地方。測試UI的最好方式就是建立一個Espresso測試。你可以建立一個fragment然後提供一個模擬的ViewModel。畢竟fragment僅僅與ViewModel互動,模擬ViewModel就能完整的測試該UI。
- ViewModel:ViewModel可以使用Junit。僅僅需要模擬UserRepository。
- UserRepository:同樣使用JUnit進行測試。你需要模擬Webservice和DAO。你可以測試是否進行了正確的web service請求,然後將請求結果存入資料庫並且在本地資料有快取的情況下不進行多餘的請求。因為Webservice和UserDao都是介面,你可以隨意模擬它們進行更復雜的測試用例。
- UserDao:推薦使用instrumentation測試。因為這些instrumentation測試不需要UI,所有會執行得很快。對於每個測試你都可以建立一個記憶體中的資料庫。
- Webservice:測試應該與外界獨立,因此Webservice的測試也不應該請求實際的後臺。例如,MockWebServer庫可以用於模擬一個本地伺服器以便進行測試。
- 測試套件:架構元件提供了兩個JUnit規則(InstantTaskExecutorRule和CountingTaskExecutorRule),都包含在android.arch.core:core-testing
這個maven artifact中。
最終的架構圖示
附錄: 暴露網路狀態
//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);
}
}
// ResultType: Type for the Resource data
// RequestType: Type for the API response
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();
}
// 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;
}
}
最終的UserRepository是這樣的:
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();
}
}