我為什么不主張使用Fragment

圖片發自簡書App

由于最近在整理Fragment相關的資料,所以把這篇文章翻出來,轉到了簡書。
雖然已經是幾年前的文章了,但是有些思想還是值得學習,轉自:gitbook

最近我在Droidcon Paris舉辦了一場技術講座,我講述了Square公司在使用Android fragments時遇到的問題,以及其他人如何避免使用fragments。

在2011年,基于以下原因我們決定在項目中使用fragments:

  • 在那個時候,我們還沒有支持平板設備-但是我們知道最終將會支持的,Fragments有助于構建響應式UI;
  • Fragments是view controllers,它們包含可測試的,解耦的業務邏輯塊;
  • Fragments API提供了返回堆棧管理功能(即把activity堆棧的行為映射到單獨一個activity中);
  • 由于fragments是構建在views之上的,而views很容易實現動畫效果,因此fragments在屏幕切換時具有更好的控制;
  • Google推薦使用fragments,而我們想要我們的代碼標準化;

自從2011年以來,我們為Square找到了更好的選擇。

關于fragments你所不知道的

復雜的生命周期

Android中,Context是一個上帝對象(god object),而Activity是具有附加生命周期的context。具有生命周期的上帝對象?有點諷刺的意味。Fragments不是上帝對象,但它們為了彌補這一點,實現了及其復雜的生命周期。

Steve Pomeroy為Fragments復雜的生命周期制作了一張圖表看起來并不可愛:

圖片發自簡書App

上面Fragments的生命周期使得開發者很難弄清楚在每個回調處要做什么,這些回調是同步的還是異步的?順序如何?

難以調試

當你的app出現bug,你使用調試器并一步一步執行代碼以便了解到底發生了什么,這通常能很好地工作,直到你遇到了FragmentManagerImpl:它是地雷。

下面這段代碼很難跟蹤和調試,這使得很難正確的修復app中的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;
                }
            }
        }
// ...
}

如果你曾經遇到屏幕旋轉時舊的unattached的fragment重新創建,那么你應該知道我在談論什么(不要讓我從嵌套fragments講起)。

正如Coding Horror所說,根據法律要求我需要附上這個動畫的鏈接。

圖片發自簡書App

經過多年深入的分析,我得到的結論是WTFs/min = 2^fragment的個數。

View controllers?沒這么快

由于fragments創建,綁定和配置views,它們包含了大量的視圖相關的代碼。這實際上意味著業務邏輯沒有和視圖代碼解耦-這使得很難針對fragments編寫單元測試。

Fragment事務

Fragment事務使得你可以執行一系列fragment操作,不幸的是,提交事務是異步的,而且是附加在主線程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

Fragments的經驗教訓

盡管存在缺點,fragments教給我們寶貴的教訓,讓我們在編寫app的時候可以重用:

  • 單Activity界面:沒有必要為每個界面使用一個activity。我們可以分割我們的app為解耦的組件然后根據需要進行組合。這使得動畫和生命周期變得簡單。我們可以把組件代碼分割成視圖代碼和控制器代碼。
  • 返回棧不是activity特性的概念;我們可以在一個activity中實現返回棧。
  • 沒有必要使用新的API;我們所需要的一切都是早就存在的:activities,views和layout inflaters。

響應式UI:fragments vs 自定義views

Fragments

讓我們看一個fragment的簡單例子,一個列表和詳情UI。

HeadlinesFragment是一個簡單的列表:

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到底需要處理相同界面上的細節還是不需要呢?

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

自定義views

讓我們只使用views來重新實現上面代碼的相似版本。

首先,我們定義Container的概念,它可以顯示一個item,也可以處理返回鍵。

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

Activity假設總會存在一個container,并把工作委托給它。

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

列表的代碼也類似如下:

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>

下面是這些containers的簡單實現:

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

抽象出這些container并以這種方式來構建app并不難-我們不僅不需要fragments,而且代碼將是易于理解的。

Views & presenters

使用自定義views是很棒的,但我們想把業務邏輯分離到專門的controllers中。我們把這些controller稱為presenters。這樣一來,代碼將更加可讀,測試更加容易。上面例子中的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 Register中抽取的代碼,編輯賬號信息的界面如下:

image.png

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來實現這個功能。Ray Ryan寫了一篇很贊的博文介紹Flow。

我已經深陷在fragment的泥沼中,我如何逃離呢?

把fragments做成空殼,把view相關的代碼寫到自定義view類中,把業務邏輯代碼寫到presenter中,由presenter和自 定義views進行交互。這樣一來,你的fragment幾乎就是空的了,只需要在其中inflate自定義views,并把views和 presenters關聯起來。

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了。

從fragments模式移植過來并不容易,但我們做到了-感謝Dimitris KoutsogiorgasRay Ryan的杰出工作。

Dagger&Mortar如何呢?

Dagger&Mortar和fragments是正交的,它們可以和fragments一起工作,也可以脫離fragments而工作。

Dagger幫助我們把app模塊化成一個解耦的組件圖。他處理所有的綁定,使得可以很容易的提取依賴并編寫自相關對象。

Mortar工作于Dagger之上,它具有兩大優點:

  • 它為被注入組件提供簡單的生命周期回調。這使你可以編寫在屏幕旋轉時不會被銷毀的presenters單例,而且可以保存狀態到bundle中從而在進程死亡中存活下來。
  • 它為你管理Dagger子圖,并幫你把它綁定到activity的生命周期中。這讓你有效的實現范圍的概念:一個views生成的時候,它的presenter和依賴會作為子圖創建;當views銷毀的時候,你可以很容易的銷毀這個范圍,并讓垃圾回收起作用。

結論

我們曾經大量的使用fragments,但最終改變了我們的想法:

  • 我們很多疑難的crashes都和fragment生命周期相關;
  • 我們只需要views來構建響應式的UI,一個返回棧和屏幕轉場功能。
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,565評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,115評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 177,577評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,514評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,234評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,621評論 1 326
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,641評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,822評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,380評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,128評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,319評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,879評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,548評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,970評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,229評論 1 291
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,048評論 3 397
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,285評論 2 376

推薦閱讀更多精彩內容