架構原則
關注點分離
- 一個元件應該只關注一個簡單的問題,只負責完成一項簡單的任務,應該盡少依賴其它元件
- 就算依賴另一個元件,也不能同時依賴它下下一級的元件,要像網路協議分層一樣簡單明確
Activity
和Fragment
作為作業系統和應用之間的粘合類,不應該將所有程式碼寫在它們裡面,它們甚至可以看成是有生命週期的普通 View,大部分情況下就是 被 用來簡單 顯示資料的
模型驅動檢視
- 為了保證資料 model 和它對應顯示的 UI 始終是一致的,應該用 model 驅動 UI,而且最好是是持久化 model。model 是負責處理應用資料的元件,只關心資料
單一資料來源
- 為了保證資料的一致性,必須實現相同的資料來自同一個資料來源。如: 好友列表頁顯示了好友的備註名,資料來源於伺服器的
api/friends
響應,好友詳情頁也顯示了好友的備註名,資料來源於伺服器的api/user
響應,此時在好友詳情頁更改了對這個好友的備註名,那麼好友列表並不知情,它的資料模型並沒有發生變化,所以還是顯示原來的備註名,這就產生了資料不一致的問題 - 要實現單一資料來源(Single source of truth),最簡單的方式就是將本地資料庫作為單一資料來源,主鍵和外來鍵的存在保證了資料對應實體的一致性
推薦架構
Android Jetpack 元件庫中有一個叫 Architecture Components 的元件集,裡面包含了 Data Binding,Lifecycles,LiveData,Navigation,Paging,Room,ViewModel,WorkManager 等元件的實現ViewModel
用來為指定的 UI 元件提供資料,它只負責根據業務邏輯獲取合適的資料,他不知道 View 的存在,所以它不受系統銷燬重建的影響,一般它的生命週期比 View 更長久LiveData
是一個資料持有者,它持有的資料可以是任何 Object 物件。它類似於傳統觀察者模式中的 Observable,當它持有的資料發生變化時會通知它所有的 Observer。同時它還可以感知 Activity,Fragment 和 Service 的生命週期,只通知它們中 active 的,在生命週期結束時自動取消訂閱Activity/Fragment
持有ViewModel
進行資料的渲染,ViewModel
持有LiveData
形式的資料以便尊重應用元件的生命週期,但是獲取LiveData
的具體實現應該由 Repository 完成- Repository 是資料的抽象,它提供簡潔一致的運算元據的 API,內部封裝好對持久化資料、快取資料、後臺伺服器資料等資料來源資料的操作。所以
ViewModel
不關心資料具體是怎麼獲得的,甚至可以不關心資料到底是從哪拿到的
實踐
基礎設施建設
建立專案時要勾選 【Use AndroidX artifacts】 核取方塊以便自動使用 AndroidX 支援庫,否則需要手動在 gradle.properties
檔案中新增
android.useAndroidX=true
android.enableJetifier=true
複製程式碼
然後在專案根目錄建立 versions.gradle
檔案,以便統一管理依賴和版本號
ext.deps = [:]
def build_versions = [:]
build_versions.min_sdk = 14
build_versions.target_sdk = 28
ext.build_versions = build_versions
def versions = [:]
versions.android_gradle_plugin = "3.3.0"
versions.support = "1.1.0-alpha01"
versions.constraint_layout = "1.1.3"
versions.lifecycle = "2.0.0"
versions.room = "2.1.0-alpha04"
versions.retrofit = "2.5.0"
versions.okhttp = "3.12.1"
versions.junit = "4.12"
versions.espresso = "3.1.0-alpha4"
versions.atsl_runner = "1.1.0-alpha4"
versions.atsl_rules = "1.1.0-alpha4"
def deps = [:]
deps.android_gradle_plugin = "com.android.tools.build:gradle:$versions.android_gradle_plugin"
def support = [:]
support.app_compat = "androidx.appcompat:appcompat:$versions.support"
support.v4 = "androidx.legacy:legacy-support-v4:$versions.support"
support.constraint_layout = "androidx.constraintlayout:constraintlayout:$versions.constraint_layout"
support.recyclerview = "androidx.recyclerview:recyclerview:$versions.support"
support.cardview = "androidx.cardview:cardview:$versions.support"
support.design = "com.google.android.material:material:$versions.support"
deps.support = support
def lifecycle = [:]
lifecycle.runtime = "androidx.lifecycle:lifecycle-runtime:$versions.lifecycle"
lifecycle.extensions = "androidx.lifecycle:lifecycle-extensions:$versions.lifecycle"
lifecycle.java8 = "androidx.lifecycle:lifecycle-common-java8:$versions.lifecycle"
lifecycle.compiler = "androidx.lifecycle:lifecycle-compiler:$versions.lifecycle"
deps.lifecycle = lifecycle
def room = [:]
room.runtime = "androidx.room:room-runtime:$versions.room"
room.compiler = "androidx.room:room-compiler:$versions.room"
deps.room = room
def retrofit = [:]
retrofit.runtime = "com.squareup.retrofit2:retrofit:$versions.retrofit"
retrofit.gson = "com.squareup.retrofit2:converter-gson:$versions.retrofit"
deps.retrofit = retrofit
deps.okhttp_logging_interceptor = "com.squareup.okhttp3:logging-interceptor:${versions.okhttp}"
deps.junit = "junit:junit:$versions.junit"
def espresso = [:]
espresso.core = "androidx.test.espresso:espresso-core:$versions.espresso"
deps.espresso = espresso
def atsl = [:]
atsl.runner = "androidx.test:runner:$versions.atsl_runner"
deps.atsl = atsl
ext.deps = deps
複製程式碼
以顯示 谷歌的開源倉庫列表(api.github.com/users/googl…)為例,先依賴好 ViewModel
、LiveData
和 Retrofit
:
apply plugin: 'com.android.application'
android {
compileSdkVersion build_versions.target_sdk
defaultConfig {
applicationId "cn.frank.sample"
minSdkVersion build_versions.min_sdk
targetSdkVersion build_versions.target_sdk
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
lintOptions {
abortOnError false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation deps.support.app_compat
implementation deps.support.constraint_layout
implementation deps.lifecycle.runtime
implementation deps.lifecycle.extensions
annotationProcessor deps.lifecycle.compiler
implementation deps.room.runtime
annotationProcessor deps.room.compiler
implementation deps.retrofit.runtime
implementation deps.retrofit.gson
implementation deps.okhttp_logging_interceptor
testImplementation deps.junit
androidTestImplementation deps.atsl.runner
androidTestImplementation deps.espresso.core
}
複製程式碼
然後根據習慣合理地設計原始碼的目錄結構,如
public class RepoRepository {
private static RepoRepository sInstance;
public RepoRepository() {
}
public static RepoRepository getInstance() {
if (sInstance == null) {
synchronized (RepoRepository.class) {
if (sInstance == null) {
sInstance = new RepoRepository();
}
}
}
return sInstance;
}
public LiveData<List<Repo>> getRepo(String userId) {
final MutableLiveData<List<Repo>> data = new MutableLiveData<>();
ServiceGenerator.createService(GithubService.class)
.listRepos(userId)
.enqueue(new Callback<List<Repo>>() {
@Override
public void onResponse(Call<List<Repo>> call, Response<List<Repo>> response) {
data.setValue(response.body());
}
@Override
public void onFailure(Call<List<Repo>> call, Throwable t) {
}
});
return data;
}
}
複製程式碼
public class RepoViewModel extends AndroidViewModel {
private LiveData<List<Repo>> repo;
private RepoRepository repoRepository;
public RepoViewModel(@NonNull Application application) {
super(application);
this.repoRepository = ((SampleApp) application).getRepoRepository();
}
public void init(String userId) {
if (this.repo != null) {
return;
}
this.repo = repoRepository.getRepo(userId);
}
public LiveData<List<Repo>> getRepo() {
return repo;
}
}
複製程式碼
public class RepoFragment extends Fragment {
private static final String ARG_USER_ID = "user_id";
private RepoViewModel viewModel;
private TextView repoTextView;
public RepoFragment() {
}
public static RepoFragment newInstance(String userId) {
RepoFragment fragment = new RepoFragment();
Bundle args = new Bundle();
args.putString(ARG_USER_ID, userId);
fragment.setArguments(args);
return fragment;
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_repo, container, false);
repoTextView = (TextView) rootView.findViewById(R.id.repo);
return rootView;
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
Bundle args = getArguments();
if (args != null) {
String userId = args.getString(ARG_USER_ID);
viewModel = ViewModelProviders.of(this).get(RepoViewModel.class);
viewModel.init(userId);
viewModel.getRepo().observe(this, new Observer<List<Repo>>() {
@Override
public void onChanged(List<Repo> repos) {
StringBuilder builder = new StringBuilder();
if (repos != null) {
for (Repo repo : repos) {
builder.append(repo.getFull_name()).append("\n");
}
}
repoTextView.setText(builder);
}
});
}
}
}
複製程式碼
這是最簡單直接的實現,但還是存下很多模板程式碼,還有很多地方可以優化
- 既然 View 是和 ViewModel 繫結在一起的,那為什麼每次都要先
findViewById()
再setText()
呢?在宣告或者建立 View 的時候就給它指定好對應的 ViewModel 不是更簡單直接麼 - 網路請求的結果最好都快取到記憶體和資料庫中,既保證了單一資料來源原則又能提升使用者體驗
Data Binding
對於第一個問題,Data Binding 元件是一個還算不錯的實現,可以在佈局檔案中使用 表示式語言 直接給 View 繫結資料,繫結可以是單向的也可以是雙向的。Data Binding 這樣繫結可以避免記憶體洩漏,因為它會自動取消繫結。可以避免空指標,因為它會寬容評估表示式。可以避免同步問題,可以在後臺執行緒更改非集合資料模型,因為它會在評估時本地化資料
為了使用 Data Binding,需要在 app module 的 build.gradle
檔案中新增
dataBinding {
enabled = true
}
複製程式碼
利用 @{}
語法可以給 View 的屬性繫結資料變數,但是該表示式語法應該儘可能簡單直接,複雜的邏輯應該藉助於自定義 BindingAdapter
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable name="user" type="com.example.User"/>
</data>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{user.firstName}"/>
</LinearLayout>
</layout>
複製程式碼
不需要重新編譯程式碼,構建工具就會為每個這樣的佈局檔案自動生成一個對應的繫結類,繼承自 ViewDataBinding
,路徑為 app/build/generated/data_binding_base_class_source_out/debug/dataBindingGenBaseClassesDebug/out/cn/frank/sample/databinding/FragmentRepoBinding.java
,預設的類名是佈局檔名的大駝峰命名加上 Binding 字尾,如 fragment_repo.xml
對應 FragmentRepoBinding
,可以通過 <data class=".ContactItem">
自定義類名和所在包名。可以通過 DataBindingUtil
的 inflate()
等靜態方法或自動生成的繫結類的 inflate()
等靜態方法獲取繫結類的例項,然後就可以操作這個例項了
操作符和關鍵字
這個表示式語言的 操作符和關鍵字 包括: 數學運算 + - / * %
,字串拼接 +
,邏輯 && ||
,二進位制運算 & | ^
,一元操作符 + - ! ~
,移位 >> >>> <<
,比較 == > < >= <=
,判斷例項 instanceof
,分組 ()
,字元/字串/數字/null
的字面量,強制轉化,方法呼叫,欄位訪問,陣列訪問 []
,三目運算子 ?:
,二目空預設運算子 ??
android:text="@{String.valueOf(index + 1)}"
android:visibility="@{age > 13 ? View.GONE : View.VISIBLE}"
android:transitionName='@{"image_" + id}'
android:text="@{user.displayName ?? user.lastName}"
android:text="@{user.lastName}"
android:padding="@{large? @dimen/largePadding : @dimen/smallPadding}"
android:text="@{@string/nameFormat(firstName, lastName)}"
android:text="@{@plurals/banana(bananaCount)}"
複製程式碼
小於比較符 <
需要轉義為 <
,為了避免字串轉義單引號和雙引號可以隨便切換使用
<import>
的類衝突時可以取別名加以區分
<import type="android.view.View"/>
<import type="com.example.real.estate.View"
alias="Vista"/>
複製程式碼
<include>
佈局中可以傳遞變數
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:bind="http://schemas.android.com/apk/res-auto">
<data>
<variable name="user" type="com.example.User"/>
</data>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/name"
bind:user="@{user}"/>
<include layout="@layout/contact"
bind:user="@{user}"/>
</LinearLayout>
</layout>
複製程式碼
不支援 <merge>
結合 <include>
的使用
事件處理
View 事件的分發處理有兩種機制,一種是 Method references,在表示式中直接通過監聽器方法的簽名來引用,Data Binding 會在編譯時評估這個表示式,如果方法不存在或者簽名錯誤那麼編譯就會報錯,如果表示式評估的結果是 null
那麼 Data Binding 就不會建立監聽器而是直接設定 null
監聽器,Data Binding 在 繫結資料的時候 就會建立監聽器的例項: android:onClick="@{handlers::onClickFriend}"
。一種是 Listener bindings,Data Binding 在 事件發生的時候 才會建立監聽器的例項並設定給 view然後評估 lambda 表示式,android:onClick="@{(theView) -> presenter.onSaveClick(theView, task)}"
繫結 Observable 資料
雖然 View 可以繫結任何 PO 物件,但是所繫結物件的更改並不能自動引起 View 的更新,所以 Data Binding 內建了 Observable
介面和它的 BaseObservable
,ObservableBoolean
等子類可以方便地將物件、欄位和集合變成 observable
private static class User {
public final ObservableField<String> firstName = new ObservableField<>();
public final ObservableInt age = new ObservableInt();
}
複製程式碼
private static class User extends BaseObservable {
private String firstName;
private String lastName;
@Bindable
public String getFirstName() {
return this.firstName;
}
@Bindable
public String getLastName() {
return this.lastName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
notifyPropertyChanged(BR.firstName);
}
public void setLastName(String lastName) {
this.lastName = lastName;
notifyPropertyChanged(BR.lastName);
}
}
複製程式碼
執行繫結
有時候繫結需要立即執行,如在 onBindViewHolder()
方法中:
public void onBindViewHolder(BindingHolder holder, int position) {
final T item = mItems.get(position);
holder.getBinding().setVariable(BR.item, item);
holder.getBinding().executePendingBindings();
}
複製程式碼
Data Binding 在為 View 設定表示式的值的時候會自動選擇對應 View 屬性的 setter 方法,如 android:text="@{user.name}"
會選擇 setText()
方法,但是像 android:tint
屬性沒有 setter 方法,可以使用 BindingMethods
註解自定義方法名
@BindingMethods({
@BindingMethod(type = "android.widget.ImageView",
attribute = "android:tint",
method = "setImageTintList"),
})
複製程式碼
如果要自定義 setter 方法的繫結邏輯,可以使用 BindingAdapter
註解
@BindingAdapter("android:paddingLeft")
public static void setPaddingLeft(View view, int padding) {
view.setPadding(padding,
view.getPaddingTop(),
view.getPaddingRight(),
view.getPaddingBottom());
}
複製程式碼
<ImageView app:imageUrl="@{venue.imageUrl}" app:error="@{@drawable/venueError}" />
複製程式碼
@BindingAdapter({"imageUrl", "error"})
public static void loadImage(ImageView view, String url, Drawable error) {
Picasso.get().load(url).error(error).into(view);
}
複製程式碼
如果要自定義表示式值的自動型別轉換,可以使用 BindingConversion
註解
<View
android:background="@{isError ? @color/red : @color/white}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
複製程式碼
@BindingConversion
public static ColorDrawable convertColorToDrawable(int color) {
return new ColorDrawable(color);
}
複製程式碼
ViewModel
可以實現 Observable
介面並結合 PropertyChangeRegistry
可以更方便地控制資料更改後的行為
雙向繫結
使用 @={}
符號可以實現 View 和資料的雙向繫結
<CheckBox
android:id="@+id/rememberMeCheckBox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="@={viewmodel.rememberMe}" />
複製程式碼
public class LoginViewModel extends BaseObservable {
// private Model data = ...
@Bindable
public Boolean getRememberMe() {
return data.rememberMe;
}
public void setRememberMe(Boolean value) {
// 為了防止無限迴圈,必須要先檢查再更新
if (data.rememberMe != value) {
data.rememberMe = value;
saveData();
notifyPropertyChanged(BR.remember_me);
}
}
}
複製程式碼
自定義屬性的雙向繫結還需要藉助 @InverseBindingAdapter
和 @InverseBindingMethod
@BindingAdapter("time")
public static void setTime(MyView view, Time newValue) {
// Important to break potential infinite loops.
if (view.time != newValue) {
view.time = newValue;
}
}
@InverseBindingAdapter("time")
public static Time getTime(MyView view) {
return view.getTime();
}
複製程式碼
監聽屬性的更改,事件屬性以 AttrChanged
作為字尾
@BindingAdapter("app:timeAttrChanged")
public static void setListeners(
MyView view, final InverseBindingListener attrChange) {
// Set a listener for click, focus, touch, etc.
}
複製程式碼
可以藉助轉換器類定製 View 的顯示規則
<EditText
android:id="@+id/birth_date"
android:text="@={Converter.dateToString(viewmodel.birthDate)}" />
複製程式碼
public class Converter {
@InverseMethod("stringToDate")
public static String dateToString(EditText view, long oldValue,
long value) {
// Converts long to String.
}
public static long stringToDate(EditText view, String oldValue,
String value) {
// Converts String to long.
}
}
複製程式碼
Data Binding 內建了 android:text
,android:checked
等的雙向繫結
生命週期敏感元件
在 Activity 或 Fragment 的生命週期方法中進行其它元件的配置並不總是合理的,如在 onStart()
方法中註冊廣播接收器 A、開啟定位服務 A、啟用元件 A 的監聽、啟用元件 B 的監聽等等,在 onStop()
方法中登出廣播接收器 A、關閉定位服務 A、停用元件 A 的監聽、停用元件 B 的監聽等等,隨著業務邏輯的增加這些生命週期方法變得越來越臃腫、越來越亂、越來越難以維護,如果這些元件在多個 Activity 或 Fragment 上使用那麼還得重複相同的邏輯,就更難以維護了。 而且如果涉及到非同步甚至 沒辦法保證 onStart()
方法中的程式碼 一定 在 onStop()
方法執行前執行
關注點分離,這些元件的行為受生命週期的影響,所以它們自己應該意識到自己是生命週期敏感的元件,當生命週期變化時它們應該 自己決定 自己的行為,而不是交給生命週期的擁有者去處理
生命週期有兩個要素: 事件和狀態,生命週期事件的發生一般會導致生命週期狀態的改變
生命週期敏感元件應該實現 LifecycleObserver
以觀察 LifecycleOwner
的生命週期,支援庫中的 Activity 和 Fragment 都實現了 LifecycleOwner
,可以直接通過它的 getLifecycle()
方法獲取 Lifecycle
例項
MainActivity.this.getLifecycle().addObserver(new MyLocationListener());
複製程式碼
class MyLocationListener implements LifecycleObserver {
private boolean enabled = false;
@OnLifecycleEvent(Lifecycle.Event.ON_START)
void start() {
if (enabled) {
// connect
}
}
public void enable() {
enabled = true;
if (lifecycle.getCurrentState().isAtLeast(STARTED)) {
// connect if not connected
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
void stop() {
// disconnect if connected
}
}
複製程式碼
GenericLifecycleObserver
介面繼承了 LifecycleObserver
,有一個介面方法 onStateChanged(LifecycleOwner, Lifecycle.Event)
表明它可以接收所有的生命週期過渡事件
LiveData
它的 observe()
方法原始碼
@MainThread
public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<? super T> observer) {
assertMainThread("observe");
if (owner.getLifecycle().getCurrentState() == DESTROYED) {
// ignore
return;
}
LifecycleBoundObserver wrapper = new LifecycleBoundObserver(owner, observer);
ObserverWrapper existing = mObservers.putIfAbsent(observer, wrapper);
if (existing != null && !existing.isAttachedTo(owner)) {
throw new IllegalArgumentException("Cannot add the same observer"
+ " with different lifecycles");
}
if (existing != null) {
return;
}
owner.getLifecycle().addObserver(wrapper);
}
複製程式碼
說明 LiveData
只能在主執行緒中訂閱,訂閱的觀察者被包裝成生命週期元件的觀察者 LifecycleBoundObserver
class LifecycleBoundObserver extends ObserverWrapper implements GenericLifecycleObserve
@NonNull
final LifecycleOwner mOwner;
LifecycleBoundObserver(@NonNull LifecycleOwner owner, Observer<? super T> observer)
super(observer);
mOwner = owner;
}
@Override
boolean shouldBeActive() {
return mOwner.getLifecycle().getCurrentState().isAtLeast(STARTED);
}
@Override
public void onStateChanged(LifecycleOwner source, Lifecycle.Event event) {
if (mOwner.getLifecycle().getCurrentState() == DESTROYED) {
removeObserver(mObserver);
return;
}
activeStateChanged(shouldBeActive());
}
@Override
boolean isAttachedTo(LifecycleOwner owner) {
return mOwner == owner;
}
@Override
void detachObserver() {
mOwner.getLifecycle().removeObserver(this);
}
}
複製程式碼
當觀察到生命週期狀態變化時會呼叫 onStateChanged()
方法,所以當狀態為 DESTROYED
的時候會移除資料觀察者和生命週期觀察者,shouldBeActive()
方法的返回值表明只有生命週期狀態是 STARTED
和 RESUMED
的 LifecycleOwner
對應的資料觀察者才是 active 的,只有 active 的資料觀察者才會被通知到,當資料觀察者 第一次 從 inactive 變成 active 時,也會 收到通知
observeForever()
方法也可以訂閱,但是 LiveData
不會自動移除資料觀察者,需要主動呼叫 removeObserver()
方法移除
LiveData
的 MutableLiveData
子類提供了 setValue()
方法可以在主執行緒中更改所持有的資料,還提供了 postValue()
方法可以在後臺執行緒中更改所持有的資料
可以繼承 LiveData
實現自己的 observable 資料,onActive()
方法表明有 active 的觀察者了,可以進行資料更新通知了,onInactive()
方法表明沒有任何 active 的觀察者了,可以清理資源了
單例的 LiveData
可以實現多個 Activity 或 Fragment 的資料共享
可以對 LiveData
持有的資料進行變換,需要藉助 Transformations
工具類
private final PostalCodeRepository repository;
private final MutableLiveData<String> addressInput = new MutableLiveData();
public final LiveData<String> postalCode =
Transformations.switchMap(addressInput, (address) -> {
return repository.getPostCode(address);
});
複製程式碼
private LiveData<User> getUser(String id) {
...;
}
LiveData<String> userId = ...;
LiveData<User> user = Transformations.switchMap(userId, id -> getUser(id) );
複製程式碼
LiveData
的 MediatorLiveData
子類可以 merge 多個 LiveData 源,可以像 ReactiveX 的操作符一樣進行各種變換