如何優雅地構建易維護、可複用的 Android 業務流程(二)

prototypez發表於2018-08-11

這是關於如何在 Android 中封裝業務流程經驗分享的第二篇,第一篇在這裡。所謂 業務流程 ,指的是一系列頁面的集合,這些頁面肩負著一個特定職責,負責和使用者互動,從使用者端收集資訊。業務流程有時候由使用者主動觸發,而有時候是由於某些條件不滿足而觸發,當流程完成以後,有時候只是簡單地回到發起流程的頁面,用流程的結果更新那個頁面;而有時候是繼續之前 由於觸發流程而中斷 的操作;還有些時候是則是轉入新的流程。

回顧

上一篇分享中,我根據自己在公司專案中的實踐,列舉了七點流程框架應該解決的問題。同時也記錄了在選型過程中調研和嘗試的若干種方案,它們的優點與不足,以及最後為什麼放棄的原因,這些方案分別是:

  • 簡單的基於 startActivityForResult/onActivityResult
  • 基於 EventBus 或者其他基於事件匯流排的方案
  • 簡單的基於 FLAG_ACTIVITY_CLEAR_TOP 或者設定 launchMode 為 singleTop / singleTask
  • 開闢新的 Activity 任務棧

上一篇分享中提出的最後一個方案 -- 使用 Fragment 框架封裝流程,是我相對而言比較滿意的方案,它並不是完美的方案,至少它沒有全部解決我自己提出的對流程框架的七個問題,但是從現階段來看,在複雜程度,易用性和可靠性上,這種方案已經足夠滿足我們日常開發所需,在我發現更好的方案之前,我認為還是值得介紹一下的:)

簡單回顧一下,這種方案就是一個 Activity 對應一個流程,這個 Activity 就是這個流程對外暴露的介面,具體暴露的介面是 startActivityForResultonActivityResult, 任何觸發流程的位置,都只通過這兩個方法,和代表流程的 Activity 進行互動。

流程的每一個步驟(頁面),被封裝為一個個 Fragment, Fragment 只和宿主 Activity 互動,通常就是把本步驟的資料傳遞給宿主 Activity 以及通知宿主 Activity 本步驟已做完。宿主 Activity 即流程 Activity。

流程 Activity 除了擔當本流程對外介面任務以外,還要承擔流程內部步驟間的流轉,其實就是對代表步驟的 Fragment 的新增與移除。

以登入流程為例(包含兩個步驟:使用者名稱密碼驗證、需要手機驗證碼的兩步驗證), 整個流程與流程觸發點(例如首頁資訊流點贊操作)的互動以及流程內部 Fragment 棧如下圖所示:

如何優雅地構建易維護、可複用的 Android 業務流程(二)

而流程宿主 Activity 與代表流程的每個具體步驟的 Fragment 的互動可以用下圖來表示:

如何優雅地構建易維護、可複用的 Android 業務流程(二)

如何優化流程與外部互動介面

上一篇分享中,最後有提到基於 startActivityForResultonActivityResult 兩個方法來發起流程和接收流程結果是 不優雅 的,而且這種寫法也不利於流程在其他位置被複用。例如登入操作在點贊時可能被觸發,在評論時也可能被觸發,常規寫法只會讓 Activity / Fragment 過於臃腫。

我們希望的結果是,發起流程可以被封裝為一個普通的非同步操作,然後我們就可以像對待普通的非同步任務那樣,為這個流程指派一個觀察者來監聽非同步結果。但是封裝的難點在於我們並不容易在 startActivityForResult 的位置獲取一個物件,這個物件可以在 onActivityResult 的時候獲得回撥,根源在於 onActivityResult 並不屬於 Activity / Fragment 的生命週期函式,所以無論是 Google 官方的 Lifecycle Component 還是第三方的 RxLifecycle 都不包含這個回撥。

但是我們還是有機會通過別的辦法在 startActivityForResult 的位置拿到 onActivityResult 這個回撥的觀察者。其中一種方案就是借鑑 Glide 的思想,Glide 可以為非同步操作繫結 Activity 的生命週期,它的原理就是為發起請求的 Activity 新增一個 不可見的 Fragment,大家知道,Fragment 也可以發起 startActivityForResult 操作,並通過 onActivityResult 接受結果。

到這裡,我們的思路就清晰了。我們的 Activity 自己不需要發起 startActivityForResult,而是新建一個不可見的 Fragemnt ,然後把這個任務交給它,Fragment 就相當於一個觀察者, Activity 持有這個 Fragment 物件,Fragment 可以在自己收到 onActivityResult 的時候把結果通知 Activity。

這種做法有兩個好處:首先,

  1. 如果我們自己建立一個觀察者,那麼通常會被放在全域性作用空間。那麼就需要仔細考慮物件生命週期繫結問題,以防止可能造成的記憶體洩漏。Fragment 屬於 Android 框架的一部分,只要正常使用(例如不要錯誤使用 static 引用,謹慎使用匿名內部類),就不會造成記憶體洩漏。

  2. 自己建立的觀察者物件,在 程式被殺死重新建立 或者 後臺Activity被回收 的情況下,可能無法正常恢復,一方面可能導致無法接收到 onActivityResult 的結果,另一方面有可能導致應用 Crash(通常是因為空指標)。而如果我們把觀察者的任務交給 Fragment,由於 Fragment 被 Activity 的 FragmentManager 管理,即使 Activity 由於系統的原因被銷燬重新建立了,還是可以保證觀察者自身被正確恢復,並且正常收到 onActivityResult 回撥。

