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

prototypez發表於2018-05-27

有一定實際 Android 專案開發經驗的人,一定曾經在專案中處理過很多重複的業務流程。例如開發一個社交 App ,那麼出於使用者體驗考慮,會需要允許匿名使用者(不登入的使用者)可以瀏覽資訊流的內容(或者只能瀏覽受限的內容),當使用者想要進一步操作(例如點贊)時,提示使用者需要登入或者註冊,使用者完成這個流程才可以繼續剛剛的操作。而如果使用者需要進行更深入的互動(例如評論,釋出狀態),則需要實名認證或者補充手機號這樣的流程完成才可以繼續操作。

而上面列舉的還只是比較簡單的情況,流程之間還可以互相組合。例如:匿名使用者點選了評論,那麼需要連續做完:

  1. 登入/註冊
  2. 實名認證

這兩個流程才可以繼續評論某條資訊。另外 1 中,登入流程還可能巢狀“忘記密碼”或者“密碼找回”這樣的流程,也有可能因為服務端檢測到使用者異地登入插入一個兩步驗證/手機號驗證流程。

需要解決的問題

(一) 流程的體驗應當流暢

根據本人使用市面上 App 的經驗,處理業務流程按體驗分類可以分為兩類,一種是觸發流程完成後,回到原頁面,沒有任何反應,使用者需要再點一下剛才的按鈕,或者重新操作一遍剛才觸發流程的行為,才能進行原來想要的操作。另外一種是,流程完成後,如果之前不滿足的某些條件此時已經滿足,那麼自動幫使用者繼續剛剛被打斷的操作。顯然,後一種更符合使用者的預期,如果我們需要開發一個新的流程框架,那麼這個問題需要被解決。

(二) 流程需要支援巢狀

如果在進行一個流程的過程中,某些條件不滿足,需要觸發一個新的流程,應當可以啟動那個流程,完成操作,並且返回繼續當前流程。

(三) 流程步驟間資料傳遞應當簡單

傳統 Activity 之間資料傳遞是基於 Intent 的,所以資料型別需要支援 Parcelable 或者 Serializable ,並且需要以 key-value 的方式往 Intent 內填充,這是有一定侷限性的。此外,流程步驟間有些資料是共享的,有些是獨有的,如何方便地去讀寫這些資料?

有人可能會說,那可以把這些資料放到一個公共的空間,想要讀寫這些資料的 Activity 自行訪問這些資料。但是如果真的這樣,帶來的新問題是:應用程式是可能在任意時刻銷燬重建的,重建以後記憶體中儲存的這些資料也消失了。如果不希望看到這樣,就需要考慮資料持久化,而持久化的資料也只是被這一次流程用到,何時應該銷燬這些資料?持久化的資料需要考慮自身的生命週期的問題,這引入了額外的複雜度。且並沒有比使用 Intent 傳遞方便多少。

(四) 流程需要適應 Android 元件生命週期

前面說到了應用程式銷燬重建的問題,由於很多操作觸發流程以後,啟動的流程頁面是基於 Activity 實現的,所以完成流程回到的 Activity 例項很有可能不是原來觸發流程時的那個 Activity 例項,原來那個例項可能已經被銷燬了,必須有合適的手段保證流程完成後,回到觸發流程的頁面可以正確恢復上下文。

(五) 流程需要可以簡單複用

還有流程往往是可以複用的,例如登入流程可以在應用的很多地方觸發,所以觸發後流程結束以後的跳轉頁面也都是不一樣的,不可以在流程結束的頁面寫死跳轉的頁面。

(六) 流程頁面在完成後需要比較容易銷燬

流程結束以後,流程每個步驟頁面可以簡單地銷燬,回到最初觸發流程的介面。

(七) 流程進行中回退行為的處理

如果一個流程包含多箇中間步驟,使用者進行到中間某個步驟,按返回鍵時,行為應該如何定義?在大多數情況下,應該支援返回上一個步驟,但是在某些情況下,也應當支援直接返回到流程起始步驟。

方案一:基於 startActivityForResult

其實說起流程這個事情,我們最容易想到的應該就是 Android 原生提供給我們的 startActivityForResult 方法,以 Android 官網中的一個例子(從通訊錄中選擇一個聯絡人)為例:

static final int PICK_CONTACT_REQUEST = 1;  // The request code
...
private void pickContact() {
    Intent pickContactIntent = new Intent(Intent.ACTION_PICK, Uri.parse("content://contacts"));
    pickContactIntent.setType(Phone.CONTENT_TYPE); // Show user only contacts w/ phone numbers
    startActivityForResult(pickContactIntent, PICK_CONTACT_REQUEST);
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    // Check which request we're responding to
    if (requestCode == PICK_CONTACT_REQUEST) {
        // Make sure the request was successful
        if (resultCode == RESULT_OK) {
            // The user picked a contact.
            // The Intent's data Uri identifies which contact was selected.

            // Do something with the contact here (bigger example below)
        }
    }
}
複製程式碼

在上面的例子中,當使用者點選按鈕(或者其他操作)時,pickContact 方法被觸發,系統啟動通訊錄,使用者從通訊錄中選擇聯絡人以後,回到原頁面,繼續處理接下來的邏輯。從通訊錄選擇使用者並返回結果 就可以被看作為一個流程。

