我一直追求從Android活動中分離程式碼。在最近的一個專案中,我成功的實現了傳統的”Form Model”模式,想在此分享我的感想。
“Form Model”的基本思想是,把處理UI互動以及資料繫結和狀態保持的程式碼提取到單獨的類中。這種分離非常自然,並且讓我們的Activity變得簡單。
我認為在Android中這個領域不太被關注——在大多數的開發文件中資料錄入和表單不是重點。在很多流行的社交應用程式中,大多數的畫面只是顯示資訊;可能也有幾個畫面用於發微博或者訊息,但不是應用的痛點。
對我來說,上兩個Android應用有特別多的資料錄入工作。部分原因是因為所處的領域(醫療、金融)和客戶(更貼近於企業應用而不是創業)。但我們經常把“表單輸入”介面搞得一片混亂——特別是當開始新增東西的時候,比如編輯現有的條目,提示丟棄未儲存的更改,以及處理旋轉而不會清除所有欄位值。
使用這種表單模型方案會減少bug,讓程式碼更容易理解,開發者也會變得更快樂。
搜尋表單示例
我們有一個銀行應用程式,希望有一個畫面來搜尋交易資料。有多個過濾條件:開始是一個金額下拉選單,一個關鍵字欄位和一個金額範圍。(希望你可以想象在未來將會增加更多的這類過濾器,複雜性會激增)。
我們沒有把所有的檢視、單擊處理程式,驗證邏輯和資料繫結的程式碼堆到一個Activity中,而是要建立一個 SearchForm類來處理這一切。
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 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
; html-script: false ] public class SearchForm extends LinearLayout { @InjectView(R.id.account) private Spinner mAccountSpinner; private AccountAdapter mAccountAdapter; @InjectView(R.id.keyword) private EditText mKeywordField; @InjectView(R.id.min_amount) private CurrencyEditText mMinAmountField; @InjectView(R.id.max_amount) private CurrencyEditText mMaxAmountField; public SearchFormModel(Context context, AttributeSet attrs) { super(context, attrs); setup(context); } private void setup(Context context) { LayoutInflater.from(context).inflate(R.layout.search_form, this, true); ButterKnife.inject(this); // <3 @JakeWharton mAccountAdapter = new AccountAdapter(context); mAccountSpinner.setAdapter(mAccountAdapter); } public initialize(List<Account> accounts) { mAccountAdapter.setItems(accounts); } public String getKeywords() { return mKeywordField.getText().toString(); } public void setKeywords(String keywords) { mKeywordField.setText(keywords); } public MoneyAmount getMinimumAmount() { return mMinAmountField.getAmount(); } public void setMinimumAmount(double amount) { mMinmountField.setAmountFromDouble(amount); } public MoneyAmount getMaximumAmount() { return mMaxAmountField.getAmount(); } public void setMaximumAmount(double amount) { mMaxAmountField.setAmountFromDouble(amount); } public Account getSelectedAccount() { return mAccountSpinner.getSelectedItem(); } public boolean validate() { clearErrors(); boolean isValid = true; if (!isValidAmountRange()) { isValid = false; mMinAmountField.setError("Invalid range"); mMaxAmountField.setError("Invalid range"); } return isValid; } private boolean isValidAmountRange() { return getMinimumAmount() <= getMaximumAmount(); } private void clearErrors() { mMinAmountField.setError(null); mMaxAmountField.setError(null); } public SearchParameters buildParameters() { return new SearchParameters(getSelectedAccount(), getKeywords(), getMinimumAmount(), getMaximumAmount()); } public void persist(Bundle outState) { outState.putInt("SELECTED_ACCT_INDEX", mAccountSpinner.getSelectedItemPosition()); } public void restore(Bundle bundle) { int accountPosition = bundle.getInt("SELECTED_ACCT_INDEX"); mAccountSpinner.setSelection(accountPosition, false); } } |
我們建立了一個類,繼承自LinearLayout(或者FrameLayout,由你的喜好決定)。它允許把相關的控制元件組織到一個佈局中,我們將填充佈局,設定列表檢視併為金額列表建立一個介面卡。
我們把Android控制元件封裝到getter和setter方法中——這可能會有些爭議,但我認為它使SearchForm擁有更好的公共API。我們有一個方法來驗證使用者的輸入,並根據需要提供錯誤資訊。 buildParameters()方法做了一些資料繫結工作並返回業務物件。結尾的兩個方法使用了Android onSaveInstanceState中的Bundle,以處理自定義配置的更改(注意,大多數的原始UI控制元件會自行處理持久化)。
這是個一百行左右的程式碼,大部分還不錯。這個類中所有內容似乎都屬於“搜尋表單”物件,對未來的特性有良好的功能擴充套件點(日期範圍過濾器、支出與存款過濾器、只用支票等)。我們有意避免處理如何獲取資料,把它留給了其他更適合的地方處理這些邏輯程式碼。
活動中的程式碼是什麼樣的呢?
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 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
; html-script: false ] public class TransactionSearchActivity extends BaseActivity { @InjectView(R.id.search_form) private SearchForm mForm; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.transaction_search); setTitle("Search Your Transactions"); mForm.initialize(mAccounts); // fetch accounts via API/DB/etc if (savedInstanceState != null) { mForm.restore(savedInstanceState); } } @Override public boolean onOptionsItemSelected(MenuItem menu) { switch (menu.getItemId()) { case R.id.action_submit_search: onSubmitSearch(); return true; } return super.onOptionsItemSelected(menu) } private void onSubmitSearch() { if (mForm.validate()) { // Do your magic, post to an API/DB/etc // You have access to the domain object with mForm.buildParameters() } } @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.search_menu, menu); return super.onCreateOptionsMenu(menu); } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); mForm.persist(outState); } } |
我們的Activity在XML佈局檔案中包含了一個 **標籤,並且只處理高層面的使用者互動(點選動作欄中的提交按鈕),並協調獲取和儲存資料。繁重的UI控制和表單邏輯都委託給了 **SearchForm。
Activity的程式碼在50行左右——其中大部分是處理框架中生命週期和選單建立的樣板程式碼。
總體印象
一旦涉及到API或資料庫,事情總是會變得更復雜。但總體來講,通過把表單特定的邏輯和檢視相關內容移出活動,程式碼變得更容易理解。
我可以為 SearchForm編寫大量的Robolectric測試程式碼而且不會帶來與活動生命週期有關的問題。我可以為表單的互動、動作欄、後端編寫測試程式碼而不用考慮邊界。當為表單新增新過濾條件時,可以避免對活動做任何的更改(類似於設計模式中的開/閉原則)。
對比其他框架(從其他開發人員的角度來說),Android中資料繫結功能很弱。這種設計似乎還差點什麼,因為和Android的類耦合的過於緊密,依賴於方法的呼叫順序(initialize()方法應在validate()方法之前呼叫)——儘管如此,但我認為對於“所有內容混在一起的Activity”來說是一種改進。
隨著表單模型越來越複雜,你可能要考慮把驗證邏輯提取到一個單獨的物件中,並且把自定義檢視功能移動到自己的控制元件中(就像我們例子中的 CurrencyEditText)。此外,為了更好的為使用者服務,也可以考慮把複雜的表單拆分成為多步驟嚮導。
我們發現這種模式可以成功的清理亂糟糟的表單程式碼,建議嘗試一下。我把程式碼模式稍微規範了一下,並建立了一個小的基類,以減少樣板程式碼,可以隨意的使用。
我希望聽到您的想法、意見或建議,請在Twitter上和我聯絡。