使用 RxJava 進行封裝

上一小節提到,我們希望可以像對待一個普通的非同步任務一樣,對待業務流程。對於像我們這樣的已經在專案中引入 RxJava 的團隊來說,使用 RxJava 封裝自然是首選。

首先,onActivityResult 這個回撥中回傳給我們的 3 個引數,我們單獨封裝為一個類:

public class ActivityResult {

    private int requestCode;
    private int resultCode;
    private Intent data;

    public ActivityResult(int requestCode, int resultCode, Intent data) {
        this.requestCode = requestCode;
        this.resultCode = resultCode;
        this.data = data;
    }

    // getters & setters
}
複製程式碼

本文在第一節的時候提到:流程 有時候是由於某些條件不滿足而觸發 的,舉一個簡單的例子:社交 App 的點贊操作需要登入態才可以進行,那麼處理點贊事件的程式碼很有可能是這樣的:

likeBtn.setOnClickListener(v -> {
    if (LoginInfo.isLogin()) {
        doLike();
    } else {
        startLoginProcess()
            .subscribe(this::doLike);
    }
})
複製程式碼

上面的程式碼中,我們假設 startLoginProcess 為一個封裝好的登入流程,它的返回型別為 Observable<ActivityResult>。像這樣的條件檢測並且發起流程的類似程式碼很多,一個非同步任務,把原本邏輯上流暢的一個程式碼流程給拆成兩部分。為了可以讓這部分更優雅,我們其實可以把 LoginInfo.isLogin()true 這種情況也視為 startLoginProcess 這個 Observable 所發射的資料。到目前為止 ActivityResult 這個物件我們已經單獨封裝好了,我們可以自行例項化這個物件,無需依賴 onActivityResult 回撥來構造這個物件:

public Observable<ActivityResult> loginState() {
    if (LoginInfo.isLogin()) {
        return Observable.just(new ActivityResult(REQUEST_CODE_LOGIN, RESULT_OK, new Intent()));
    } else {
        return startLoginProcess();
    }
}
複製程式碼

這樣一來,檢測使用者是否登入,在 使用者已經登入使用者一開始沒登入但是通過登入流程以後登入成功 的情況下,執行點贊操作的程式碼就變成了下面這樣:

likeBtn.setOnClickListener(v -> {
    loginState()
        .subscribe(this::doLike);
})
複製程式碼

雖然整體上程式碼量並沒有變少,但是邏輯上更加清晰了,loginState 這個方法也更容易被其他地方所複用了。

我們繼續剛才的討論,上文中假設的 startLoginProcess 這個方法我們還沒有提供實現,根據再往前的討論,我們不難猜出,startLoginProcess 這個方法的實現應該是 Activity 把發起流程的任務交給 Fragment 這樣的邏輯, Fragment 承擔著 onActivityResult 這個方法的觀察者的責任,為了可以在應對 條件檢測 - 發起流程 這類情景時,Fragment 對外部有更加簡潔和一致的介面,我們允許這個 Fragment 除了在它自己得到 onActivityResult 回撥時例項化一個 ActivityResult 這種情況以外,也可以手動插入一個 ActivityResult 物件。具體程式碼如下:

public class ActivityResultFragment extends Fragment {

    private final BehaviorSubject<ActivityResult> mActivityResultSubject = BehaviorSubject.create();

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        mActivityResultSubject.onNext(new ActivityResult(requestCode, resultCode, data));
    }

    public static Observable<ActivityResult> getActivityResultObservable(Activity activity) {
        FragmentManager fragmentManager = activity.getFragmentManager();
        ActivityResultFragment fragment = (ActivityResultFragment) fragmentManager.findFragmentByTag(
                ActivityResultFragment.class.getCanonicalName());
        if (fragment == null) {
            fragment = new ActivityResultFragment();
            fragmentManager.beginTransaction()
                    .add(fragment, ActivityResultFragment.class.getCanonicalName())
                    .commit();
            fragmentManager.executePendingTransactions();
        }
        return fragment.mActivityResultSubject;
    }

    public static void startActivityForResult(Activity activity, Intent intent, int requestCode) {
        FragmentManager fragmentManager = activity.getFragmentManager();
        ActivityResultFragment fragment = (ActivityResultFragment) fragmentManager.findFragmentByTag(
                ActivityResultFragment.class.getCanonicalName());
        if (fragment == null) {
            fragment = new ActivityResultFragment();
            fragmentManager.beginTransaction()
                    .add(fragment, ActivityResultFragment.class.getCanonicalName())
                    .commit();
            fragmentManager.executePendingTransactions();
        }
        fragment.startActivityForResult(intent, requestCode);
    }

    public static void insertActivityResult(Activity activity, ActivityResult activityResult) {
        FragmentManager fragmentManager = activity.getFragmentManager();
        ActivityResultFragment fragment= (ActivityResultFragment) fragmentManager.findFragmentByTag(
                ActivityResultFragment.class.getCanonicalName());
        if (fragment == null) {
            fragment = new ActivityResultFragment();
            fragmentManager.beginTransaction()
                    .add(fragment, ActivityResultFragment.class.getCanonicalName())
                    .commit();
            fragmentManager.executePendingTransactions();
        }
        fragment.mActivityResultSubject.onNext(activityResult);
    }
}
複製程式碼