不過上面的流程是屬於比較簡單的情況,因為流程邏輯只有一個頁面,而有時候一個複雜流程可能包含多個頁面:例如註冊,包含手機號驗證介面(接收驗證碼驗證),設定暱稱頁面,設定密碼頁面。假設註冊流程是從登入介面啟動的,那麼使用 startActivityForResult 來實現註冊流程的 Activity 任務棧的變化如下圖所示:

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

上圖的註冊流程實現細節如下:

  1. 登入介面通過 startActivityForResult 啟動註冊頁面的第一個介面 ---- 驗證手機號;
  2. 手機號驗證成功後,驗證手機號介面通過 startActivityForResult 啟動設定暱稱頁面;
  3. 暱稱檢查合法後,暱稱資訊通過 onActivityResult 返回給驗證手機號介面,驗證手機號介面通過 startActivityForResult 啟動設定密碼介面,由於設定密碼是最後一個流程,驗證手機號介面把之前收集好的手機號資訊,暱稱資訊都一併傳遞給密碼介面,密碼檢查合法後,根據現有的手機號、暱稱、密碼發起註冊;
  4. 註冊成功後,伺服器返回註冊使用者資訊,設定密碼介面通過 onActivityResult 把註冊結果反饋給設定手機號介面;
  5. 註冊成功,設定手機號介面結束自己,同時把註冊成功資訊通過 onActivityResult 反饋給流程發起者(本例中即登入介面);

通過這個例子可以看出來,手機號驗證介面 不僅承擔了在註冊流程中驗證手機號的功能,還承擔了註冊流程對外的介面的職責。也就是說,觸發註冊流程的任意位置,都不需要對註冊流程的細節有任何瞭解,而只需要通過 startActivityForResultonActivityResult 與流程對外暴露的 Activity 互動即可,如下圖:

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

上面的例子中可能有一點令您疑惑:為什麼每個步驟需要返回到驗證手機號頁面,然後由驗證手機號頁面負責啟動下個步驟呢?一方面,由於驗證手機號是流程的第一個頁面,它承擔了流程排程者的身份,所以由它來進行步驟的分發,這樣的好處是每個步驟(除了第一步)之間是解耦和內聚的,每個步驟只需要做好自己的事情並且通過 onActivityResult 返回資料即可,假如後續流程的步驟發生增刪,維護起來比較簡單;另一方面,由於每個步驟做完都返回,當最後一個步驟做完以後,之前流程的中間頁面都不存在了,不需要手動去銷燬做完的流程頁,這樣編碼起來也比較方便。

但是這麼做帶來一個小小的副作用:如果在流程的中間步驟按返回鍵,就會回到流程的第一個步驟,而使用者有時候是希望可以回到上一個步驟。為了讓使用者可以在按返回鍵的時候返回上一個步驟,就必須要把每個步驟的 Activity 壓棧,但是這樣做的話最後一步做完之後如何銷燬流程相關的所有 Activity 又是一個問題。

為了解決流程相關 Activity 的銷燬問題,需要對上面的圖做一點修改,如下:

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

原先,每個步驟做完自己的任務以後只需要結束自己並返回結果,修改後,每個步驟做完自己的任務後不結束自己,也不返回結果,同時需要負責啟動流程的下一個步驟(通過 startActivityForResult),當它的下一個步驟結束並返回它的結果的時候,這個步驟能在自己的 onActivityResult 裡接住,它在onActivityResult裡需要做的是把自己的結果和它的下一個步驟的結果合在一起,傳遞給它的上一個步驟,並結束自己。

通過這樣,實現了使用者按返回鍵所需要的行為,但是這種做法的缺點是造成了流程內步驟間的耦合,一方面是啟動順序之間的耦合,另一方面由於需要同時攜帶它下個步驟的結果並返回造成的資料的耦合。

除此以外我還見過有人會單獨使用一個棧,來儲存流程中啟動過的 Activity , 然後在流程結束後自己去手動依次銷燬每個 Activity。我不太喜歡這種方法,它相比上面的方法沒有解決實質問題,而且需要額外維護一個資料結構,同時還要考慮生命週期,得不償失。

最後總結一下前文, startActivityForResult 這個方法有著它自己的優勢:

  1. 足夠簡單,原生支援。
  2. 可以處理流程返回結果,繼續處理觸發流程前的操作。
  3. 流程封裝良好,可複用。
  4. 雖然引入了額外的 requestCode,但是在某種程度上保留了請求的上下文。

但是這個原生方案存在的問題也是顯而易見的:

  1. 寫法過於 Dirty,發起請求和處理結果的邏輯被分散在兩處,不易維護。
  2. 頁面中如果存在的多個請求,不同流程回撥都被雜糅在一個 onActivityResult 裡,不易維護。
  3. 如果一個流程包含多個頁面,程式碼編寫會非常繁瑣,顯得力不從心。
  4. 流程步驟間資料共享基於 Intent,沒有解決 問題(三)
  5. 流程頁面的自動銷燬和流程進行中回退行為存在矛盾,問題(六)問題(七) 沒有很好地解決。

實際開發中,這幾個問題都非常突出,影響開發效率,所以無法直接拿來使用。

方案二:EventBus 或者其他基於事件匯流排的解決方案

基於事件解耦也是一種比較優雅的解決方案,尤其是著名的 EventBus 框架了,它實現了非常經典的釋出訂閱模型,完成了出色的解耦:

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

我相信很多 Android 開發者都曾經很愉快地使用過這個框架……………………………………最後放棄了它,或者只在小範圍使用它。比如說我,目前已經在專案中逐漸刪除使用 EventBus 的程式碼,並且使用 RxJava 作為替代。

