嘿!經過一段時間收集了大量反饋意見後,我認為應該來說說這個話題了。我會在這裡給出我認為構建現代移動應用(Android)的好方法,這會是另一番體味。
開始之前,假設你已經閱讀過我之前撰寫的文章“ Architecting Android…The clean way?”。如果還沒有閱讀過,為了更好地理解這篇文章,應藉此機會讀一讀:
架構演變
演變意味著一個循序漸進的過程,由某些狀態改變到另一種不同的狀態,且新狀態通常更好或更復雜。
照這麼一說,軟體是隨著時間發展和改變的,是架構上的發展和改變。實際上,好的軟體設計必須能夠幫助我們發展和擴充解決方案,保持其健壯性,而不必每件事都重寫程式碼(雖然在某些情況下重寫的方法更好,但是那是另一篇文章的話題,所以相信我,讓我們聚焦在前面所討論的話題上)。
在這篇文章中,我將講解我認為是必需的和重要的要點,為了保持基本程式碼條理清晰,要記住下面這張圖片,我們開始吧!
響應式方法:RxJava
因為已經有很多這方面的文章,還有這方面做得很好、令人景仰的人,所以我不打算在這裡討論RxJava的好處(我假設您已經對它有所體驗了)。但是,我將指出在Android應用程式開發方面的有趣之處,以及如何幫助我形成第一個清晰的架構的方法。
首先,我選擇了一種響應式的模式通過轉換usecase(在這個清晰的架構命名規則中,其被稱為interactor)返回Observables<T>,表示所有底層都遵循這一鏈條,也返回Observables<T> 。
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 abstract class UseCase { private final ThreadExecutor threadExecutor; private final PostExecutionThread postExecutionThread; private Subscription subscription = Subscriptions.empty(); protected UseCase(ThreadExecutor threadExecutor, PostExecutionThread postExecutionThread) { this.threadExecutor = threadExecutor; this.postExecutionThread = postExecutionThread; } protected abstract Observable buildUseCaseObservable(); public void execute(Subscriber UseCaseSubscriber) { this.subscription = this.buildUseCaseObservable() .subscribeOn(Schedulers.from(threadExecutor)) .observeOn(postExecutionThread.getScheduler()) .subscribe(UseCaseSubscriber); } public void unsubscribe() { if (!subscription.isUnsubscribed()) { subscription.unsubscribe(); } } } |
正如你所看到的,所有用例繼承這個抽象類,並實現抽象方法buildUseCaseObservable()。該方法將建立一個Observables<T>,它承擔了繁重的工作,還要返回所需的資料。
需要強調是,在execute()方法中,要確保Observables<T> 是在獨立執行緒執行,因此,要儘可能減輕阻止android主執行緒的程度。其結果就是會通過android主執行緒排程程式將主執行緒壓入執行緒佇列的尾部(push back)。
到目前為止,我們的Observables<T>啟動並且執行了。但是,正如你所知,必須要觀察它所發出的資料序列。要做到這一點,我改進了presenters(MVP模式表現層的一部分),把它變成了觀察者(Subscribers),它通過用例對發出的專案做出“react”,以便更新使用者介面。
觀察者是這樣的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
private final class UserListSubscriber extends DefaultSubscriber<List<User>> { @Override public void onCompleted() { UserListPresenter.this.hideViewLoading(); } @Override public void onError(Throwable e) { UserListPresenter.this.hideViewLoading(); UserListPresenter.this.showErrorMessage(new DefaultErrorBundle((Exception) e)); UserListPresenter.this.showViewRetry(); } @Override public void onNext(List<User> users) { UserListPresenter.this.showUsersCollectionInView(users); } } |
每個觀察者都是每個presenter的內部類,並實現了一個Defaultsubscriber<T>介面,建立了基本的預設錯誤處理。
將所有的片段放在一起後,通過下面的圖,你可以獲得完整的概念:
讓我們列舉一些擺脫基於RxJava方法的好處:
在觀察者(Subscribers)與被觀察者(Observables)之間去耦合:更加易於維護和測試。
- 簡化非同步任務:如果要求多個非同步執行時,如果需要一個以上非同步執行的級別,Java的thread和future的操作和同步比較複雜,因此通過使用排程程式,我們可以很方便地(不需要額外工作)在後臺與主執行緒之間跳轉,特別是當我們需要更新UI時。還可以避免“回撥的坑”—— 它使我們程式碼可讀性差,且難以跟進。
- 資料轉換/組成:在不影響客戶端情況下,我們能夠整合多個Observables,使解決方案更靈活。
- 錯誤處理:在任何Observables內發生錯誤時,就向消費者發出訊號。
從我的角度看有一點不足,甚至要為此需要付出代價,那些還不熟悉概念的開發人員還是要遵循學習曲線。但你從中得到了極有價值的東西。為了成功而reactive起來吧!
依賴注入:Dagger 2
關於依賴注入,因為我已經寫了一篇完整的文章,我不想說太多。強烈建議你閱讀它,這樣我們就可以接著說下面的內容了。
值得一提的是,通過實現一個像Dagger 2那樣的依賴注入框架我們能夠獲得:
- 元件重用,因為依賴的物件可以在外部注入和配置。
- 當注入物件作為協作者(collaborators)時,由於物件的例項存在於在一個隔離和解耦地方,這樣在我們的程式碼庫中,就不需要做很多的改變,就可以改變任何物件的實現。
- 依賴可以注入到一個元件:這些將這些模擬實現的依賴物件注入成為可能,這使得測試更容易。
Lambda表示式:Retrolambda
沒有人會抱怨在程式碼中使用Java 8的lambada表示式,甚至在簡化並擺脫了很多樣板程式碼以後,使用得更多,如你看到這段程式碼:
1 2 3 4 5 6 |
private final Action1<UserEntity> saveToCacheAction = userEntity -> { if (userEntity != null) { CloudUserDataStore.this.userCache.put(userEntity); } }; |
然而,我百感交集,為什麼呢?我們曾在@SoundCloud討論Retrolambada,主要是是否使用它,結果是:
1. 贊成的理由:
- Lambda表示式和方法引用
- “try-with-resources”語句
- 使用karma做開發
2. 反對的理由:
- Java 8 API的意外使用
- 十分令人反感的第三方庫
- 要與Android一起使用的第三方外掛Gradle
最後,我們認定它不能為我們解決任何問題:你的程式碼看起來很好且具有可讀性,但這不是我們與之共存的東西,由於現在所有功能最強大的IDE都包含程式碼摺疊式選項,這就涵蓋這一需求了,至少是一個可接受的方式。
說實話,儘管我可能會在業餘時間的專案中使用它,但在這裡使用它的主要原因是嘗試和體驗Android中Lambda表示式。是否使用它由你自己決定。在這裡我只是展示我的視野。當然,對於這樣一項了不起的工作,這個庫的作者值得稱讚。
測試方法
在測試方面,與示例的第一個版本相關的部分變化不大:
- 表現層:用Espresso 2和Android Instrumentation測試框架測試UI。
- 領域層:JUnit + Mockito —— 它是Java的標準模組。
- 資料層:將測試組合換成了Robolectric 3 + JUnit + Mockito。這一層的測試曾經存在於單獨的Android模組。由於當時(當前示例程式的第一個版本)沒有內建單元測試的支援,也沒有建立像robolectric那樣的框架,該框架比較複雜,需要一群黑客的幫忙才能讓其正常工作。
幸運的是,這都是過去的一部分,而現在所有都是即刻可用,這樣我可以把它們重新放到資料模組內,專門為其預設的測試路徑:src/test/java。
包的組織
我認為一個好的架構關鍵因素之一是程式碼/包的組織:程式設計師瀏覽原始碼遇到的第一件事情就是包結構。一切從它流出,一切依賴於它。
我們能夠辨別出將應用程式封裝進入包(package)的2個路徑:
- 按層分包:每一個包(package)中包含的項通常不是彼此密切相關的。這樣包的內聚性低、模組化程度低,包之間偶合度高。因此,編輯某個特性要編輯來自不同包的檔案。另外,單次操作幾乎不可能刪除掉某個功能特性。
- 按特性分包:用包來體現特性集。把所有相關某一特性(且僅特性相關)的項放入一個包中。這樣包的內聚性高,模組化程度高,包之間偶合度低。緊密相關的項放在一起。它們沒有分散到整個應用程式中。
我的建議是去掉按特性分包,會帶來的好處有以下主要幾點:
- 模組化程度更高
- 程式碼導航更容易
- 功能特性的作用域範圍最小化了
如果與功能特性團隊一起工作(就像我們在@SoundCloud的所作所為),也會是非常有趣的事情。程式碼的所有權會更容易組織,也更容易被模組化。在許多開發人員共用一個程式碼庫的成長型組織當中,這是一種成功。
如你所見,我的方法看起來就像按層分包:這裡我可能會犯錯(例如,在“users”下組織一切),但在這種情況下我會原諒自己,因為這是個以學習為目的的例子,而且我想顯示的是清晰架構方法的主要概念。領會其意,切勿盲目模仿:-)。
還需要做的事:組織構建邏輯
我們都知道,房子是從地基上建立起來的。軟體開發也是這樣,我想說的是,從我的角度來看,構建系統(及其組織)是軟體架構的重要部分。
在Android平臺上,我們採用Gradle,它事實上是一種與平臺無關的構建系統,功能非常強大。這裡的想法是通過一些提示和技巧,讓你組織構建應用程式時能夠得到簡化。
- 在單獨的gradle構建檔案中按功能對內容進行分組
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def ciServer = 'TRAVIS' def executingOnCI = "true".equals(System.getenv(ciServer)) // Since for CI we always do full clean builds, we don't want to pre-dex // See http://tools.android.com/tech-docs/new-build-system/tips subprojects { project.plugins.whenPluginAdded { plugin -> if ('com.android.build.gradle.AppPlugin'.equals(plugin.class.name) || 'com.android.build.gradle.LibraryPlugin'.equals(plugin.class.name)) { project.android.dexOptions.preDexLibraries = !executingOnCI } } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
apply from: 'buildsystem/ci.gradle' apply from: 'buildsystem/dependencies.gradle' buildscript { repositories { jcenter() } dependencies { classpath 'com.android.tools.build:gradle:1.2.3' classpath 'com.neenbedankt.gradle.plugins:android-apt:1.4' } } allprojects { ext { ... } } ... |
因此,你可以用“apply from: ‘buildsystem/ci.gradle’”插入到任何Gradle建立的檔案中進行配置。不要把所有都放置在一個build.gradle檔案中,否則就是去建立一個怪物,這是教訓。
- 建立依賴關係圖
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 |
... ext { //Libraries daggerVersion = '2.0' butterKnifeVersion = '7.0.1' recyclerViewVersion = '21.0.3' rxJavaVersion = '1.0.12' //Testing robolectricVersion = '3.0' jUnitVersion = '4.12' assertJVersion = '1.7.1' mockitoVersion = '1.9.5' dexmakerVersion = '1.0' espressoVersion = '2.0' testingSupportLibVersion = '0.1' ... domainDependencies = [ daggerCompiler: "com.google.dagger:dagger-compiler:${daggerVersion}", dagger: "com.google.dagger:dagger:${daggerVersion}", javaxAnnotation: "org.glassfish:javax.annotation:${javaxAnnotationVersion}", rxJava: "io.reactivex:rxjava:${rxJavaVersion}", ] domainTestDependencies = [ junit: "junit:junit:${jUnitVersion}", mockito: "org.mockito:mockito-core:${mockitoVersion}", ] ... dataTestDependencies = [ junit: "junit:junit:${jUnitVersion}", assertj: "org.assertj:assertj-core:${assertJVersion}", mockito: "org.mockito:mockito-core:${mockitoVersion}", robolectric: "org.robolectric:robolectric:${robolectricVersion}", ] } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
apply plugin: 'java' sourceCompatibility = 1.7 targetCompatibility = 1.7 ... dependencies { def domainDependencies = rootProject.ext.domainDependencies def domainTestDependencies = rootProject.ext.domainTestDependencies provided domainDependencies.daggerCompiler provided domainDependencies.javaxAnnotation compile domainDependencies.dagger compile domainDependencies.rxJava testCompile domainTestDependencies.junit testCompile domainTestDependencies.mockito } |
如果想在專案的不同模組間重用相同的元件版本,這很好;否則就要在不同的模組間使用不同的版本的元件依賴。另外一點,你是在同一個地方控制依賴關係,像元件版本發生衝突這樣的事情一眼就能看出來。
結語
到目前為止講了那麼多,一句話,要記住沒有靈丹妙藥。但好的軟體架構會幫助程式碼保持清晰和健壯,還可以保持程式碼的可擴充套件性,易於維護。
我想指出一些事情。面對軟體存在的問題,要報以本應當解決的態度:
- 遵守SOLID原則
- 不要過度思考(不過度工程化)
- 務實
- 儘可能降低(Android)框架中模組的依賴性
原始碼
Clean architecture github repository – master branch
Clean architecture github repository – releases
延伸閱讀
Architecting Android..the clean way
Tasting Dagger 2 on Android
The Mayans Lost Guide to RxJava on Android
It is about philosophy: Culture of a good programmer
參考資料
RxJava wiki by Netflix
Framework bound by Uncle Bob
Gradle user guide
Package by feature, not layer