面向無神論安卓開發:如何和為什麼要幹掉上帝物件
上帝已死… Context 也已經死了。
–Friedrich Nietszche (或許吧)
不像其他領域中的無神論,物件導向程式設計中的無神論無可爭議地是沒毛病的。有些人可能希望學校裡有上帝或者政府裡有上帝,但是其他條件相同的情況下,沒有人真正願意在他們的程式設計過程中存在著上帝。
特別是在安卓開發中,我們都知道有一個讓我們又愛又恨的上帝: Context
。[1] 這篇文章是關於我為什麼要和如何把我的應用中的 Context
消滅的,其原因和方法同樣也適用於 “殺死“ 其他領域的上帝。
為什麼我要幹掉 Context
雖然 Context
是上帝物件,我也知道使用上帝物件有很多不好的地方,但是這並不是我想要移除 Context 的主要原因。事實上,在開始 TDD
之後很自然而然地就要想要去幹掉 Context
了。為什麼呢?因為在我們進行 TDD 的時候,主要是忙著進行著一廂情願的活動:我們為測試的物件寫了很多我們想要的介面。Freeman 和 Pryce 這麼說道:
我們傾向於通過寫一個測試來開始,假設它已經有對應的實現了,然後新增任何需要來讓它生效 – 這就是 Abelson 和 Sussman 所說的 “一廂情願的程式設計” 。[2]
如果我們仔細地考慮下這種方式,它和我們不應該構造我們沒有的虛擬物件的思想很相似,最後,我們既有用該物件的問題域表示的依賴,又有一個適配層。Freeman 和 Pryce 又說過:
如果我們不想模擬外部的 API,那我們怎麼能測試那些驅動他的程式碼呢?我們將使用 TDD 在物件的問題域中給其所需要的服務設計介面,而不是直接用外部的庫。[3]
當在測試中第一次給我的物件寫這個理想介面時,我發現其實沒有一個的類是真正需要 Context
的。我的物件們真正需要的是一個獲取本地字串,或者是持久化儲存鍵值對的方法,而這些我們通常都是間接通過 Context 物件來獲取的。
當我傳入一個與被測試物件的角色關係很清晰的物件,而不是傳一個 Context
時,我就能夠更容易地去理解我的類。
下面是一個例子,假設你需要實現下面的內容:
當使用者使用 app 三次之後展示一個 “評分彈窗”。使用者可以選擇給 app 評分,要求下次提醒再評分,或者拒絕評分。如果使用者選擇了評分,就把他們引導到 Google play store 並且下次不再展示。如果使用者選擇下次提醒評分,三天之後再次顯示彈窗。如果使用者拒絕評分的話,那就再也不展示彈窗。
這個功能可能讓我們有點小緊張,那就先讓恐懼驅動我們寫個測試。
@RunWith(MockitoJUnitRunner.class)
public class AppRaterPresenterTests {
@Mock AskAppRateView askAppRateView;
@Mock AppUsageStore appUsageStore;
@Test public void showsRateDialogIfUsedThreeTimes() throws Exception {
AskAppRatePresenter askAppRatePresenter = new AskAppRatePresenter(appUsageStore);
when(appUsageStore.getNumberOfUsages()).thenReturn(3);
askAppRatePresenter.onAttach(askAppRateView);
verify(askAppRateView).displayAsk();
}
}複製程式碼
在我寫這個測試和給 AskAppRatePresenter
寫理想介面的時候,我不會去考慮應用使用次數是怎麼儲存的。它們應該是通過 SharedPreferences
或者資料庫或者是 realm 或者其他方式來儲存的,因此,我沒有將 AskAppRatePresenter
設計成需要 Context 物件。我關心的只有 AskAppRatePresenter
有一個獲得應用使用次數的方法而已。[4]
這一步確實讓我後面看程式碼更加容易一點。如果看到 Context 已經被注入到物件裡了,我可能真的不知道它是用來做什麼的。它是個上帝物件,能夠用來幹任何事情。但是如果我看到了一個 AppUsageStore 被傳進去了,那我就能進一步知道這個 AskAppRatePresenter 是幹什麼的。[5]
我怎麼樣幹掉 Context
一旦我們寫了測試和失敗用例,我們可以開始實現我們需要傳進去的引數。很明顯,在實現裡面我們需要一個 Context
,但是它是一個 AskAppRatePresenter 不需要知道的細節。這裡有兩個公認的方式去實現,一種是把 Context
傳入 AppUsageStore 的構造方法裡,這樣就能從 SharedPreferences
獲取儲存的資訊。
class SharedPreferencesAppUsageStore implements AppUsageStore {
private final SharedPreferences sharedPreferences;
SharedPreferencesAppUsageStore(Context context) {
sharedPreferences = context.getSharedPreferences("usage", Context.MODE_PRIVATE);
}
@Override public int getNumberOfUsages() {
return sharedPreferences.getInt("numusages", 0);
}
}
}複製程式碼
另外一個方法是讓使用這個 presenter 的 Activity 去繼承 AppUsageStore
的介面,然後傳一個 Activity 的引用到 AskAppRatePresenter
的構造方法中。
public class MainActivity extends Activity implements AppUsageStore, AskAppRateView {
@Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
AskAppRatePresenter askAppRatePresenter = new AskAppRatePresenter(this);
askAppRatePresenter.onAttach(this);
}
@Override public int getNumberOfUsages() {
return getSharedPreferences("usage", Context.MODE_PRIVATE)
.getInt("usage", 0);
}
}複製程式碼
所以,幹掉 Context
– 或者其他類似的上帝物件 – 的通用方法如下所示:
- 創造一個代表你真正想從 Context 中獲取的內容的介面。
- 創造一個繼承這個介面的類;這個類可能已經是一個 Context 了 (比如:Activity )
- 把這個類注入到你的類裡面。
結論
如果你能夠堅持遵循上述的準則,那麼所有你感興趣的程式碼實際上都不會和 Context 有互動。所有與 Context
互動都將在適配層中實現。當你領悟到這一點時,你就能夠專心在你感興趣的程式碼上 ,並不會因為任何與上帝有關介面而影響你去理解你的程式碼。
註釋:
1. Context
是一個上帝物件。我們都知道上帝物件是反設計模式, 也許 Context
看上去就是一個錯誤。但是我不這麼認為,因為第一,在我上一篇文章指出的, Android 剛開始的時候非常看重效能,整潔的抽象在那個時候可能是一種消耗計算機效能的奢侈浪費,並不能被接受。第二點,根據 Diane Hackborne 的想法,app 元件被精確定位為和 Android OS 的進行特定互動作用的。他們不是你的典型物件因為他們是由框架例項化的並且他們是龐大的 Android SDK 的一個入口。這兩個論點證明了 context 設計成一個上帝物件可能不是一個壞的點子。
2. Steve Freeman 和 Nat Pryce, 測試驅動的物件導向軟體開發, 141.
4. 有趣的是,通過 TDD, 我們無意中就走進了遵循介面分離原則的程式碼中去了。
5. 這說明注入物件的複雜度和我們去理解被注入的類的難易程度是成反相關的。換句話說,一個類的依賴越複雜,那麼理解這個類的本身含義就越難。