通過具體的程式碼一窺 EventBus 的基本用法:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    EventBus.getDefault().register(this);
    EventBus.getDefault().post(new MessageEvent("hello","world"));

}

@Subscribe(threadMode = ThreadMode.MainThread)
public void helloEventBus(MessageEvent message){
    mText.setText(message.name);
}

@Override
protected void onDestroy() {
    super.onDestroy();
    EventBus.getDefault().unregister(this);
}

class MessageEvent {
  public final String name;
  public final String password;
  public MessageEvent(String name, String password) {
    this.name = name;
    this.password = password;
  }
}
複製程式碼

那它有什麼不足之處呢?首先,發起一個一般的非同步任務,開發者期望在回撥中得到的是 這個任務 的結果,而在 EventBus 的概念中,回撥中傳遞的是“事件”(例子中的 MessageEvent)。這裡稍稍有點不同,理論上,非同步任務的結果的資料型別可以就是事件的資料型別,這樣兩個概念就統一了,然而實際中還是有很多場合無法這樣處理, 舉個例子:A Activity 和 B Activity 都需要請求一個網路介面,如果把網路請求的響應的物件型別直接作為事件型別提供給它們的 Subscriber,就會產生混亂,如下圖。

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

圖中,A Activity 和 B Activity 都發起同一個網路請求(可能引數不同,例如查天氣介面,一個是查北京的天氣,另一個是查上海的天氣),那麼他們的響應結果類是一樣的,如果把這個響應結果直接作為事件型別提供給 EventBus 的回撥,那麼造成的結果就是兩個 Activity 都收到兩次訊息。我把它稱為 事件傳播在空間上引起的混亂

解決的方案通常是封裝一個事件,把 Response 作為這個事件攜帶的資料:

public class ResponseEvent {
    String sender;
    Response response;
}
複製程式碼

在把響應物件封裝成事件之後,加入了一個 sender 欄位,用來區分這個響應應該對應哪個 Subscriber ,這樣就解決了上述問題。

不僅僅在空間上, 事件傳播還可以在時間上引起混亂,想象一種情況,如果先後發起兩個相同型別的請求,但是處理他們的回撥是不同的。如果用傳統的設定回撥的方法,只要給這兩個請求設定兩個回撥就可以了,但是如果使用 EventBus ,由於他們的請求型別相同,所以他們資料返回型別也相同,如果直接把返回資料型別當成事件型別,那麼在 EventBus 的事件處理回撥中無法區分這兩個請求(無法保證一先一後的兩個請求一定也是一先一後返回)。解決的方案也類似上面的方案,只要把 sender 這個欄位換成類似 timestamp 這樣的欄位就可以了。

歸根結底,事件傳播在空間和時間上引起混亂的深層次原因是,把傳統的“為每個非同步請求設定一個回撥”這種模式,變成了“設定一個回撥,用來響應某一種事件”這種模式。傳統的方式是一個具體的請求和一個具體的回撥之間是強關聯,一個具體的回撥服務於一個具體的請求,而 EventBus 把兩者給解耦開了,回撥和請求之間是弱關聯,回撥只和事件型別之間是強關聯

除了上面的問題,事實上還有一個更嚴峻的問題,具體程式碼:

// File: ActivityA.java
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_a);

    EventBus.getDefault().register(this);

    findViewById(R.id.start).setOnClickListener(
        v -> startActivity(new Intent(this, ActivityB.class))
    )
}

@Subscribe(threadMode = ThreadMode.MainThread)
public void helloEventBus(MessageEvent message){
    mText.setText(message.name);
}

@Override
protected void onDestroy() {
    super.onDestroy();
    EventBus.getDefault().unregister(this);
}

........

// File: ActivityB.java
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_b);

    findViewById(R.id.btn).setOnClickListener(v -> {
        EventBus.getDefault().post(new MessageEvent("hello","world"));
        finish();
    })
}
複製程式碼

上述程式碼的意圖主要是:在 ActivityA 點選按鈕,啟動 ActivtyB, ActivtyB 承載了一個業務流程,當 ActivityB 所承擔的流程任務完成以後,點選它頁面內的按鈕,結束流程,把結果資料通過 EventBus 傳遞迴 ActivityA ,同時結束自己,把使用者帶回 ActivityA。

理想情況下,這樣是沒有問題,但是如果在開啟了 Android 系統中的 “開發者選項 - 不保留活動”選項以後,ActivityA 不會收到來自 ActivityB 的任何訊息。“不保留活動”這個選項其實是模擬了當系統記憶體不足的時候,會銷燬 Activity 棧中使用者不可見的 Activity 這一特性。這點在低端機中非常常見,經常玩著玩著手機,突然接個電話,回來發現整個頁面都重新載入了。那麼原因已經顯而易見了:因為 ActivityA 在被系統銷燬的時候執行了 onDestroy,從 EventBus 中移除了自身回撥,因此無法接收到來自 ActivityB 的回撥了。能不能不移除回撥呢?當然是不能,因為這樣會造成記憶體洩漏,更糟。

熟悉 EventBus 的朋友應該對 postSticky 這個方法不陌生,確實,在這種情況下,postSticky 這個方法可以讓事件多存活一段時間,直到它的消費者出現把它消費掉。但是這個方法也有一些副作用,使用postSticky傳送的事件需要由 Subscriber 手動把事件移除,這就導致,如果事件有多個消費者,那寫程式碼的時候就不知道應該在什麼時候把事件移除,需要增加一個計數器或者別的什麼手段,引入了額外的複雜度。postSticky的事件只是為了保證 Activity 重走生命週期後內部回撥依然可以收到事件,卻汙染了全域性的空間,這種做法我覺得非常不優雅。

