有必定實際 Android 項目開發經驗的人,必定曾經在項目中處理過不少重複的業務流程。例如開發一個社交 App ,那麼出於用戶體驗考慮,會須要容許匿名用戶(不登陸的用戶)能夠瀏覽信息流的內容(或者只能瀏覽受限的內容),當用戶想要進一步操做(例如點贊)時,提示用戶須要登陸或者註冊,用戶完成這個流程才能夠繼續剛剛的操做。而若是用戶須要進行更深刻的互動(例如評論,發佈狀態),則須要實名認證或者補充手機號這樣的流程完成才能夠繼續操做。php
而上面列舉的還只是比較簡單的狀況,流程之間還能夠互相組合。例如:匿名用戶點擊了評論,那麼須要連續作完:html
這兩個流程才能夠繼續評論某條信息。另外 1 中,登陸流程還可能嵌套「忘記密碼」或者「密碼找回」這樣的流程,也有可能由於服務端檢測到用戶異地登陸插入一個兩步驗證/手機號驗證流程。java
(一) 流程的體驗應當流暢android
根據本人使用市面上 App 的經驗,處理業務流程按體驗分類能夠分爲兩類,一種是觸發流程完成後,回到原頁面,沒有任何反應,用戶須要再點一下剛纔的按鈕,或者從新操做一遍剛纔觸發流程的行爲,才能進行原來想要的操做。另一種是,流程完成後,若是以前不知足的某些條件此時已經知足,那麼自動幫用戶繼續剛剛被打斷的操做。顯然,後一種更符合用戶的預期,若是咱們須要開發一個新的流程框架,那麼這個問題須要被解決。服務器
(二) 流程須要支持嵌套網絡
若是在進行一個流程的過程當中,某些條件不知足,須要觸發一個新的流程,應當能夠啓動那個流程,完成操做,而且返回繼續當前流程。數據結構
(三) 流程步驟間數據傳遞應當簡單app
傳統 Activity 之間數據傳遞是基於 Intent 的,因此數據類型須要支持 Parcelable
或者 Serializable
,而且須要以 key-value
的方式往 Intent 內填充,這是有必定侷限性的。此外,流程步驟間有些數據是共享的,有些是獨有的,如何方便地去讀寫這些數據?框架
有人可能會說,那能夠把這些數據放到一個公共的空間,想要讀寫這些數據的 Activity 自行訪問這些數據。可是若是真的這樣,帶來的新問題是:應用進程是可能在任意時刻銷燬重建的,重建之後內存中保存的這些數據也消失了。若是不但願看到這樣,就須要考慮數據持久化,而持久化的數據也只是被這一次流程用到,什麼時候應該銷燬這些數據?持久化的數據須要考慮自身的生命週期的問題,這引入了額外的複雜度。且並無比使用 Intent 傳遞方便多少。異步
(四) 流程須要適應 Android 組件生命週期
前面說到了應用進程銷燬重建的問題,因爲不少操做觸發流程之後,啓動的流程頁面是基於 Activity 實現的,因此完成流程回到的 Activity 實例頗有可能不是原來觸發流程時的那個 Activity 實例,原來那個實例可能已經被銷燬了,必須有合適的手段保證流程完成後,回到觸發流程的頁面能夠正確恢復上下文。
(五) 流程須要能夠簡單複用
還有流程每每是能夠複用的,例如登陸流程能夠在應用的不少地方觸發,因此觸發後流程結束之後的跳轉頁面也都是不同的,不能夠在流程結束的頁面寫死跳轉的頁面。
(六) 流程頁面在完成後須要比較容易銷燬
流程結束之後,流程每一個步驟頁面能夠簡單地銷燬,回到最初觸發流程的界面。
(七) 流程進行中回退行爲的處理
若是一個流程包含多箇中間步驟,用戶進行到中間某個步驟,按返回鍵時,行爲應該如何定義?在大多數狀況下,應該支持返回上一個步驟,可是在某些狀況下,也應當支持直接返回到流程起始步驟。
其實提及流程這個事情,咱們最容易想到的應該就是 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 任務棧的變化以下圖所示:
上圖的註冊流程實現細節以下:
startActivityForResult
啓動註冊頁面的第一個界面 ---- 驗證手機號;startActivityForResult
啓動設置暱稱頁面;onActivityResult
返回給驗證手機號界面,驗證手機號界面經過 startActivityForResult
啓動設置密碼界面,因爲設置密碼是最後一個流程,驗證手機號界面把以前收集好的手機號信息,暱稱信息都一併傳遞給密碼界面,密碼檢查合法後,根據現有的手機號、暱稱、密碼發起註冊;onActivityResult
把註冊結果反饋給設置手機號界面;onActivityResult
反饋給流程發起者(本例中即登陸界面);經過這個例子能夠看出來,手機號驗證界面 不只承擔了在註冊流程中驗證手機號的功能,還承擔了註冊流程對外的接口的職責。也就是說,觸發註冊流程的任意位置,都不須要對註冊流程的細節有任何瞭解,而只須要經過 startActivityForResult
和 onActivityResult
與流程對外暴露的 Activity 交互便可,以下圖:
上面的例子中可能有一點令您疑惑:爲何每一個步驟須要返回到驗證手機號頁面,而後由驗證手機號頁面負責啓動下個步驟呢?一方面,因爲驗證手機號是流程的第一個頁面,它承擔了流程調度者的身份,因此由它來進行步驟的分發,這樣的好處是每一個步驟(除了第一步)之間是解耦和內聚的,每一個步驟只須要作好本身的事情而且經過 onActivityResult
返回數據便可,假如後續流程的步驟發生增刪,維護起來比較簡單;另外一方面,因爲每一個步驟作完都返回,當最後一個步驟作完之後,以前流程的中間頁面都不存在了,不須要手動去銷燬作完的流程頁,這樣編碼起來也比較方便。
可是這麼作帶來一個小小的反作用:若是在流程的中間步驟按返回鍵,就會回到流程的第一個步驟,而用戶有時候是但願能夠回到上一個步驟。爲了讓用戶能夠在按返回鍵的時候返回上一個步驟,就必需要把每一個步驟的 Activity 壓棧,可是這樣作的話最後一步作完以後如何銷燬流程相關的全部 Activity 又是一個問題。
爲了解決流程相關 Activity 的銷燬問題,須要對上面的圖作一點修改,以下:
原先,每一個步驟作完本身的任務之後只須要結束本身並返回結果,修改後,每一個步驟作完本身的任務後不結束本身,也不返回結果,同時須要負責啓動流程的下一個步驟(經過 startActivityForResult
),當它的下一個步驟結束並返回它的結果的時候,這個步驟能在本身的 onActivityResult
裏接住,它在onActivityResult
裏須要作的是把本身的結果和它的下一個步驟的結果合在一塊兒,傳遞給它的上一個步驟,並結束本身。
經過這樣,實現了用戶按返回鍵所須要的行爲,可是這種作法的缺點是形成了流程內步驟間的耦合,一方面是啓動順序之間的耦合,另外一方面因爲須要同時攜帶它下個步驟的結果並返回形成的數據的耦合。
除此之外我還見過有人會單獨使用一個棧,來保存流程中啓動過的 Activity , 而後在流程結束後本身去手動依次銷燬每一個 Activity。我不太喜歡這種方法,它相比上面的方法沒有解決實質問題,並且須要額外維護一個數據結構,同時還要考慮生命週期,得不償失。
最後總結一下前文, startActivityForResult
這個方法有着它本身的優點:
- 足夠簡單,原生支持。
- 能夠處理流程返回結果,繼續處理觸發流程前的操做。
- 流程封裝良好,可複用。
- 雖然引入了額外的
requestCode
,可是在某種程度上保留了請求的上下文。
可是這個原生方案存在的問題也是顯而易見的:
- 寫法過於 Dirty,發起請求和處理結果的邏輯被分散在兩處,不易維護。
- 頁面中若是存在的多個請求,不一樣流程回調都被雜糅在一個
onActivityResult
裏,不易維護。- 若是一個流程包含多個頁面,代碼編寫會很是繁瑣,顯得力不從心。
- 流程步驟間數據共享基於 Intent,沒有解決 問題(三)。
- 流程頁面的自動銷燬和流程進行中回退行爲存在矛盾,問題(六) 和 問題(七) 沒有很好地解決。
實際開發中,這幾個問題都很是突出,影響開發效率,因此沒法直接拿來使用。
基於事件解耦也是一種比較優雅的解決方案,尤爲是著名的 EventBus 框架了,它實現了很是經典的發佈訂閱模型,完成了出色的解耦:
我相信不少 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,就會產生混亂,以下圖。
圖中,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 ,因此這裏再也不贅述。
因爲考慮使用第三方的框架始終沒法避開 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 會是下面兩種結果之一
- 在
onNewIntent
回調中接收到來自 D 傳遞過來的 Intent- 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 都在一個任務棧中,那麼這個任務棧的變化以下圖所示:
(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);
複製代碼
使用這種方法,優勢以下:
finish()
方法都不須要調用;onNewIntent
回調會在 onCreate
以後被調用;看上去這種方法彷佛比 方案一 要好不少, 可是其實上面的例子仍是有點問題:最後一步 Activity C 顯式啓動了 Activity A。流程頁不該該和觸發流程的頁面發生任何耦合,否則流程就沒法複用,因此應該想一種機制,可讓二者不耦合,同時又能夠把流程完成後攜帶的數據傳遞給流程觸發的地方。目前能想到比較合適的手段就是 方案一 中的 startActivityForResult
了,具體作法是,Activity A 只和 Activity B 經過 startActivityForResult
和 onActivityResult
進行交互,流程最後一個頁面則經過上述的 onNewIntent
把流程結束相關數據帶回流程第一個頁面(Activity B),由 Activity B 經過 onActivityResult
把數據傳遞給流程觸發者,具體邏輯以下圖所示:
這樣流程封裝和複用的問題解決了,可是這個方案仍是存在一些缺點:
startActivityForResult
同樣,寫法 Dirty,若是流程不少,維護較爲不易;onNewIntent
裏面區分;onNewIntent
數據傳遞也是基於 Intent 的, 也沒有用於步驟間共享數據的措施,共享的數據可能須要從頭傳到尾;其中缺點2解釋一下,點贊會觸發登陸,評論也會觸發登陸,二者登陸成功都會返回信息流頁面。不增長額外字段,onNewIntent
只是接收到了用戶的登陸信息,並不知道剛剛進行的是點贊仍是評論。
這個方案和純 startActivityForResult
的方案(方案一)有一種互補的感受,一個擅長流程頁不支持回退的狀況,另外一種擅長流程頁支持回退的狀況,並且它們都沒有很好解決 問題(三) , 咱們須要進一步探索是否有更優方案。
因爲咱們目前接手的項目中的流程頁面,都是基於 Activity 實現的,那麼天然而然就能想到應該讓處理流程的 Activity 們更加內聚,若是流程相關 Activity 都是在一個獨立的 Activity 任務棧中,那麼當流程處理完之後,只要在拿到流程的最終結果之後銷燬那個任務棧便可,簡單又粗暴。
若是依然使用上面那個信息流登陸的例子的話,Activity 任務棧的變化應該以下圖所示:
要實現圖中的效果,那麼須要考慮兩個問題:
- 如何開啓一個新的任務棧,把涉及流程的 Activity 都放到裏面?
- 如何在流程結束之後銷燬流程佔用的任務棧,同時把流程結果返回到觸發流程的頁面?
問題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 以及startActivityForResult
和 onActivityResult
兩個方法與流程交互; 其次,流程內部的步驟要和 Activity B 的交互的話,有 onNewIntent
以及 onActivityResult
這兩種回調的方法。
看上去這種思路比較有但願,可是通過幾回試驗,我放棄了這種作法,緣由是一旦開闢一個新的任務棧來處理,手機上最近應用列表上,就會多一個App的欄位(多出來的那個表明流程任務棧),也就是說用戶在作流程的時候若是按 Home 鍵切換出去,那他想回來的時候,按 最近應用列表,他會看到兩個任務,他不知道回哪一個。即便流程完成, 最近應用列表 中還會保留着那個位置,後續依然會給用戶形成困惑。另外,任務棧切換時的默認動畫和 Activty 默認切換動畫不一樣(雖然能夠修改爲同樣),會在使用過程當中感受有一絲怪異。
到目前爲止,上面各類方案中,相對能使用的方案,只有方案一和方案三。方案一中又存在一對矛盾,若是但願流程內全部步驟都能優雅銷燬,步驟之間耦合更鬆散,就無法保證回退行爲;回退行爲有保證之後,流程步驟的銷燬就不夠優雅了,步驟之間耦合也緊一些;方案三中,流程步驟銷燬的問題和回退得以優雅解決,可是步驟間的耦合沒有解決。咱們但願一種可以一箭雙鵰的方案,步驟之間耦合鬆散,回退優雅,銷燬也容易。
仔細分析兩種方案的優缺點,其實不可貴出結論:之因此僅靠 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 交互,以下圖所示:
Activity A 經過 startActivityForResult
啓動 ActivityLogin,ActivityLogin 在內部經過 Fragment 把業務流程完成,finish
自身,而且把流程結果經過 onActivityResult
返回給 Activity A。流程包含的兩個步驟被封裝成兩個 Fragment , 它們與宿主 Activity 的交互以下圖所示:
ActivityLogin 啓動流程第一個頁面 ---- 密碼登陸,經過 push
方法(本例中的方法皆僞代碼)把 Fragment A 展現到用戶面前,用戶登陸密碼驗證成功,經過 onLoginOk
方法回調 ActivityLogin,ActivityLogin 保存該步驟必要信息。
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 的職責也明確了:
startActivityForResult
和 onActivityResult
)舉一個例子,註冊流程由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 設置的回調(setPhoneValidateCallback
、 setNicknameCheckCallback
、 setRegisterCallback
)有可能已經無效了,由於 Activity 從新建立之後,內存中是一個新的對象,這個對象只經歷了 onCreate
、onStart
、onResume
這些回調,若是給 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 實現的流程框架的全部關鍵細節就都說完了。這個方案對比 方案一 和 方案三 顯然是更好的方案,由於它綜合了這兩個方案的優勢。咱們來總結一下這個方案的優勢:
finish
方法,很是輕量級;再回顧一下本文一開始提出的流程框架須要解決的 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 封裝流程框架。可是這還不是終點,雖然在理論指標上,這個方案知足了咱們的需求,可是實際開發中,仍是有一些小問題等待被解決。好比:
requestCode
能保存的信息量有限,尤爲是在 ListView / RecyclerView 的場合下;等等。
在下一篇分享中,我將繼續介紹如何更優雅地去使用、封裝流程框架,歡迎繼續關注!