ActivityResultFragment 這個類中:

  1. mActivityResultSubject 即為發射 ActivityResult 的 Observable ;

  2. getActivityResultObservable 這個方法是用於在 Activity 中獲取不可見 Fragment 對應的 Observable<ActivityResult>,借鑑了 Glide 的思想;

  3. onActivityResult 這個方法裡,Fragment 把自己接收到的資料封裝為 ActivityResult 傳遞給 mActivityResultSubject

  4. startActivityForResult 這個方法是用來被 Activity 呼叫的,Activity 把本來應該由自己發起的 startActivityForResult 交給由這個 Fragment 來發起;

  5. insertActivityResult 這個方法的作用前面解釋過了,是為了給呼叫流程的呼叫者,提供一致的介面,主要優化 條件檢測 - 發起流程 這種情景。

可複用流程的封裝

到目前為止,基於 RxJava,需要封裝流程所需的基礎設施已經準備完畢。我們來嘗試封裝一個流程,以登入流程為例,按上一篇討論的結果,登入流程可能包含多個頁面(使用者名稱、密碼驗證,手機驗證碼兩步驗證等),也可能有子流程(忘記密碼),但是對於“登入”這個流程,它對外只暴露一個代表它這個流程的 Activity,無論它內部跳轉多麼複雜,外部與這個登入流程互動也非常簡單,只需要通過 startActivityForResultonActivityResult 這兩個方法。而這兩個方法在上一節已經可以被很方便的封裝,我們以登入流程為例,假設登入流程只對外暴露 LoginActivity 這一個 Activity,程式碼如下:

public class LoginActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        // your other code

        loginBtn.setOnClickListener(v -> {
            // 簡單起見,略去請求部分,直接登入成功
            this.setResult(RESULT_OK);
            finish();
        });
    }

    public static Observable<ActivityResult> loginState(Activity activity) {
        if (LoginInfo.isLogin()) {
            ActivityResultFragment.insertActivityResult(
                activity,
                new ActivityResult(REQUEST_CODE_LOGIN, RESULT_OK, new Intent())
            );
        } else {
            Intent intent = new Intent(activity, LoginActivity.class);
            ActivityResultFragment.startActivityForResult(activity, intent, REQUEST_CODE_LOGIN);
        }
        return ActivityResultFragment.getActivityResultObservable(activity)
            .filter(ar -> ar.getRequestCode() == REQUEST_CODE_LOGIN)
            .filter(ar -> ar.getResultCode() == RESULT_OK);
    }
}
複製程式碼

這個 Activity 對外提供一個靜態的 loginState 方法,返回型別為 Observable<ActivityResult>,在已經登入的情況下,Observable 會立即傳送一個 ActivityResult 表示登入成功,在非登入態下, 會喚起登入流程,如果登入流程最後的結果是登入成功,Observable 也會傳送一個 ActivityResult 表示登入成功,所以凡是使用到這個登入流程的地方,對這個登入流程的呼叫,應該如下程式碼所示(仍然以點贊操作為例):

likeBtn.setOnClickListener(v -> {
    LoginActivity.loginState(this)
        .subscribe(this::doLike);
})
複製程式碼

原先需要書寫複雜的 startActivityForResultonActivityResult 兩個方法,才能完成和登入流程互動,而且還需要在發起流程前先確認是否當前已經是登入態,現在只需要一行 LoginActivity.loginState(), 然後指定一個 Observer 即可達到一樣的效果,更重要的是,寫法變簡單了以後,整個登入流程變得非常容易複用,任何需要檢查登入態然後再做操作的地方,都只需要這一行程式碼即可完成登入態檢測,實現了 流程的高度可複用

這種寫法可以在流程完成以後,繼續之前的操作(例如本例中點贊),不需要使用者重新進行一遍先前被流程所打斷的操作(例如本例中的點贊操作)。但是細心的你可能並不這麼認為,因為上面的程式碼本質上是有問題的,問題在於在上面 LoginActivity.loginState() 的呼叫在 likeBtn.setOnClickListener 的內部回撥,那麼考慮極端情況,如果登入流程被喚起,而發起登入流程的 Activity 不幸被系統回收,那麼當登入流程做完回到的發起登入流程的 Activity 將會是系統重新建立的 Activity,這個全新的 Activity 是沒有執行過 likeBtn.setOnClickListener 的內部回撥的任何程式碼的,所以 .subscribe() 方法指定的觀察者不會受到任何回撥,this::doLike 不會被執行。