寫到這裡,這篇文章快成了 EventBus 批判文了,其實 EventBus 本身沒問題,只是我們使用者要考慮場景,不能濫用,還是有些場合比較適用的,但是對於業務流程處理這個任務來說,我並不認為這是一個很好的應用場景。

上述陳述中,很多例子我都使用了“非同步任務”作為例子來闡述,主要是我認為其實在使用者操作中我們插入的業務流程也可以視為一種非同步任務,反正最後結果都是非同步返回給呼叫者的。所以我認為 EventBus 不適合非同步任務的那些點,同樣不適合業務流程。

其他的事件匯流排解決方案基本類似,Android 原生的 Broadcast 如果不考慮它的跨程式特性的話,在處理業務流程這件事情上基本可以認為是個低配版的 EventBus ,所以這裡不再贅述。

方案三:FLAG_ACTIVITY_CLEAR_TOP 或許是一種方案

由於考慮使用第三方的框架始終無法避開 Android 生命週期的問題(上一節 EventBus 案例中 Activity 的銷燬與重建丟失上下文的例子)。我們還是傾向於從 Android 原生框架中尋找符合我們要求的功能元件。這時我從 Intent Flags 中找到了 FLAG_ACTIVITY_CLEAR_TOP, 官方文件在這裡, 我不打算照搬文件,但是想把其中一個例子翻譯一下:

如果一個 Activity 任務棧有下列 Activity:A, B, C, D. 如果這時 D 呼叫 startActivity(), 並且作為引數的 Intent 最後解析為要啟動 Activity B(這個 Intent 中包含 FLAG_ACTIVITY_CLEAR_TOP ), 那麼 C 和 D 都會銷燬,B 會接收到這個 Intent, 最後這個任務棧應該是這樣:A, B。

這段只描述了現象,文件中還描述了更細節的資料流動,建議仔細閱讀消化文件描述,我只把其中最重要的一塊單獨翻譯一下:

上面的例子中的 B 會是下面兩種結果之一

  1. onNewIntent 回撥中接收到來自 D 傳遞過來的 Intent
  2. B 會銷燬重建, 而重建的 Intent 就是由 D 傳遞過來的那個 Intent

如果 B 的 launchMode 被申明為 multiple(即standard) Intent Flags 中沒有 FLAG_ACTIVITY_SINGLE_TOP, 那麼就是上面的結果2。剩下的情況(launchMode 被申明為 multiple Intent Flags 中 FLAG_ACTIVITY_SINGLE_TOP),就是結果1.

上面的描述中,B 的結果1 就很適合我們業務流程的封裝,為什麼這麼說呢,這裡舉一個例子。背景:一個社交 App, 首頁資訊流。假設所有 Activity 都在一個任務棧中,那麼這個任務棧的變化如下圖所示:

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

(1) 匿名使用者瀏覽了一會,進行了一次點贊操作,此時觸發登入流程,登入介面被彈出來; (2) 使用者輸完正確的使用者名稱密碼後(假設為老使用者),伺服器接收到登入請求後,檢測到風險,發起兩步驗證(需要簡訊驗證),客戶端彈出簡訊驗證頁面進行驗證; (3) 使用者輸入正確的驗證碼,點選登入,回到資訊流頁面,同時頁面上點贊操作已經成功。

如何實現第3步中描述的現象呢? 只要在 Activity C 裡面,登入成功的邏輯裡新增啟動 Activity A 的邏輯,同時給這個啟動 Activity A 的 Intent 同時加上 FLAG_ACTIVITY_CLEAR_TOP FLAG_ACTIVITY_SINGLE_TOP 兩個 Intent Flag 即可(所有 Activity 的 launchMode 均為 standard), 程式碼如下:

Intent intent = new Intent(ActivityC.this, ActivityA.class);
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
// put your data here
// intent.putExtra("data", "result");
startActivity(intent);
複製程式碼

使用這種方法,優點如下:

  1. 可以把使用者重新帶回到觸發流程之前頁面;
  2. 有攜帶資料(本例中可以是使用者的登入資訊)的回撥;
  3. 在回撥中可以幫助使用者繼續完成之前被打斷的操作;
  4. 流程頁面全部自動銷燬,甚至 Activity C 自身的 finish() 方法都不需要呼叫;
  5. 即使開啟不保留活動依然有效,Acitivity A 的 onNewIntent 回撥會在 onCreate 之後被呼叫;
  6. 對流程進行一半時的頁面回退支援良好;

看上去這種方法似乎比 方案一 要好很多, 但是其實上面的例子還是有點問題:最後一步 Activity C 顯式啟動了 Activity A。流程頁不應該和觸發流程的頁面發生任何耦合,不然流程就無法複用,所以應該想一種機制,可以讓兩者不耦合,同時又可以把流程完成後攜帶的資料傳遞給流程觸發的地方。目前能想到比較合適的手段就是 方案一 中的 startActivityForResult了,具體做法是,Activity A 只和 Activity B 通過 startActivityForResultonActivityResult 進行互動,流程最後一個頁面則通過上述的 onNewIntent 把流程結束相關資料帶回流程第一個頁面(Activity B),由 Activity B 通過 onActivityResult 把資料傳遞給流程觸發者,具體邏輯如下圖所示:

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

