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

最近我在 Droidcon Paris 上進行了 一個技術相關的演講 ,我在此次演講中給你們展現了 Square 使用 Fragment 進行開發時遇到的種種問題,以及其餘 Android 開發者是怎麼避免在項目中使用 Fragment 的。 php

在 2011 年那會,因爲下面的緣由咱們決定使用 Fragment: html

  • 在那會,雖然咱們很想讓應用能在平板設備上被使用,但咱們確實沒能爲平板提供平臺支持。而 Fragment 能幫助咱們完成這項願望,創建響應式 UI 界面。 java

  • Fragment 是視圖控制器,它們可以將一大塊耦合嚴重的業務邏輯模塊解耦,並使得解耦後的業務邏輯可以被測試。 android

  • Fragment 的 API 可以進行回退棧管理(例如,它能反射某個 Activity 內 Activity 棧的具體操做) git

  • 由於 Fragment 處於視圖層的頂層,而爲 View 設置動畫並不麻煩,使得 Fragment 爲設置頁面切換的過渡效果提供了更好的支持。 程序員

  • Google 建議咱們使用 Fragment,而咱們做爲開發者都想讓本身的代碼符合標準。 github

在 2011年以後,咱們在爲 Square 進行開發的過程當中發現了比使用 Fragment 更好的方法。 架構

關於 Fragment 你不知道的事

The lolcycle

在 Android 中,Context 就像一個 上帝對象 ,由於在 Context 類中涵蓋了太多 Android 系統的信息和相關的操做,使得 Context 在 Android 系統中至關於一個全知全能的上帝,而 Activity 就是爲 Context 添加了生命週期的子類。不過讓上帝具備生命週期仍是有些諷刺的。雖然 Fragment 不是上帝對象,但 Fragment 爲了可以完成 Activity 中能完成的各類操做,使 Fragment 自身的生命週期變得異常複雜。 app

Steve Pomeroy 作了一張 Fragment 的完整生命週期圖 ,我相信任誰看到這張圖都不會好受: 異步

這張圖由 Steve Pomeroy 完成,圖中移除了 Activity 的生命週期,分享這張圖須要得到 CC BY-SA 4.0 許可。

整個 Fragment 的生命週期讓你很頭疼要怎樣使用這些回調方法,它們是同步調用的呢,仍是隻是一次性所有調用呢,仍是其它狀況……?

難於調試

當你的應用出現 Bug,你得用調試工具一步一步地執行代碼才能知道到底發生了什麼,雖然說通常狀況下這樣作 Bug 都能解決,但若是你在調試的時候發現 Bug 和 FragmentManagerImpl 類存在某種聯繫,那麼我可要好好恭喜你即將中大獎了!

由於要跟蹤 FragmentManagerImpl 類內代碼的執行順序,並進行調試是很困難的,這也使得修復應用中相關的 Bug 也變得異常困難:

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 處在未知的狀態。

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 建立,例以下面的代碼看起來沒有什麼問題:

DialogFragment dialogFragment = new DialogFragment() { @Override public Dialog onCreateDialog(Bundle savedInstanceState) { ... }
};
dialogFragment.show(fragmentManager, tag);

然而,當咱們須要存儲 Activity 實例的狀態時,Fragment Manager 可能會經過反射機制從新建立該 Fragment 的實例,又由於這是一個匿名內部類,該類有一個隱藏的構造器的參數正是外部類的引用,若是你們有看過 這篇博文 的話就會知道,擁有外部引用可能會帶來內存泄漏的問題。

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:

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<String>(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 是否處於同一個頁面中。

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 的代碼

首先,咱們會引入一個叫做「容器」的概念,「容器」的做用是幫助咱們展現一項內容並處理後退操做

public interface Container { void showItem(String item); boolean onBackPressed();
}

Acitivity 將假設始終存在容器,而且幾乎不會將業務交給容器處理。

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。

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

<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" /> </com.squareup.view.SinglePaneContainer>

res/layout-land/main_activity.xml

<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" /> <include layout="@layout/detail" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="0.8" /> </com.squareup.view.DualPaneContainer>

下面是這些容器類的簡單實現:

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);
  }
}
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:

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:

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 實現測試單元猶如一縷春風拂面來,甚是舒心爽快吶~

@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 的操做:

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 也能知足實際開發的需求。

相關文章
相關標籤/搜索