為了可以讓封裝的流程相容這種情況,可以採用這種方案:修改 loginState 方法,使其返回 ObservableTransformer, 我們重新命名 loginState 方法為 ensureLogin:

public static ObservableTransformer<T, ActivityResult> ensureLogin(Activity activity) {
    return upstream -> {
        Observable<ActivityResult> loginOkResult = ActivityResultFragment.getActivityResultObservable(activity)
            .filter(ar -> ar.getRequestCode() == REQUEST_CODE_LOGIN)
            .filter(ar -> ar.getResultCode() == RESULT_OK);

        upstream.subscribe(t -> {
            if (LoginInfo.isLogin()) {
                ActivityResultFragment.insertActivityResult(
                    activity,
                    new ActivityResult(REQUEST_CODE_LOGIN, RESULT_OK, new Intent())
                );
            } else {
                Intent intent = new Intent(activity, LoginActivity.class);
                ActivityResultFragment.startActivityForResult(activity, intent, REQUEST_CODE_LOGIN);
            }
        }

        return loginOkResult;
    }
}
複製程式碼

如果您之前沒有接觸過 ObservableTransformer, 這裡做一個簡單介紹,它通常和 compose 操作符一起使用,用來把一個 Observable 進行加工、修飾,甚至替換為另一個 Observable

登入流程的封裝,現在對外體現為 ensureLogin 這一個方法,那麼其它程式碼如何呼叫這個登入流程呢,還是以點贊操作為例,現在的程式碼應該是這樣:

RxView.clicks(likeBtn)
    .compose(LoginActivity.ensureLogin(this))
    .subscribe(this::doLike);
複製程式碼

這裡的 RxView.clicks 使用了 RxBinding 這個開源庫,用於把 View 的事件,轉化為 Observable,當然其實你也可以自己封裝。改完這種寫法以後,剛剛提到的極端情況下也可以正常工作了,即使發起流程的頁面在流程被喚起後被系統回收,在流程完成以後回到發起頁,發起頁被重新建立了,發起頁的 Observer 依然可以正常收到流程結果,之前被中端的操作得以繼續執行。

現在我們可以稍微總結一下,根據上一篇和本篇提出建議,如何封裝一個業務流程:

  1. 一個業務流程對應一個 Activity,這個 Activity 作為對外的介面以及流程內部步驟的排程者;
  2. 一個流程內部的一個步驟對應一個 Fragment,這個 Fragment 只負責完成自己的任務以及把自己的資料反饋給 Activity;
  3. 流程對外暴露的介面應該封裝為一個 ObservableTransformer,流程發起者應該提供發起流程的 Observable(例如以 RxView.clicks 的形式提供),兩者通過 compose 操作符關聯起來。

這是我個人實踐出的一套封裝流程的經驗,它並不是完美的方案,但是在可靠性、可複用程度、介面簡單程度上已經足以勝任我個人的日常開發,所以才有了這兩篇分享。

我們已經封裝了一個最簡單的流程 -- 登入流程,但是實際專案中往往會遇到更嚴峻的挑戰,例如流程組合與流程巢狀。

複雜流程實踐:流程組合

舉例:某款基金銷售 App,在使用者點選購買基金時,可能存在如下圖流程:

如何優雅地構建易維護、可複用的 Android 業務流程(二)

可用從上圖中看出,某個未登入使用者想要購買一款基金的最長路徑包含:登入 - 綁卡 - 風險測評 - 投資者適當性管理 這幾個步驟。但是,並不是所有使用者都要經歷所有這些步驟,例如,如果使用者已登入並且已做過風險測評,那這個使用者只需要再做 綁卡 - 適當性管理 這兩步就可以了。

這樣的一個需求,如果用傳統的寫法來寫,可以預見肯定會在 click 事件處理的地方羅列很多 if - else

// ...
// 設定點選事件處理函式
buyFundBtn.setOnClickListener(v -> handleBuyFund());


// ...
// 處理結果
public void onActivityResult(int requestCode, int resultCode, Intent data) {
   switch (requestCode) {
       case REQUEST_LOGIN:
       case REQUEST_ADD_BANKCARD:
       case REQUEST_RISK_TEST:
       case REQUEST_INVESTMNET_PROMPT:
           if (resultCode == RESULT_OK) handleBuyFund();  
           break;
   }
} 

// ...

private void handleBuyFund() {
   // 判斷是否已登入
   if (!isLogin()) {
       startLogin();
       return;
   }
   // 判斷是否已綁卡
   if (!hasBankcard()) {
       startAddBankcard();
       return;
   }
   // 判斷是否已做風險測試
   if (!isRisktestDone()) {
       startRiskTest();
       return;
   }
   // 判斷是否需要按照投資者適當性管理規定,給予使用者必要提示
   if (investmentPrompt()) {
       startInvestmentPrompt();
       return;
   }

   startBuyFundActivity();
}
複製程式碼

上面這種寫法一方面程式碼比較長,另一方面,流程發起和結果處理分散在兩處,程式碼較為不易維護。我們分析一下,整個大的流程是幾個小流程的組合,我們可以把上面的圖上的流程換一種畫法:

如何優雅地構建易維護、可複用的 Android 業務流程(二)

按照上文的思想,我們令每個流程對外暴露一個 Activity,並且已經使用 RxJava ObservableTransformer 封裝好,那麼前面複雜的程式碼可以簡化為:

RxView.clicks(buyFundBtn)
    // 確保未登入情況下,發起登入流程,已登入情況下自動流轉至下一個流程
    .compose(ActivityLogin.ensureLogin(this))
    // 確保未綁卡情況下,發起綁卡流程,已綁卡情況下自動流轉至下一個流程
    .compose(ActivityBankcardManage.ensureHavingBankcard(this))
    // 確保未風險測評情況下,發起風險測評流程,已測評情況下自動流轉至下一個流程
    .compose(ActivityRiskTest.ensureRiskTestDone(this))
    // 確保需要適當性提示情況下,發起適當性提示,已提示或不需要提示情況下自動流轉至下一個流程
    .compose(ActivityInvestmentPrompt.ensureInvestmentPromptOk(this))
    // 所有條件都滿足,進入購買基金頁
    .subscribe(v -> startBuyFundActivity(this));
複製程式碼

通過 RxJava 的良好封裝,我們做到了可以用更少的程式碼來表達更復雜的邏輯。上面的例子中的 4 個被組合的流程,它們有一個共同的特點,就是彼此獨立,互相不依賴其它剩餘流程的結果,現實中,我們可能會遇到這樣的情況: B 流程啟動,需要依賴 A 流程完成的結果,為了能滿足這種情況,我們只需要對上面的封裝稍作修改。

假設綁卡流程需要依賴登入流程完成後的使用者資訊,那麼首先,在登入流程結束呼叫 setResult 的位置, 傳遞使用者資訊:

this.setResult(
    RESULT_OK, 
    IntentBuilder.newInstance().putExtra("user", user).build()
);
finish();
複製程式碼

然後,修改 ensureLogin 方法,使經過 ObservableTransformer 處理後,返回的新的 Observable 由發射 ActivityResult 改為發射 User

public static ObservableTransformer<T, User> ensureLogin(Activity activity) {
    return upstream -> {
        Observable<ActivityResult> loginOkResult = ActivityResultFragment.getActivityResultObservable(activity)
            .filter(ar -> ar.getRequestCode() == REQUEST_CODE_LOGIN)
            .filter(ar -> ar.getResultCode() == RESULT_OK)
            .map(ar -> (User)ar.getData.getParcelableExtra("user"));

        upstream.subscribe(t -> {
            if (LoginInfo.isLogin()) {
                ActivityResultFragment.insertActivityResult(
                    activity,
                    new ActivityResult(
                        REQUEST_CODE_LOGIN, 
                        RESULT_OK, 
                        IntentBuilder.newInstance().putExtra("user", LoginInfo.getUser()).build()
                    )
                );
            } else {
                Intent intent = new Intent(activity, LoginActivity.class);
                ActivityResultFragment.startActivityForResult(activity, intent, REQUEST_CODE_LOGIN);
            }
        }

        return loginOkResult;
    }
}
複製程式碼

與此同時,原來的 ensureHavingBankcard 方法的 ObservableTransformer 方法接受的 Observable 原來是任意型別 T 的,由於我們現在規定,綁卡流程需要依賴登入流程的結果 User ,所以我們把 T 型別,改為 User 型別:

public static ObservableTransformer<User, ActivityResult> ensureHavingBankcard(Activity activity) {
    return upstream -> {
        Observable<ActivityResult> bankcardOk = ActivityResultFragment.getActivityResultObservable(activity)
            .filter(ar -> ar.getRequestCode() == REQUEST_ADD_BANKCARD)
            .filter(ar -> ar.getResultCode() == RESULT_OK);

        upstream.subscribe(user -> {
            if (getBankcardNum() > 0) {
                ActivityResultFragment.insertActivityResult(
                    activity,
                    new ActivityResult(
                        REQUEST_ADD_BANKCARD, 
                        RESULT_OK, 
                        new Intent()
                    )
                );
            } else {
                Intent intent = new Intent(activity, AddBankcardActivity.class);
                intent.putExtra("user", user);
                ActivityResultFragment.startActivityForResult(activity, intent, REQUEST_ADD_BANKCARD);
            }
        }

        return bankcardOk;
    }
}
複製程式碼

這樣,這兩個流程之間就有了依賴關係,綁卡依賴登入流程返回的結果,但是組合這兩個流程的寫法還是不會有任何改變:

RxView.clicks(someBtn)
    .compose(ActivityLogin.ensureLogin(this))
    .compose(ActivityBankcardManage.ensureHavingBankcard(this))
    .subscribe(v -> doSomething());
複製程式碼

除此以外,綁卡流程還是可複用的,它是依賴可以返回 User 的流程的,所以只要是其他可以返回 User 作為結果的流程,都可以與綁卡流程組合。

複雜流程實踐:流程巢狀

舉例:登入流程中的登入頁面,除了可以選擇使用者名稱密碼登入外,往往還提供其他選項,最典型的就是註冊和忘記密碼兩個功能:

如何優雅地構建易維護、可複用的 Android 業務流程(二)

從直覺上,我們肯定是認為註冊和忘記密碼應該是不屬於登入這個流程的,它們是相對獨立的兩個流程,也就是說在登入這流程內部,嵌入了其它的流程,我把這種情況稱之為流程的巢狀。

按照同樣的套路,我們應該先把註冊、忘記密碼這兩個流程使用 ObservableTransformer 進行封裝,然後我們把上圖流程按照本文思想整理一下,如下:

如何優雅地構建易維護、可複用的 Android 業務流程(二)

可以看到,現在的區別是,發起流程的地方不再是一個普通的 Activity,而是另一個流程中的某個步驟,按照先前的討論,流程中的步驟是由 Fragment 承載的。所以這裡有兩種處理方法,一種是 Fragment 把發起流程的任務交給宿主 Activity,由宿主 Activity 分配給屬於它的“看不見的 Fragment” 去發起流程並處理結果,另一種是直接由該 Fragmnet 發起流程,由於 Fragment 也有屬於它自己的 ChildFragmentManager,所以只需要對“使用 RxJava 進行封裝”這一節中的相關方法做一些修改即可支援由 Fragment 內部發起流程,具體修改內容為把 activity.getFragmentManager() 改為 fragment.getChildFragmentManager() 即可。

在具體應用中,本人使用的是後一種,即 直接由 Fragment 發起流程,因為被巢狀的流程往往和主流程有關聯,即巢狀流程的結果有可能改變主流程的流轉分支,所以直接由 Fragment 發起流程並處理結果比較方便一點,如果交給宿主 Activity 可能需要額外多寫一些程式碼進行 Activity - Fragment 的通訊才能實現相同效果。

首先,在沒有巢狀流程的情況下,登入流程的第一個步驟登入步驟(使用者名稱、密碼驗證),程式碼應該如下:

public class LoginFragment extends Fragment {
    // UI references.
    private EditText mPhoneView;
    private EditText mPasswordView;

    LoginCallback mCallback;

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_login_by_pwd, container, false);
        // Set up the login form.
        mPhoneView = view.findViewById(R.id.phone);
        mPasswordView = view.findViewById(R.id.password);

        Button signInButton = view.findViewById(R.id.sign_in);
        signInButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                String phone = mPhoneView.getText().toString();
                String pwd = mPasswordView.getText().toString();

                if (mCallback != null) {
                    // mock login ok
                    mCallback.onLoginOk(true, new User(
                            UUID.randomUUID().toString(),
                            "Jack",
                            mPhoneView.getText().toString()
                    ));
                }
            }
        });

        return view;
    }

    public void setLoginCallback(LoginCallback callback) {
        this.mCallback = callback;
    }

    public interface LoginCallback {
        void onLoginOk(boolean needSmsVerify, User user);
    }
}
複製程式碼