這樣流程封裝和複用的問題解決了,但是這個方案還是存在一些缺點:

  1. startActivityForResult 一樣,寫法 Dirty,如果流程很多,維護較為不易;
  2. 即使是同一個流程,在同一個頁面中也存在複用的情況,不增加新欄位無法在 onNewIntent 裡面區分;
  3. 問題(三) 沒有得到解決,onNewIntent 資料傳遞也是基於 Intent 的, 也沒有用於步驟間共享資料的措施,共享的資料可能需要從頭傳到尾;
  4. 步驟之間有輕微的耦合:每個步驟需要負責啟動它的下一個步驟;

其中缺點2解釋一下,點贊會觸發登入,評論也會觸發登入,兩者登入成功都會返回資訊流頁面。不增加額外欄位,onNewIntent 只是接收到了使用者的登入資訊,並不知道剛剛進行的是點贊還是評論。

這個方案和純 startActivityForResult 的方案(方案一)有一種互補的感覺,一個擅長流程頁不支援回退的情況,另一種擅長流程頁支援回退的情況,而且它們都沒有很好解決 問題(三) , 我們需要進一步探索是否有更優方案。

方案四:利用新開闢的 Activity 棧來完成業務流程

由於我們目前接手的專案中的流程頁面,都是基於 Activity 實現的,那麼自然而然就能想到應該讓處理流程的 Activity 們更加內聚,如果流程相關 Activity 都是在一個獨立的 Activity 任務棧中,那麼當流程處理完以後,只要在拿到流程的最終結果以後銷燬那個任務棧即可,簡單又粗暴。

如果依然使用上面那個資訊流登入的例子的話,Activity 任務棧的變化應該如下圖所示:

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

要實現圖中的效果,那麼需要考慮兩個問題:

  1. 如何開啟一個新的任務棧,把涉及流程的 Activity 都放到裡面?
  2. 如何在流程結束以後銷燬流程佔用的任務棧,同時把流程結果返回到觸發流程的頁面?

問題1相對而言比較簡單,我們把流程相關的所有 Activity 顯式設定 taskAffinity (例如 com.flowtest.flowA), 注意不要和 Application 的 packageName 相同,因為 Activity 預設的 taskAffinity 就是 packageName。啟動流程的時候,在啟動流程入口 Activity 的 Intent 中增加 FLAG_ACTIVITY_NEW_TASK 即可:

