Square:從今天開始拋棄Fragment吧!

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 的完整生命週期圖,我相信任誰看到這張圖都不會好受:

Square:從今天開始拋棄Fragment吧!

Square:從今天開始拋棄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):

Square:從今天開始拋棄Fragment吧!

Square:從今天開始拋棄Fragment吧!

在多年的深度分析中我得出結論:操蛋程度/調試耗費的時間 = 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 註冊界面中編輯賬戶的頁面吧!

Square:從今天開始拋棄Fragment吧!

Square:從今天開始拋棄Fragment吧!

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 也能滿足實際開發的需求。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章