在上面的程式碼中,LoginCallback 這個介面作用是,登入這個步驟,收集完資訊,與伺服器互動完畢後,把結果回傳給宿主 Activity,由 Activity 決定後續步驟的流轉。上面的例子中做了一部分簡化,在 onClick 處理函式裡沒有發起和服務端的互動,而是直接 Mock 了一個請求成功的結果。

現在的需求是,在登入這個步驟裡,嵌入兩個步驟:

  1. 一個是註冊流程,而且註冊成功後直接視為登入成功,不需要再走剩餘的登入流程步驟;
  2. 另一個是忘記密碼流程,忘記密碼流程本質是重置密碼,但是即使密碼重置成功還是需要使用者使用新密碼登入,不會直接在重置密碼後自動登入。

根據需求,我們在上述程式碼中加入嵌入這兩個流程的程式碼:

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_login_by_pwd, container, false);
        // Set up the login form.
        mPhoneView = view.findViewById(R.id.phone);
        mPasswordView = view.findViewById(R.id.password);

        Button signInButton = view.findViewById(R.id.sign_in);
        Button mPwdResetBtn = view.findViewById(R.id.pwd_reset);
        Button mRegisterBtn = view.findViewById(R.id.register);

        // 直接登入
        signInButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                String phone = mPhoneView.getText().toString();
                String pwd = mPasswordView.getText().toString();

                if (mCallback != null) {
                    // mock login ok
                    mCallback.onLoginOk(true, new User(
                            UUID.randomUUID().toString(),
                            "Jack",
                            mPhoneView.getText().toString()
                    ));
                }
            }
        });

        // 發起註冊流程
        RxView.clicks(mRegisterBtn)
            .compose(RegisterActivity.startRegister(this))
            .subscribe(user -> {
                if (mCallback != null) {
                    mCallback.onRegisterOk(user);
                }
            });

        // 發起忘記密碼流程
        RxView.clicks(mPwdResetBtn)
            .compose(PwdResetActivity.startPwdReset(this))
            .subscribe();

        return view;
    }

    public interface LoginCallback {
        void onLoginOk(boolean needSmsVerify, User user);
        void onRegisterOk(User user);
    }