<!-- In AndroidManifest.xml -->
<activity android:name=".ActivityB" android:taskAffinity="com.flowtest.flowA"/>
複製程式碼
// In ActivityA.java
Intent intent = new Intent(ActivityA.this, ActivityB.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
複製程式碼

而流程中的其他 Activity 的啟動方式不需要做任何修改,因為它們的 taskAffinity 與 流程入口 Activity 相同,所以它們會被自動置入同一個任務棧中。

問題2稍微複雜一點,據我所知,目前 Android 還沒有提供在任務棧之間相互通訊的手段,那我們只能回過頭繼續考慮 Activity 之間資料傳遞的方法。首先,出於流程複用性考慮, 流程依然還是暴露 Activity B, 而流程觸發者(Activity A) 通過 Activity B 以及startActivityForResultonActivityResult 兩個方法與流程互動; 其次,流程內部的步驟要和 Activity B 的互動的話,有 onNewIntent 以及 onActivityResult 這兩種回撥的方法。

看上去這種思路比較有希望,但是經過幾次試驗,我放棄了這種做法,原因是一旦開闢一個新的任務棧來處理,手機上最近應用列表上,就會多一個App的欄位(多出來的那個代表流程任務棧),也就是說使用者在做流程的時候如果按 Home 鍵切換出去,那他想回來的時候,按 最近應用列表,他會看到兩個任務,他不知道回哪個。即使流程完成, 最近應用列表 中還會保留著那個位置,後續依然會給使用者造成困惑。另外,任務棧切換時的預設動畫和 Activty 預設切換動畫不同(雖然可以修改成一樣),會在使用過程中感覺有一絲怪異。

方案五:使用 Fragment 框架封裝流程

到目前為止,上面各種方案中,相對能使用的方案,只有方案一和方案三。方案一中又存在一對矛盾,如果希望流程內所有步驟都能優雅銷燬,步驟之間耦合更鬆散,就沒法保證回退行為;回退行為有保證以後,流程步驟的銷燬就不夠優雅了,步驟之間耦合也緊一些;方案三中,流程步驟銷燬的問題和回退得以優雅解決,但是步驟間的耦合沒有解決。我們希望一種能夠兩全其美的方案,步驟之間耦合鬆散,回退優雅,銷燬也容易。

仔細分析兩種方案的優缺點,其實不難得出結論:之所以僅靠 Activity 之間互動難以達成上述目標本質上是由於 Activity 任務棧沒有開放給我們足夠的 API,我們與任務棧能做的事情有限。看到這裡其實就容易想到 Android 中,除了 Activity ,Fragment 也是擁有 Back Stack 的,如果我們把流程頁以 Fragment 封裝,就可以在一個 Activity 內通過 Fragment 切換完成流程;由於 Activity 與 Fragment Back Stack 生命週期同在,Activity 就成了理想的儲存 Fragment Back Stack 狀態(流程狀態)的理想場所;此外,只要呼叫 Activity 的 finish() 方法就可以清空 Fragment Back Stack!

仍然以登入兩步驗證為例,經過 Fragment 改造以後,觸發流程的點只會啟動一個 Activity ,並且只和這個 Activity 互動,如下圖所示:

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

Activity A 通過 startActivityForResult 啟動 ActivityLogin,ActivityLogin 在內部通過 Fragment 把業務流程完成,finish 自身,並且把流程結果通過 onActivityResult 返回給 Activity A。流程包含的兩個步驟被封裝成兩個 Fragment , 它們與宿主 Activity 的互動如下圖所示:

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

  1. ActivityLogin 啟動流程第一個頁面 ---- 密碼登入,通過 push 方法(本例中的方法皆虛擬碼)把 Fragment A 展示到使用者面前,使用者登入密碼驗證成功,通過 onLoginOk 方法回撥 ActivityLogin,ActivityLogin 儲存該步驟必要資訊。

  2. ActivityLogin 啟動流程第二個頁面 ---- 兩步驗證,同時附帶上個步驟的資訊傳遞給 Fragment B,也是通過 push 方法,手機簡訊驗證成功,通過 onValidataOk 方法回撥 ActivityLogin, ActivityLogin 把這步的資料和之前步驟的資料打包,通過 onActivityResult 傳遞給流程觸發點。

再回過頭看開頭,我們對新的流程框架提出了7個待解決問題,再看本方案,我們可以發現,除了 問題(三) 還存疑,其餘的問題應該說都得到了妥善的解決。

正常情況下,新增 Fragment 是不帶有動畫的,沒有像 Activity 切換那樣的預設動畫。為了可以使 Fragment 的切換給使用者的感覺和 Activity 的體驗一致,我建議把 Fragment 的切換動畫設定成和 Activity 一樣。首先,給 Activity 指定切換動畫(不同手機 ROM 的預設 Activity 切換動畫不一樣,為了使 App 體驗一致強烈推薦手動設定切換動畫)。

以向左滑動進入、向右滑動推出的動畫為例,styles.xml 中設定主題如下:

<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
    <item name="android:windowAnimationStyle">@style/ActivityAnimation</item>
    <!-- Customize your theme here. -->
    ...
</style>


<!-- Activity 進入、退出動畫 -->
<style name="ActivityAnimation" parent="android:Animation.Activity">
    <item name="android:activityOpenEnterAnimation">@anim/push_in_left</item>
    <item name="android:activityCloseEnterAnimation">@anim/push_in_right</item>
    <item name="android:activityCloseExitAnimation">@anim/push_out_right</item>
    <item name="android:activityOpenExitAnimation">@anim/push_out_left</item>
</style>
複製程式碼

定義進場和退場動畫,動畫檔案放在 res/anim 資料夾下:

<!-- file: push_in_left.xml -->
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate   
    android:fromXDelta="100%p"    
    android:toXDelta="0"    
    android:duration="400"/>    
</set>

<!-- file: push_in_right.xml -->
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate
            android:fromXDelta="-25%p"
            android:toXDelta="0"
            android:duration="400"/>
</set>

<!-- file: push_out_right.xml -->
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate
        android:fromXDelta="0"
        android:toXDelta="100%p"
        android:duration="400"/>
</set>

<!-- file: push_out_left.xml -->
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate
            android:fromXDelta="0"
            android:toXDelta="-25%p"
            android:duration="400"/>
</set>
複製程式碼

所以,加上 Fragment 的切換動畫以後,上面的 push 方法的實現如下:

    protected void push(Fragment fragment, String tag) {
        List<Fragment> currentFragments = fragmentManager.getFragments();
        FragmentTransaction transaction = fragmentManager.beginTransaction();
        if (currentFragments.size() != 0) {
            // 流程中,第一個步驟的 Fragment 進場不需要動畫,其餘步驟需要
            transaction.setCustomAnimations(
                    R.anim.push_in_left,
                    R.anim.push_out_left,
                    R.anim.push_in_right,
                    R.anim.push_out_right
            );
        }
        transaction.add(R.id.fragment_container, fragment, tag);
        if (currentFragments.size() != 0) {
            // 從流程的第二個步驟的 Fragment 進場開始,需要同時隱藏上一個 Fragment,這樣才能看到切換動畫
            transaction
                    .hide(currentFragments.get(currentFragments.size() - 1))
                    .addToBackStack(tag);
        }
        transaction.commit();
    }
複製程式碼

每個代表流程中一個具體步驟的 Fragment 的職責也是清晰的:收集資訊,完成步驟,並把該步驟的結果返回給宿主 Activity。該步驟本身不負責啟動下一個步驟,與其他步驟之間也是鬆耦合的,一個具體的例子如下:

public class PhoneRegisterFragment extends Fragment {

    PhoneValidateCallback mPhoneValidateCallback;

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_simple_content, container, false);
        Button button = view.findViewById(R.id.action);
        EditText input = view.findViewById(R.id.input);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if (mPhoneValidateCallback != null) {
                    mPhoneValidateCallback.onPhoneValidateOk(input.getText().toString());
                }
            }
        });
        return view;
    }

    public void setPhoneValidateCallback(PhoneValidateCallback phoneValidateCallback) {
        mPhoneValidateCallback = phoneValidateCallback;
    }

    public interface PhoneValidateCallback {
        void onPhoneValidateOk(String phoneNumber);
    }
}
複製程式碼

