Square:從今天開始拋棄Fragment吧!
- 原文鏈接 : Advocating Against Android Fragments
- 原文作者 : Pierre-Yves Ricau
- 譯文出自 : 開發技術前線 www.devtf.cn
- 譯者 : chaossss
- 校對者: Belial
- 狀態 : 完成
最近我在 Droidcon Paris 上進行了一個技術相關的演講,我在這次演講中給大家展示了 Square 使用 Fragment 進行開發時遇到的種種問題,以及其他 Android 開發者是怎麼避免在項目中使用 Fragment 的。
在 2011 年那會,由於下面的原因我們決定使用 Fragment:
- 在那會,雖然我們很想讓應用能在平板設備上被使用,但我們確實沒能爲平板提供平臺支持。而 Fragment 能幫助我們完成這項願望,建立響應式 UI 界面。
- Fragment 是視圖控制器,它們能夠將一大塊耦合嚴重的業務邏輯模塊解耦,並使得解耦後的業務邏輯能夠被測試。
- Fragment 的 API 能夠進行回退棧管理(例如,它能反射某個 Activity 內 Activity 棧的具體操作)
- 因爲 Fragment 處於視圖層的頂層,而爲 View 設置動畫並不麻煩,使得 Fragment 爲設置頁面切換的過渡效果提供了更好的支持。
- Google 建議我們使用 Fragment,而我們作爲開發者都想讓自己的代碼符合標準。
在 2011年之後,我們在爲 Square 進行開發的過程中發現了比使用 Fragment 更好的方法。
關於 Fragment 你不知道的事
The lolcycle
在 Android 中,Context 就像一個上帝對象, 因爲在 Context 類中涵蓋了太多 Android 系統的信息和相關的操作,使得 Context 在 Android 系統中相當於一個全知全能的上帝,而 Activity 就是爲 Context 添加了生命週期的子類。不過讓上帝具有生命週期還是有些諷刺的。雖然 Fragment 不是上帝對象,但 Fragment 爲了能夠完成 Activity 中能完成的各種操作,使 Fragment 自身的生命週期變得異常複雜。
Steve Pomeroy 做了一張 Fragment 的完整生命週期圖,我相信任誰看到這張圖都不會好受:
這張圖由 Steve Pomeroy 完成,圖中移除了 Activity 的生命週期,分享這張圖需要獲得 CC BY-SA 4.0 許可。
整個 Fragment 的生命週期讓你很頭疼要怎樣使用這些回調方法,它們是同步調用的呢,還是隻是一次性全部調用呢,還是其它情況……?
難於調試
當你的應用出現 Bug,你得用調試工具一步一步地執行代碼才能知道到底發生了什麼,雖說一般情況下這樣做 Bug 都能解決,但如果你在調試的時候發現 Bug 和 FragmentManagerImpl 類存在某種聯繫,那麼我可要好好恭喜你即將中大獎了!
因爲要跟蹤 FragmentManagerImpl 類內代碼的執行順序,並進行調試是很困難的,這也使得修復應用中相關的 Bug 也變得異常困難:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
switch (f.mState) { case Fragment.INITIALIZING: if (f.mSavedFragmentState != null ) { f.mSavedViewState = f.mSavedFragmentState.getSparseParcelableArray( FragmentManagerImpl.VIEW_STATE_TAG); f.mTarget = getFragment(f.mSavedFragmentState, FragmentManagerImpl.TARGET_STATE_TAG); if (f.mTarget != null ) { f.mTargetRequestCode = f.mSavedFragmentState.getInt( FragmentManagerImpl.TARGET_REQUEST_CODE_STATE_TAG, 0); } f.mUserVisibleHint = f.mSavedFragmentState.getBoolean( FragmentManagerImpl.USER_VISIBLE_HINT_TAG, true ); if (!f.mUserVisibleHint) { f.mDeferStart = true ; if (newState > Fragment.STOPPED) { newState = Fragment.STOPPED; } } } // ... } |
如果你曾經需要解決應用旋轉後產生一個與旋轉前 UI 相同(方向發生變化)的獨立的 Fragment 的需求,我想你應該懂我在說什麼。(別給我提嵌套使用的 Fragment!)
我想下面這張圖很好地詮釋了這類代碼給程序員帶來的傷害(由於版權問題我得放出這張圖的出處哈:this cartoon):
在多年的深度分析中我得出結論:操蛋程度/調試耗費的時間 = 2^m,m 爲 Fragment 的個數。
Fragment 是視圖控制器?想太多
因爲 Fragment 需要創建、綁定和配置 View,它們包含了許多與 View 關聯的結點,這就意味着 View 類代碼中的業務邏輯並沒有真正地被解耦,正是這個原因使得我們要爲 Fragment 實現測試單元將會變得很困難。
Fragment transactions
Fragment 的 transaction 允許你執行一系列的 Fragment 操作,但不幸的是,提交 transaction 是異步操作,並且在 UI 線程的 Handler 隊列的隊尾被提交。這會在接收多個點擊事件或配置發生改變時讓你的 App 處在未知的狀態。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
class BackStackRecord extends FragmentTransaction { int commitInternal(boolean allowStateLoss) { if (mCommitted) throw new IllegalStateException( "commit already called" ); mCommitted = true ; if (mAddToBackStack) { mIndex = mManager.allocBackStackIndex( this ); } else { mIndex = -1; } mManager.enqueueAction( this , allowStateLoss); return mIndex; } } |
創建 Fragment 可能帶來的問題
Fragment 的實例能夠通過 Fragment Manager 創建,例如下面的代碼看起來沒有什麼問題:
1
2
3
4
|
DialogFragment dialogFragment = new DialogFragment() { @Override public Dialog onCreateDialog(Bundle savedInstanceState) { ... } }; dialogFragment.show(fragmentManager, tag); |
然而,當我們需要存儲 Activity 實例的狀態時,Fragment Manager 可能會通過反射機制重新創建該 Fragment 的實例,又因爲這是一個匿名內部類,該類有一個隱藏的構造器的參數正是外部類的引用,如果大家有看過這篇博文的話就會知道,擁有外部引用可能會帶來內存泄漏的問題。
1
2
3
4
|
android.support.v4.app.Fragment$InstantiationException: Unable to instantiate fragment com.squareup.MyActivity$1: make sure class name exists, is public, and has an empty constructor that is public |
Fragment 教給我們的思想
儘管 Fragment 有着上面提到的缺點,但也是 Fragment 教給我們許多代碼架構的思想:
- 獨立的 Activity 接口:實際上我們並不需要爲每一個頁面創建一個 Activity,我們大可以將應用切分成許多解耦的視圖組件,按照我們的實際需求把它們組裝成我們想要的界面。這樣做也能簡化生命週期和動畫設置,因爲 我們還能將視圖組件切分爲 view 組件和控制器組件。
- 回退棧不是 Activity 的特有概念,也就意味着你能在 Activity 內部實現回退棧。
- 不需要添加新的 API,我們需要的只是 Activity,View 和 LayoutInflater。
響應式 UI:Fragment VS Custom View
Fragment
我們不妨先來看看一個 Fragment 的範例,界面中顯示了一個 list。
HeadlinesFragment 就是顯示 List 的簡單 Fragment:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
public class HeadlinesFragment extends ListFragment { OnHeadlineSelectedListener mCallback; public interface OnHeadlineSelectedListener { void onArticleSelected(int position); } @Override public void onCreate(Bundle savedInstanceState) { super .onCreate(savedInstanceState); setListAdapter( new ArrayAdapter(getActivity(), R.layout.fragment_list, Ipsum.Headlines)); } @Override public void onAttach(Activity activity) { super .onAttach(activity); mCallback = (OnHeadlineSelectedListener) activity; } @Override public void onListItemClick(ListView l, View v, int position, long id) { mCallback.onArticleSelected(position); getListView().setItemChecked(position, true ); } } |
現在有趣的事情來了:ListFragmentActivity 必須控制 list 是否處於同一個頁面中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
public class ListFragmentActivity extends Activity implements HeadlinesFragment.OnHeadlineSelectedListener { @Override public void onCreate(Bundle savedInstanceState) { super .onCreate(savedInstanceState); setContentView(R.layout.news_articles); if (findViewById(R.id.fragment_container) != null ) { if (savedInstanceState != null ) { return ; } HeadlinesFragment firstFragment = new HeadlinesFragment(); firstFragment.setArguments(getIntent().getExtras()); getFragmentManager() .beginTransaction() .add(R.id.fragment_container, firstFragment) .commit(); } } public void onArticleSelected(int position) { ArticleFragment articleFrag = (ArticleFragment) getFragmentManager() .findFragmentById(R.id.article_fragment); if (articleFrag != null ) { articleFrag.updateArticleView(position); } else { ArticleFragment newFragment = new ArticleFragment(); Bundle args = new Bundle(); args.putInt(ArticleFragment.ARG_POSITION, position); newFragment.setArguments(args); getFragmentManager() .beginTransaction() .replace(R.id.fragment_container, newFragment) .addToBackStack( null ) .commit(); } } } |
自定義 View
我們不妨重新實現一個簡化版的只使用了 View 的代碼
首先,我們會引入一個叫作“容器”的概念,“容器”的作用是幫助我們展示一項內容並處理後退操作
1
2
3
4
5
|
public interface Container { void showItem(String item); boolean onBackPressed(); } |
Acitivity 將假設始終存在容器,並且幾乎不會將業務交給容器處理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
public class MainActivity extends Activity { private Container container; @Override protected void onCreate(Bundle savedInstanceState) { super .onCreate(savedInstanceState); setContentView(R.layout.main_activity); container = (Container) findViewById(R.id.container); } public Container getContainer() { return container; } @Override public void onBackPressed() { boolean handled = container.onBackPressed(); if (!handled) { finish(); } } } |
要顯示的 List 也只是個平凡的 List
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
public class ItemListView extends ListView { public ItemListView(Context context, AttributeSet attrs) { super (context, attrs); } @Override protected void onFinishInflate() { super .onFinishInflate(); final MyListAdapter adapter = new MyListAdapter(); setAdapter(adapter); setOnItemClickListener( new OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { String item = adapter.getItem(position); MainActivity activity = (MainActivity) getContext(); Container container = activity.getContainer(); container.showItem(item); } }); } } |
這樣做的好處是:能夠基於資源文件夾在不同的 XML 佈局文件
res/layout/main_activity.xml
1
2
3
4
5
6
7
8
9
10
11
|
<com.squareup.view.SinglePaneContainer xmlns:android= "http://schemas.android.com/apk/res/android" android:layout_width= "match_parent" android:layout_height= "match_parent" android:id= "@ id/container" > <com.squareup.view.ItemListView android:layout_width= "match_parent" android:layout_height= "match_parent" /> |
res/layout-land/main_activity.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
<com.squareup.view.DualPaneContainer xmlns:android= "http://schemas.android.com/apk/res/android" android:layout_width= "match_parent" android:layout_height= "match_parent" android:orientation= "horizontal" android:id= "@ id/container" > <com.squareup.view.ItemListView android:layout_width= "0dp" android:layout_height= "match_parent" android:layout_weight= "0.2" />
"@layout/detail" android:layout_width= "0dp" android:layout_height= "match_parent" android:layout_weight= "0.8" /> |
下面是這些容器類的簡單實現:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
public class DualPaneContainer extends LinearLayout implements Container { private MyDetailView detailView; public DualPaneContainer(Context context, AttributeSet attrs) { super (context, attrs); } @Override protected void onFinishInflate() { super .onFinishInflate(); detailView = (MyDetailView) getChildAt(1); } public boolean onBackPressed() { return false ; } @Override public void showItem(String item) { detailView.setItem(item); } } |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
public class SinglePaneContainer extends FrameLayout implements Container { private ItemListView listView; public SinglePaneContainer(Context context, AttributeSet attrs) { super (context, attrs); } @Override protected void onFinishInflate() { super .onFinishInflate(); listView = (ItemListView) getChildAt(0); } public boolean onBackPressed() { if (!listViewAttached()) { removeViewAt(0); addView(listView); return true ; } return false ; } @Override public void showItem(String item) { if (listViewAttached()) { removeViewAt(0); View.inflate(getContext(), R.layout.detail, this ); } MyDetailView detailView = (MyDetailView) getChildAt(0); detailView.setItem(item); } private boolean listViewAttached() { return listView.getParent() != null ; } } |
不難想象:將容器類抽象,並用這種的方式開發 App,不但不需要 Fragment,還能架構出容易理解的代碼。
View 和 Presenter
自定義 View 在應用中非常有用,但我們希望將業務邏輯從 View 中剝離,轉交給特定的控制器處理,也就是接下來我們所說的 Presenter,引入 Presenter 能提高代碼的可讀性和可測試性。如果你不信的話,不妨看看重構後的 MyDetailView:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
public class MyDetailView extends LinearLayout { TextView textView; DetailPresenter presenter; public MyDetailView(Context context, AttributeSet attrs) { super (context, attrs); presenter = new DetailPresenter(); } @Override protected void onFinishInflate() { super .onFinishInflate(); presenter.setView( this ); textView = (TextView) findViewById(R.id.text); findViewById(R.id.button).setOnClickListener( new OnClickListener() { @Override public void onClick(View v) { presenter.buttonClicked(); } }); } public void setItem(String item) { textView.setText(item); } } |
我們來看看 Square 註冊界面中編輯賬戶的頁面吧!
Presenter 將在更高層級中操控 View:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
class EditDiscountPresenter { // ... public void saveDiscount() { EditDiscountView view = getView(); String name = view.getName(); if (isBlank(name)) { view.showNameRequiredWarning(); return ; } if (isNewDiscount()) { createNewDiscountAsync(name, view.getAmount(), view.isPercentage()); } else { updateNewDiscountAsync(discountId, name, view.getAmount(), view.isPercentage()); } close(); } } |
大家可以看到,爲這個 Presenter 實現測試單元猶如一縷春風拂面來,甚是舒心爽快吶~
1
2
3
4
5
6
7
|
@Test public void cannot_save_discount_with_empty_name() { startEditingLoadedPercentageDiscount(); when(view.getName()).thenReturn( "" ); presenter.saveDiscount(); verify(view).showNameRequiredWarning(); assertThat(isSavingInBackground()).isFalse(); } |
回退棧管理
通過異步處理來管理回退棧實在是牛刀殺雞,大材小用了……我們只需要用一個超輕量級庫——Flow,就可以達到目的。有關 Flow 的介紹 Ray Ryan 已經寫過博客了,我就不在此贅述啦。
我把 UI 相關的代碼全都寫在 Fragment 裏了咋辦呀,在線等,急!!!
別理你的 Fragment,你就一點一點地把 View 相關的代碼移到自定義 View 裏,然後把涉及到的業務邏輯交給能夠與 View 進行交互的 Presenter,然後你就會發現 Fragment 淪爲空殼,只有一些初始化自定義 View 和連接 View 和 Presenter 的操作:
1
2
3
4
5
6
|
public class DetailFragment extends Fragment { @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { return inflater.inflate(R.layout.my_detail_view, container, false ); } } |
事實上到了這一步你已經可以拋棄 Fragment 了。
拋棄 Fragment 確實得花很大的功夫,但我們已經做到了,感謝 Dimitris Koutsogiorgas 和 Ray Ryan 的偉大貢獻!
Dagger 和 Mortar 是什麼?
Dagger & Mortar 與 Fragment 成正交關係,換句話說,兩者間各自的變化不會影響對方,使用 Dagger & Mortar 既可以用 Fragment,也可以不用 Fragment。
Dagger 能幫你將應用模塊化爲一張由解耦組件構成的圖,它考慮了所有類間的連接關係並簡化了抽取依賴的操作,並實現一個與此相關的單例對象。
Mortar 在 Dagger 的頂層進行操作,主要優勢有如下兩點:
- Mortar 爲被注入組件提供簡單的生命週期回調,使你能實現不會因旋轉被銷燬的單例 Presenter,不過需要注意的是,Mortar 將當前界面元素的狀態儲存在 Bundle 中,使數據不會隨進程的結束而被清除。
- Mortar 爲你管理 Dagger 的子圖,並幫你將它們與 Activity 的生命週期關聯在一起,這種功能讓你能有效地實現“域”:當一個 View 被添加進來,它的 Presenter 和依賴都會作爲子圖被創建;當 View 被移除,你能輕易地銷燬“域”,並讓垃圾回收機制去完成它的工作。
結論
我們曾爲 Fragment 的誕生滿心歡喜,幻想着 Fragment 能爲我們帶來種種便利,然而這一切不過是場虛空大夢,我們最後發現騎着白馬的 Fragment 既不是王子也不是唐僧,只不過是人品爆發撿了只白馬的乞丐罷了:
- 我們遇到的大多數難以解決的 Bug 都與 Fragment 的生命週期有關。
- 我們只需要 View 創建響應式 UI,實現回退棧以及屏幕事件的處理,不用 Fragment 也能滿足實際開發的需求。