複製程式碼

在上面的程式碼裡,RegisterActivity.startRegisterPwdResetActivity.startPwdReset 兩個方法即為使用了 ObservableTransformer 封裝的註冊流程和忘記密碼流程。同時可以看到,LoginCallback 這個介面裡多了一個方法 onRegisterOk,也就是說登入這個步驟不再只有 onLoginOk 這一種情況通知宿主 Activity 了,在內嵌註冊流程成功的情況下,也可以通知宿主 Activity,然後讓宿主 Activity 決定後續流轉,當然這種情況,根據需求註冊成功也是屬於登入成功的一種,宿主 Activity 通過 setResult 方法把整個登入流程的狀態標記為登入成功,finish 自己,同時把使用者資訊傳遞給發起登入流程的地方。

但是為什麼內嵌的註冊流程需要把流程的結果回傳給登入流程的宿主 Activity,而內嵌的忘記密碼流程沒有設定一個類似的方法回撥登入流程的宿主 Activity 呢?因為註冊成功這件事影響了登入流程的走向(註冊成功直接視為登入成功,登入流程狀態置為成功,並通知發起登入流程的地方本次登入結果為成功),而忘記密碼流程最後的重置密碼成功並不影響登入流程走向(即使重置密碼成功依然需要在登入介面使用新密碼登入)。