這時候,作為一系列流程步驟的宿主 Activity 的職責也明確了:

  1. 作為流程對外暴露的介面,對外資料互動(startActivityForResultonActivityResult
  2. 負責流程步驟的排程,決定步驟間呼叫的先後順序
  3. 流程步驟間資料共享的通道

舉一個例子,註冊流程由3個步驟組成:驗證手機號、設定暱稱、設定密碼,流程 Activity 如下所示:

public class RegisterActivity extends BaseActivity {

    String phoneNumber;
    String nickName;
    User mUser;

    PhoneRegisterFragment mPhoneRegisterFragment;
    NicknameCheckFragment mNicknameCheckFragment;
    PasswordSetFragment mPasswordSetFragment;


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        /**
         * 保證無論 Activity 無論在首次啟動還是銷燬重建的情況下都能獲取正確的 
         * Fragment 例項
         */
        mPhoneRegisterFragment = findOrCreateFragment(PhoneRegisterFragment.class);
        mNicknameCheckFragment = findOrCreateFragment(NicknameCheckFragment.class);
        mPasswordSetFragment = findOrCreateFragment(PasswordSetFragment.class);

        // 如果是首次啟動,把流程的第一個步驟代表的 Fragment 壓棧
        if (savedInstanceState ==  null) {
            push(mPhoneRegisterFragment);
        }

        // 負責驗證完手機號後啟動設定暱稱
        mPhoneRegisterFragment.setPhoneValidateCallback(new PhoneRegisterFragment.PhoneValidateCallback() {
            @Override
            public void onPhoneValidateOk(String phoneNumber) {
                RegisterActivity.this.phoneNumber = phoneNumber;

                push(mNicknameCheckFragment);
            }
        });

        // 設定完暱稱後啟動設定密碼
        mNicknameCheckFragment.setNicknameCheckCallback(new NicknameCheckFragment.NicknameCheckCallback() {
            @Override
            public void onNicknameCheckOk(String nickname) {
                RegisterActivity.this.nickName = nickName;

                mPasswordSetFragment.setParams(phoneNumber, nickName);
                push(mPasswordSetFragment);
            }
        });

        // 設定完密碼後,註冊流程結束
        mPasswordSetFragment.setRegisterCallback(new PasswordSetFragment.PasswordSetCallback() {
            @Override
            public void onRegisterOk(User user) {
                mUser = user;
                Intent intent = new Intent();
                intent.putExtra("user", mUser);
                setResult(RESULT_OK, intent);
                finish();
            }
        });
    }
}
複製程式碼

其中 findOrCreateFragment 方法的實現如下:

