费尔南多·塞哈斯
欢迎! 我是费尔南多·塞哈斯, @IBM, @SoundCloud 和 @Tuenti Alumni 公司的开发的开发者倡导者。我是一个极客/书呆子。一般来说对移动开发,人工智能,量子计算和软件工程有巨大的兴趣。在这里,我分享我的经验并揭露我的想法:所有的观点,帖子和意见都是我自己的。
Android 架构
已经有很长时间了,我决定再次来写关于Android 应用的架构。原因?主要来自社区的反馈和经验教训。但是,尽管从干净的架构在移动开发中开始流行的早期就已经就说了很多,但总有改进和发展的空间。
为了开始并让事情变得更容易,我会假设你已经阅读了这些旧的但仍然有效的博客文章:
基于上面文章的干净架构的的例子,代码库中有一个明显的变化,特别是因为现在应用程序业务级别是关键,比以往任何时候都更需要围绕移动开发进行扩展,模块化和组织团队(主要是由于其复杂性)。
因此,我们的想法是提出一个(优雅?)解决方案,这将使我们的生活更容易:
- 解决业务复杂度问题
- 可扩展性.
- 模块化
- 可测性.
- 框架,UI和数据库的独立性
这是一幅大图,如果您在Android应用程序中使用了Clean Architecture(干净的架构),这应该看起来很熟悉。
Clean一般是指,代码以洋葱的形状依据一定的依赖规则被划分为多层:内层对于外层一无所知。这就意味着依赖只能由外向内。
一般而言,你的应用可以有任意数量的层。但是,除非需要在所有的Android应用中采用企业级的业务逻辑,一般的应用至多只有三层:
- 外层:实现层
- 中层:接口适配层
- 内层:业务逻辑层
实现层包含了所有框架相关的东西。框架相关的代码包含了所有不是专门用来解决目标问题的部分,例如创建activity和fragment、发送目标以及网络和数据库相关的框架代码。
接口适配层的目的就是负责连接业务逻辑和框架相关的代码。
应用中最重要的就是业务逻辑层。它负责解决你的应用所真正想解决的问题。该层不包含任何框架相关的代码,因此其代码应该可以在没有模拟器的情况下独立运行。这样,测试、开发和维护业务逻辑代码就要容易很多。而这就是干净架构的主要优势。
在核心层以上的每一层,也都负责在更低层使用模型之前将它们转换为更低层的模型。内层不能使用任何属于外部的模型类的引用;但是外层可以使用和引用内层的模型——这都是因为依赖规则。这种方式会造成一定的开销,但确保了层与层之间代码的解耦合。
我们的场景
一个简单的电影Android应用程序(如有雷同仅仅是巧合)。
用Kotlin写的:除了我们想要利用现代语言的功能,如不可变性,简洁性,函数式编程等等,这些不用多说。
通过以下流程:
这个应用中我们有3个主要的用例:
- 获取一个电影列表
- 显示点击某一个电影后的详情页面
- 播放一个电影
和往常一样,源码地址
一般的架构
一般原则是使用基本的3层架构. 关于它的好处是,它很容易理解,很多人都很熟悉它。因此,我们将解决方案分解为多个层次,以尊重依赖规则(依赖关系在一个方向的流程上:参考上面的圆形清洁体系结构图)。
如果我们记住以前的帖子,那么这里没什么新鲜事。让我们深入探讨并逐步进行,以便更好地理解。
域层:功能用例
用例是一种意图,换句话说,是我们想要在我们的应用程序中做的事情,我们的主要参与者之一。 它的主要职责是协调我们的域逻辑及其与UI和数据层的连接。
通过使用Kotlin的特性,以及它将类方法作为一等公民来处理(稍后来讲),我们在我们的框架中有一个UseCase抽象类,它将作为我们应用程序中所有用例的契约(接口协议)。
1 2 3 4 5 6 7 8 9 |
abstract class UseCase<out Type, in Params> where Type : Any { abstract suspend fun run(params: Params): Either<Failure, Type> fun execute(onResult: (Either<Failure, Type>) -> Unit, params: Params) { val job = async(CommonPool) { run(params) } launch(UI) { onResult.invoke(job.await()) } } } |
这里的代码发生了什么?
我们有一个包含两个参数的抽象类:
<out Type>
: 用例结果的返回类型。<in Params>
: 传入到run()方法里面的参数,我们的用例中需要一些额外的参数。
execute()方法就是所有神奇发生的地方:
- 我们将带有Either<Failure, Type>类型,且会返回Unit对象的“onResult”方法作为参数传入(在错误处理的章节我将展开对<L,R>的解释,所以请耐心等待:))。好消息是,用例的调用者实际上是通过传递这个不可变函数(onResult)来建立所需的行为,因此,当我们传递对象时,避免了任何内部暴露或副作用(FP的好处之一,下面会讲)。
- 同样的,通过使用Kotlin的协同我们可以在另一个线程中调用传入的“onResult”方法,所以从这一点开始,我们可以安全地以同步方式编写代码。处理结果将会放在Android UI线程中处理。
abstract suspend fun run(params: Params)
就是在继承UserCase<out Type, in Params>抽象类时我们必须复写的方法,例如,这就是我们的GetMovies用例的样子:
1 2 3 4 5 6 |
class GetMovies @Inject constructor(private val moviesRepository: MoviesRepository) : UseCase<List<Movie>, None>() { override suspend fun run(params: None) = moviesRepository.movies() } |
在这个例子中,我们将电影检索委托给Repository。 是不是很简单?
UI 层:从MVP到MVVM
Model-View-ViewModel 模式(MVVM)提供了用户界面和域逻辑之间分离的一种思想。
它包含3个主要的元素:model,view,和 view model。它们之间彼此关联,尽管每个关联都有不同的独立作用:
在最高层看,view”知道”view model, 并且view model “知道” model,但是 model 不知道 view model,并且 view model 也不知道 view. view model 把 vie he model 分隔开,且允许model层的改变可以独立于 view 层。
在我们示例中的实现层,MVVM 的实现使用了 Architecture Components , 其主要优点是在屏幕旋转时处理配置更改,这是会给作为Android开发人员的我们带来许多麻烦的东西(我猜你知道我在说什么)
注意:这并不意味着我们不再关心生命周期,但它可以使其变得更简单。
从前面的例子评论MVP(Model View Presenter): 我找到一个取巧的方法来避免activity和fragment在重新创建后可能的泄露问题,使用了一个很挫的方法:保留fragments。
但是,无论如何,我遇到了这种情况。 这就是我决定尝试MVVM的原因。
让我们看看之前的例子使用了MVVM之后发生了什么改变和它是如何工作的:
- Fragments 扮演 views, 所有在屏幕上展示数据的逻辑写在这里。
- Fragment同样知道ViewModels, 它们实际上订阅了ViewModels.
- ViewModels 包含了实时的数据,用例的对象和引用
- 用例会更新实时数据,用例会响应这些改变并发送通知给ViewModels
- ViewModels 通知回已经订阅消息的Fragmetns, 目的是更新UI
为了看到所有这些内容如何整合起来的,我们看一些代码:
ViewModel包含实时数据并且通过调用UseCase.execute()方法来更新。
1 2 3 4 5 6 7 8 9 10 11 12 |
class MoviesViewModel @Inject constructor(private val getMovies: GetMovies) : BaseViewModel() { var movies: MutableLiveData<List<MovieView>> = MutableLiveData() fun loadMovies() = getMovies.execute({ it.either(::handleFailure, ::handleMovieList) }, None()) private fun handleMovieList(movies: List<Movie>) { this.movies.value = movies.map { MovieView(it.id, it.poster) } } } |
Fragment 在onCreate()方法里面订阅了上面的ViewModel。
我使用了一些扩展函数的技巧来摆脱一些多余重复的工作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class MoviesFragment : BaseFragment() { @Inject lateinit var navigator: Navigator @Inject lateinit var moviesAdapter: MoviesAdapter private lateinit var moviesViewModel: MoviesViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) appComponent.inject(this) //subscribtion to LiveData in MoviesViewModel moviesViewModel = viewModel(viewModelFactory) { observe(movies, ::renderMoviesList) failure(failure, ::handleFailure) } } ... } |
数据层:Repository模式
请记住:Repository模式的核心是一个简单的接口。 它作为我们的域逻辑和数据之间的层存在,因此我们的逻辑不需要关心不同数据源的实现:网络,数据库或内存。
下面的代码我们可以看到MoviesRepository的契约协议:
1 2 3 4 |
interface MoviesRepository { fun movies(): Either<Failure, List<Movie>> fun movieDetails(movieId: Int): Either<Failure, MovieDetails> } |
在我们的例子中,我们经常会将一个Repository对象作为一个合作者注入到我们的UseCases的实现中。
功能异常处理
整体来说 错误和异常处理 应该在设计阶段考虑,而不应该在代码实现阶段考虑,这个我认为是作为程序员犯的一个最大的错误。
传统错误处理将会发生什么?
通过异常捕获(try/catch块)来决定和改变控制流程的方向将会是一个非常差的实践:这会我们代码的弹性带来不可预测的影响,并且让调试变得困难,尤其在多线程同步的环境。加上C风格的错误处理,使用需要按惯例检查的错误代码可能是一场噩梦。
如在文章开头所说,我们看到我们的UseCase抽象类使用了Either<L, R> 作为返回类型:
1 |
abstract suspend fun run(params: Params): Either<Failure, Type> |
所以让我来介绍Either
Either<L, R>作为一个不相交的函数,意思是这个结构体的设计是它会持有Left<T>或者Right<T>其中之一的值,但是不会两个都有,它是一个函数式编程monad类型,在Kotlin 标准库里面是不存在的。
这里有一个简单的实现,它满足了我的需求,并且它非常简单理解和使用(来自于Alex Hart的灵感):
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 |
/** * Represents a value of one of two possible types (a disjoint union). * Instances of [Either] are either an instance of [Left] or [Right]. * FP Convention dictates that: * [Left] is used for "failure". * [Right] is used for "success". * * @see Left * @see Right */ sealed class Either<out L, out R> { /** * Represents the left side of [Either] class which by convention is a "Failure". */ data class Left<out L>(val a: L) : Either<L, Nothing>() /** * Represents the right side of [Either] class which by convention is a "Success". */ data class Right<out R>(val b: R) : Either<Nothing, R>() val isRight get() = this is Right<R> val isLeft get() = this is Left<L> fun either(fnL: (L) -> Any, fnR: (R) -> Any): Any = when (this) { is Either.Left -> fnL(a) is Either.Right -> fnR(b) } fun <T> flatMap(fn: (R) -> Either<L, T>): Either<L, T> {...} fun <T> map(fn: (R) -> (T)): Either<L, T> {...} } |
让我引用Danial Westheide (斯卡拉大师和好人,我在SoundCloud中遇到他)在他非常棒的文章:The Either Type.
我们代码的例子是什么样的呢?
在我们GetMovies的用例中,在实现层,我们通常会从数据层开始返回一个Either<Failure, List<Movie>> 对象,直到我们MoviesViewModel,这里将会更新要么失败的LiveData<Failure>(在失败的情况下,Left<T>) ,要么返回电影LiveData<List<MovieView>>(成功,Right<T>):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class MoviesViewModel @Inject constructor(private val getMovies: GetMovies) { var movies: MutableLiveData<List<MovieView>> = MutableLiveData() var failure: MutableLiveData<Failure> = MutableLiveData() fun loadMovies() = getMovies.execute({ it.either(::handleFailure, ::handleMovieList) }, None()) private fun handleMovieList(movies: List<Movie>) { this.movies.value = movies.map { MovieView(it.id, it.poster) } } private fun handleFailure(failure: Failure) { this.failure.value = failure } } |
在视图层(我们的MoviesFragment),我们订阅了消息来更新来自于view model的消息:
1 2 3 4 |
moviesViewModel = viewModel(viewModelFactory) { observe(movies, ::renderMoviesList) failure(failure, ::handleFailure) } |
并且这个是handleFailure()方法是如何处理失败的结果的:
1 2 3 4 5 6 7 |
private fun handleFailure(failure: Failure?) { when (failure) { is NetworkConnection -> renderFailure(R.string.failure_network_connection) is ServerError -> renderFailure(R.string.failure_server_error) is ListNotAvailable -> renderFailure(R.string.failure_movies_list_unavailable) } } |
顺便说一下:Failure是一个封装的类提供了全局默认的失败处理:
1 2 3 4 5 6 7 8 9 10 11 |
/** * Base Class for handling errors/failures/exceptions. * Every feature specific failure should extend [FeatureFailure] class. */ sealed class Failure { class NetworkConnection: Failure() class ServerError: Failure() /** * Extend this class for feature specific failures.*/ abstract class FeatureFailure: Failure() } |
我想现在Either<L, R> 的使用更清晰了,并且你知道了这个技术的原因和好处了。
模块化的第一步
首先,我要澄清一下,这篇文章不是关于某个特定的主题(这会是巨大的),但是我想记录下一些在过去经验中的事情,为了开始。 从我的角度来看,迟早,这是要走的路,一个好的架构应该有助于实现这个目标。
什么是模块化?
模块化是拆分和构建一个逻辑元素代码之间清晰的边界的处理。
讨论中反复出现的问题是:为什么? 答案很简单……错误的技术决策:我的意思是不同的模块,以便通过建立边界来更严格地遵守依赖规则,从而使打破它变得更加困难。
但是能力越大责任越大,虽然这在开始时效果很好,但示例代码仍然是MONOLITH,它会在扩展时带来问题:
- 当修改和添加一个新的功能:我们不得不改动所有的模块/分层(模块之间强依赖/耦合)
- 开发人员在代码仓库上工作是会冲突(团队越大,越多的冲突,尤其是git上面提交和合并代码)
拥抱应用的模块化
我的第一个技巧来拆分模块是通过功能来整理包结构,我们这样实现的:
- 更高的模块化
- 高内聚
- 更轻松的找到代码
- 最小化范围
- 隔离和封装
代码/包结构 的整理 是一个好架构的非常关键的因素:包结构是一个程序员浏览源码时第一眼看到的东西。所有流程都从这里面出来,所有的都依赖它。
我的第二个技巧是拥有一个具有以下主要职责的核心模块::
- 处理全局的依赖注入
- 包含扩展功能
- 包含主框架的抽象
- 在主要应用程序中启动常见的第三方库,如Analytics,Crash Reporting等。
我的第三个技巧不在代码库级别,但是如果我们正在与业务团队合作,那么添加代码所有权可能会有所帮助,这对于许多开发人员正在使用相同代码库的成长型组织来说非常有帮助。
这些是模块化的主要好处:
- 编译时间更快
- 以包名内聚
- 通用功能复用
- 冲突减少(尤其是在git上工作)
- 功能封装
- 更受控制的依赖关系。
- 团队合作:团队之间的协作。
我知道这一切听起来都不错,虽然模块化你的android代码库是一件棘手和具有挑战性的事情(由于所涉及的所有变化的部分),但优势是巨大的。
其它的实现详情
- RxJava: 这个对比之前的例子是一个最大的改动,我摆脱了RxJava是因为在这里我不需要它。顺便说一下,我遇到很多人使用它是因为要处理线程问题。确实RxJava在这方面对我们很有利,但是同时也增加了其它领域的开销和复杂性。所以确保不仅仅是因为线程的原因而把它引入到你的代码仓库
- 依赖注入:简单的使用了Dagger 2, 这个超出了这篇文章的范围
- 单元和集成测试:这里
- 验收测试
源码和讨论
源码地址:
讨论, open a new issue on Github, 所以我们可以继续谈话 over there.
结论
我们已经看到了理论方法和实现细节。还有更多值得探索的东西,但那是你的功课 :),此外,只要满足您的需求并解决您的问题,您为项目选择的架构并不重要。
请记住:没有银弹
当然,总有改进的余地,尽管这应该是一个有用的起点。
还有一些建议:
- 不要过度思考(不要过度工程)。
- 务实。
- 尽可能减少项目中的框架(android)依赖项。
- 通过重构持续改进
- 不要在没有测试的情况下编写代码(我不应该在2018年这样说:))。
自从我看到围绕FP与OOP的讨论后,还有其他的东西需要补充:功能编程并不是新的,并且已经存在了很长时间但现在越来越多的采用者。 而Kotlin将方法视为一等公民的事实为我们提供了更多的能力和工具来解决我们的问题。
使用适合你的任何东西,在这里我将我认为最好的两个世界联系在一起……
IBM Android app 架构实践经验分享-作者*费尔南多·塞哈斯(翻译)-花花鞋
aekqsetepdi
ekqsetepdi http://www.gmnhv4714ft98q553u91r36z4ne0t1cjs.org/
[url=http://www.gmnhv4714ft98q553u91r36z4ne0t1cjs.org/]uekqsetepdi[/url]