按照上面的分析,登入流程的宿主 Activity,負責分發流程步驟的邏輯的相關程式碼如下所示:

public class LoginActivity extends Activity {

    // ... 

    @Override
    protected void onCreate(Bundle savedInstanceState) {

        //...
        // 使用者名稱密碼驗證步驟
        loginFragment.setLoginCallback(new LoginFragment.LoginCallback() {
            @Override
            public void onLoginOk(boolean needSmsVerify, User user) {
                // 使用者名稱密碼驗證成功
                if (needSmsVerify) {
                    // 需要簡訊驗證碼的兩步驗證
                    push(loginBySmsFragment);
                } else {
                    // 登入成功
                    setResult(
                        RESULT_OK, 
                        IntentBuilder.newInstance().putExtra("user", user).build()
                    );
                    finish();
                }
            }

            @Override
            public void onRegisterOk(User user) {
                // 註冊成功, 直接登入
                setResult(
                    RESULT_OK, 
                    IntentBuilder.newInstance().putExtra("user", user).build()
                );
                finish();
            }
        }

        // 簡訊驗證碼兩步驗證步驟
        loginBySmsFragment.setSmsLoginCallback(new LoginBySmsFragment.LoginBySmsCallback() {
            @Override
            public void onSmsVerifyOk(User user) {
                // 簡訊驗證成功
                setResult(
                    RESULT_OK, 
                    IntentBuilder.newInstance().putExtra("user", user).build()
                );
                finish();
            }
        });
    }

    // ...
}
複製程式碼

可以看到,即使是流程巢狀的情況下,使用 RxJava 封裝的流程依然不會使流程跳轉的程式碼顯得十分混亂,這點十分可貴,因為這意味著今後流程相關程式碼不會成為專案中難以維護的模組,而是清晰且高內聚的。

流程上下文儲存

到目前為止,我們還剩最後一個問題需要解決,即涉及流程的相關上下文儲存。具體包含兩部分,一是流程觸發點,發起流程的位置,需要對發起流程前的上下文進行儲存,另一部分是流程中間步驟的結果,也需要進行儲存。

1. 流程中間步驟的結果的儲存

要對流程中間步驟的結果進行儲存是因為,按照我們前面的討論,流程中每個步驟(即Fragment),會和使用者互動,然後把該步驟的結果傳遞給宿主 Activity,那麼假設流程沒有做完,並且該步驟的結果可能會被後續步驟使用,那麼宿主 Activity 是有必要儲存這個結果的,那麼通常這個結果會以這個 Activity 的成員變數的形式被儲存,問題在於 Activity 一旦被置於後臺,就隨時可能被系統回收,此時可能流程並沒有做完,如果沒有對 Activity 的成員變數做儲存和恢復處理,當下次 Activity 回到前臺以後,這個流程的狀態就處於不確定狀態了,甚至可能崩潰。

解決的方案很顯然,繼承 Activity 的 onSaveInstanceStateonRestoreInstanceState(或者 onCreate) 這兩個方法,在這兩個方法內部實現變數的儲存與恢復操作。如果你覺得實現這兩個方法會使你的程式碼非常醜陋,那麼我推薦你使用 SaveState 這個工具,使用它,你只需要在需要儲存和恢復的成員變數上標記一個 @AutoRestore 註解,框架就會自動幫你儲存和恢復成員變數,你不需要寫任何額外的程式碼。

2. 發起流程前的上下文的儲存

1 的原因類似,流程一旦被喚起,發起流程的 Activity 就處於後臺狀態,這是一種可能被系統回收的狀態。舉個例子, 有一個理財產品列表頁,使用者未登入狀態,現在要求使用者點選任何一個理財產品,先把使用者帶到登入介面,待登入流程完成後,把使用者帶到具體的理財產品購買頁。列表的點選事件設定一般分兩種,一是為列表中每個 Item 設定一個點選處理函式,另一種是為所有 Item 設定同一個點選處理函式。以給列表所有 Item 設定同一個點選處理函式為例:


// 所有 Item 的點選事件對應的 Observable,其發射的元素為點選位置
Observable<Integer> itemClicks = ...

itemClicks
    .compose(LoginActivity.ensureLogin(activity))
    .subscribe(/** 不知道怎麼寫 **/);
複製程式碼

subscribe 裡面的觀察者不知道怎麼寫了是因為 LoginActivity.ensureLogin 這個 ObservableTransformer 會把 Observable<T> 轉為 Observable<ActivityResult>, 所以觀察者裡只知道登入成功了,不知道最初是點選哪個理財產品觸發的登入操作,所以不知道應該如何去啟動購買頁面。

我們遇到的困境是當流程完成以後,我們不知道發起流程前的上下文是什麼,導致我們無法在觀察者裡做正確的後續邏輯。一種直觀的解決方案就是,我們把發起流程時的上下文資料打包進 startActivityForResult 的 Intent 裡面,用一個保留的 Key 值去儲存,同時確保流程完成時, setResult 呼叫時,會把剛剛流程傳入的上下文資料,同樣以一個保留的 Key 值回傳給發起流程的地方。

如果這樣處理以後,我們回過頭看剛才的情況,我們再實現一個 LoginActivity.ensureLoginWithContext 方法,它的返回值為 ObservableTransformer<Bundle, Bundle>

public static ObservableTransformer<Bundle, Bundle> ensureLoginWithContext(AppCompatActivity activity) {
    return upstream -> {
        upstream.subscribe(contextData -> {
            if (LoginInfo.isLogin()) {
                ActivityResultFragment.insertActivityResult(
                    activity,
                    new ActivityResult(REQUEST_LOGIN, RESULT_OK, null, contextData)
                );
            } else {
                Intent intent = new Intent(activity, LoginActivity.class);
                ActivityResultFragment.startActivityForResult(activity, intent, REQUEST_LOGIN, contextData);
            }
        });
        return ActivityResultFragment.getActivityResultObservable(activity)
                .filter(ar -> ar.getRequestCode() == REQUEST_LOGIN)
                .filter(ar -> ar.getResultCode() == RESULT_OK)
                .map(ActivityResult::getRequestContextData);
    };
}
複製程式碼

上面的程式碼中的 ensureLoginWithContext 和原先的 ensureLogin 方法相比,除了返回值的泛型型別不同以外,在內部實現裡,呼叫的 ActivityResult 的構造方法以及 startActivityForResult 方法和原先的版本比都多了一個 Bundle 型別的 contextData 引數,這個引數即為需要儲存的流程發起前的上下文。最後看整個方法的 return 語句,多了一個 map 操作符,用來把 ActivityResult 裡儲存的流程的上下文重新取出來。這裡的邏輯就是剛剛提到的:在流程發起前,將流程發起前的上下文資訊通過 Bundle 傳遞給流程,最後流程結束時再原封不動返回給流程發起的地方,以便流程發起點可以知曉它發起流程前的狀態。這幾個方法的具體實現可以參考 Sq 這個框架。

在經過這樣處理以後,列表 Item 的點選事件發起登入流程的程式碼如下所示:

itemClicks
        .map(index -> BundleBuilder.newInstance().putInt("index", index).build())
        .compose(LoginActivity.ensureLoginWithContext(this))
        .map(bundle -> bundle.getInt("index"))
        .subscribe(index -> {
            // modification of item in position $index
            adapter.notifyItemChanged(index);
        });
複製程式碼

compose 操作符前後,分別多了一個 map 操作符,分別負責把上下文打包以及從流程結果中把原來的上下文解包取出來。

流程上下文的儲存還有一個注意點,就是流程在結束時,即呼叫 setResult 時,需要保證把先前傳入的的上下文再塞回去到結果裡去,只有做到了這點,上面的程式碼才是有效的,這些工作如果總是手動來做會很繁瑣,您可以選擇自己封裝,或者直接使用下一節介紹的開箱即用的工具。

如何快速使用

到這裡為止,對於封裝業務流程相關所有經驗分享已經介紹完畢,如果您看到這裡,對於本文以及本文的上一篇提出的流程方案感興趣,您有兩種方法整合到自己的專案裡,一是參照文中的程式碼,自己實現(核心程式碼都已在文中,稍作修改即可); 另一種方法是直接使用封裝好的版本,這個專案的名字是 Sq, 您只需要把依賴新增到 Gradle,開箱即用。

如何優雅地構建易維護、可複用的 Android 業務流程(二)

總結

文章很長,感謝您耐心讀完。由於本人能力有限,文章可能存在紕漏的地方,歡迎各位指正。關於如何對業務流程進行封裝,因為我並沒有看到過很多技術文章對這一塊進行討論,所以我個人的見解會有不全面的地方,如果您有更好的方案,歡迎一起討論。謝謝大家!

另外,歡迎關注我的個人公眾號:麻瓜日記,不定期更新原創技術分享,謝謝!:)

如何優雅地構建易維護、可複用的 Android 業務流程(二)

相關文章