public  <T extends Fragment> T findOrCreateFragment(@NonNull Class<T> fragmentClass) {
    String tag = fragmentClass.fragmentClass.getCanonicalName();
    FragmentManager fragmentManager = getSupportFragmentManager();
    T fragment = (T) fragmentManager.findFragmentByTag(tag);
    if (fragment == null) {
        try {
            fragment = fragmentClass.newInstance();
        } catch (InstantiationException e) {
                e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }
    return fragment;
}
複製程式碼

看到這裡,您也許會對 findOrCreateFragment 這個方法實現有一定疑問,主要是針對使用 Class.newInstance 這個方法對 Fragment 進行例項化這行程式碼。通常來說,Google 推薦在 Fragment 裡自己實現一個 newInstance 方法來負責對 Fragment 的例項化,同時,Fragment 應該包含一個無參建構函式,Fragment 初始化的引數不應該以建構函式的引數的形式存在,而是應該通過 Fragment.setArguments 方法進行傳遞,符合上面要求的 newInstance 方法應該形如:

public static MyFragment newInstance(int someInt) {
    MyFragment myFragment = new MyFragment();

    Bundle args = new Bundle();
    args.putInt("someInt", someInt);
    myFragment.setArguments(args);

    return myFragment;
}
複製程式碼

因為使用 Fragment.setArguments 方法設定的引數,可以在 Activity 銷燬重建時(重建過程也包含重建原來 Activity 管理的那些 Fragment),傳遞給那些被 Activity 恢復的 Fragment。

但是這邊的程式碼為什麼要這麼處理呢?首先,Activity 只要進入後臺,就有可能在某個時刻被殺死,所以當我們回到某個 Activity 的時候,我們應該有意識:這個 Activity 可能是剛剛離開前的那個 Activity,也有可能是已經被殺死,但是重新被建立的新 Activity。如果是重新被建立的情況,那麼之前 Activity 內的狀態可能已經丟失了。也就是說對於給每個流程步驟的 Fragment 設定的回撥(setPhoneValidateCallbacksetNicknameCheckCallbacksetRegisterCallback)有可能已經無效了,因為 Activity 重新建立以後,記憶體中是一個新的物件,這個物件只經歷了 onCreateonStartonResume 這些回撥,如果給 Fragment 設定回撥的呼叫不在這些生命週期函式裡,那麼這些狀態就已經丟失了(可以通過 開發者選項裡不保留活動 選項進行驗證)。

但是有一個解決方法,就是把設定 Fragment 回撥的呼叫寫在 Activity 的 onCreate 函式裡(因為無論是全新的 Activity 還是重建的 Activity 都會走 onCreate 生命週期),如本例中的 onCreate 方法的寫法。但是這就要求在 onCreate 函式內,需要獲取所有 Fragment 的例項(無論是首次全新建立的 Fragment,還是被恢復情況下,利用 FragmentManager 查詢到的系統幫我們自動恢復的那個 Fragment)。

但是流程中,很常見的情況是,某個步驟啟動所需要的引數,依賴於上個步驟。如果使用 Google 推薦的那個最佳實踐,很顯然,我們在初始化的時候需要準備好所有引數,這是不現實的,Activity 的 onCreate 函式裡肯定沒有準備好靠後的步驟的 Fragment 初始化所需要的引數。

這裡就產生了一個矛盾:一方面為了保證銷燬重建情況下,流程繼續可用,需要在 onCreate 期間獲得所有 Fragment 例項;另一方面,無法在 onCreate 期間準備好所有 Fragment 初始化所需要的引數,用來以 Google 最佳實踐例項化 Fragment。

這裡的解決方案就是上面的 findOrCreateFragment 方法,不完全使用 Google 最佳實踐。利用 Fragment 應該包含一個無參建構函式 這一點,通過反射,例項化 Fragment。

fragment = fragmentClass.newInstance();
複製程式碼

利用 Fragment 初始化的引數不應該以建構函式的引數存在,而是應該通過 Fragment.setArguments 方法進行傳遞 這一點,在每個步驟結束的回撥裡啟動下一個步驟的程式碼(本例中的 push 方法)之前,通過 Fragment.setArguments 方法傳值。 PasswordSetFragment.setParams 的方法如下(底層就是 Fragment.setArguments 方法):

    public void setParams(String phone, String nickname) {
        Bundle bundle = new Bundle();
        bundle.putString("phone", phone);
        bundle.putString("nickname", nickname);
        setArguments(bundle);
    }
複製程式碼

其實通過靜態分析程式碼可以發現,呼叫 push 方法顯示的 Fragment 例項,都是在 FragmentManger 中尚未存在的,也就是說,都是那些只被通過反射例項化以後,卻還沒有真正走過任何 Fragment 生命週期函式的 準新 Fragment。所以說,雖然我們程式碼上好像和谷歌推薦的寫法不一樣了,但本質上依然遵循谷歌推薦的最佳實踐。

看到這裡,這個通過 Fragment Back Stack 實現的流程框架的所有關鍵細節就都說完了。這個方案對比 方案一方案三 顯然是更好的方案,因為它綜合了這兩個方案的優點。我們來總結一下這個方案的優點:

  1. 流程步驟間是解耦的,每個步驟職責清晰,只需要完成自己的事並且通知給宿主;
  2. 回退支援良好,使用者體驗流暢;
  3. 銷燬流程只需要呼叫 Activity 的 finish 方法,非常輕量級;
  4. 只有一個 Activity 代表這個流程暴露給外部,封裝良好而且易於複用;
  5. 流程步驟間資料的共享變得更簡單

再回顧一下本文一開始提出的流程框架需要解決的 7 個問題,可以發現除了 問題(三) 沒有完全解決以外,其餘問題應該都是得到了較為滿意的解決。我們來看一下 問題(三),這個問題的提出的前提是,流程的每個步驟是基於 Activity 實現的,雖然使用基於 Fragment 的方案以後,Fragment 回撥給 Activity 的資料不再受 Bundle 支援格式的限制,但是從 Activity push 啟動 Fragment 需要先呼叫 setArguments 方法,而這個方法支援的格式依然受 Bundle 的限制。如果我們希望 Android 在 Activity 銷燬後重建時正確恢復 Fragment ,我們只能接受這一點。

另外,雖然 Fragment 傳遞給 Activity 的資料格式不受限制了,考慮到 Activity 有可能銷燬重建,為了保持 Activity 的狀態,我們還是需要實現 Activity 的 onSaveInstanceState 方法和 onRestoreInstanceState 方法,而這兩個方法依然是和 Bundle 打交道的:

    @Override
    protected void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        outState.putString("phoneNumber", phoneNumber);
        outState.putString("nickName", nickName);
        outState.putSerializable("user", mUser);
    }

    @Override
    protected void onRestoreInstanceState(Bundle savedInstanceState) {
        super.onRestoreInstanceState(savedInstanceState);
        if (savedInstanceState == null) return;
        phoneNumber = savedInstanceState.getString("phoneNumber");
        nickName = savedInstanceState.getString("nickName");
        mUser = (User) savedInstanceState.getSerializable("user");
    }
複製程式碼

也就是說,如果我們希望我們的 Activity/Fragment 能歷經生命週期摧殘,而始終以正確的姿態被系統恢復,那麼我們就要保證我們的資料是能夠被打包進 Bundle 的。我們犧牲了編碼上的便利性,換取程式碼執行的正確性。所以目前看來,**問題(三)**雖然沒有被我們解決或者繞過,但是其實本質上它的存在是可以被接受的。

總結

在探討和比較了上面這麼多方案以後,我們終於找到相對而言最適合解決方案 ---- 方案(五):基於 Fragment 封裝流程框架。但是這還不是終點,雖然在理論指標上,這個方案滿足了我們的需求,但是實際開發中,還是有一些小問題等待被解決。比如:

  1. 流程對外暴露的介面是 startActivityForResult/onActivityResult,基於這個 API 進行開發,很難稱得上是“優雅”;
  2. 發起流程的上下文應該如何儲存,requestCode 能儲存的資訊量有限,尤其是在 ListView / RecyclerView 的場合下;
  3. 或許我們應該藉助一個框架來幫助我們實現流程框架,而不是手寫很多重複程式碼;

等等。

在下一篇分享中,我將繼續介紹如何更優雅地去使用、封裝流程框架,歡迎繼續關注!

相關文章