Android 架构,拒绝生搬硬套!

Bezier | 作者

承香墨影 | 编辑

https://juejin.cn/post/6942464122273398820 | 原文

前言

关于 Android 架构,可能在很多人心里,一直都是虚无缥缈的存在,似懂非懂、为了用而用、处处生搬硬套,这种情况使用的意义真的很有限。

本人有多个项目重构的经验,恰好对设计领域较为感兴趣,今天我将毫无保留的将自己对架构、设计的理解分享给大家。

本文不会具体去讲什么是 MVC、MVP、MVVM,但我描述的点,应该都是这些模式的基石,从本质上讲明白为什么这样做,这样做的好处是什么,有了这些底层思想的支持再去看对应的架构模式,相信会让你有一种焕然一新的感觉。

知识储备:需掌握 Java面向对象六大设计原则,如果不理解也无妨,我尽量将用到的设计原则加以详细描述。

一、模块化的意义何在?

1.1 基本概念以及底层思想

所有的模块化,都是为了满足单一设计原则 (字面意思理解即可),一个函数或者一个类,再或者一个模块,职责越单一,复用性就越强,同时能够间接降低耦合性。

在软件工程的背景下,改动就会有出错的可能,不要说 "我注意一点就不会出错" 这种话,因为人不是机器。

我们能做的,就是尽可能让模块更加单一,职责越单一,影响到外层模块的可能性就越小,这样出错的概率也就越低。

所以模块化核心思想即:单一设计原则

1.2 我们要基于哪些特性去做模块化划分?

做模块化处理的时候,尽量基于两种特性进行功能特性业务特性

1. 功能特性

网络、图片加载等等功能,都可称之为功能特性。比如网络:我们可以将网络框架的集成、封装等等,写到同一个模块(Module、Package 等)当中,这样可以增强可读性(同一目录一目了然)、降低误操作概率,方便于维护,也更加安全。

同时也可将模块托管至远程如 maven 库,可供多个项目使用,进一步提升复用性。

2. 业务特性

业务特性,字面意思理解即可,就是我们常常编写的业务,需要以业务的特性进行模块划分。

为什么说业务特性优先级要高于功能特性?

举个例子如下图:

相信很多人见过,或者正在使用这种分包方式,在业务层把所有的 Adapter、Presenter、Activity 等等,都放在对应的包中,这种方式合理吗?

先说答案不合理

首先这已经是在业务层,我们做的所有事情其实,都在为业务层服务,所以业务的优先级应该是最高的,我们应当优先根据业务特性,将对应的类放入到同一个包中。

功能模块核心是功能,应当以功能进行模块划分。业务模块核心是业务,应当优先以业务进行模块划分,其次再以功能进行模块划分。

墨影说:补充一点,这里说的业务和功能的划分,其实在架构上是所在不同的分层的。业务模块在功能模块的上层,对于业务来说,应该是以业务去划分,对于功能模块则应该以功能去划分。

这里补充一个美团外卖 App 的架构图,大家参考一下能更直观一些。图来源:《美团外卖Android平台化架构演进实践》

1.3 Android 如何做分层处理?

前端开发其实就是做数据搬运,再展示到视图中。数据视图是两个不同的概念,为了提高复用性以及可维护性,我们应当根据单一设计原则,将二者进行分层处理,所以无论是 MVC、MVP 还是 MVVM 最核心的点都是将数据视图进行分层。

绊脚石:

通常来讲,我们通过网络请求拿到数据结构,都是后端定义的,这也就意味着,视图层不得不直接使用后端定义的字段,一旦后端进行业务调整,会迫使我们前端从数据层-->视图层都会进行对应的改动。

如下伪代码所示:

//原始逻辑
数据层
Model{
  title
}
UI层
View{
  textView = model.title
}

//后端调整后
数据层
Model{
  title
  prefix
}
UI层
View{
  textView = model.prefix + model.title
}

起初我们的 textView 显示的是 model 中的 title,但后端调整后,我们需要在 model 中加一个 prefix 字段,同时 textView 显示内容也要做一次字符串拼接。

视图层因为数据层的改动,而被动做了修改。既然做了分层我们想要的肯定是视图、数据互不干扰,如何解决?往下看...

1.4 Data Mapper 或许是解药

Data Mapper 是后端常用的一个概念,一般情况下,他们是不会直接使用数据库里面的字段,而是加一个 Data Mapper(数据映射)将数据库表转按需换成 Java Bean, 这样做的好处也很明显,表结构甭管怎么折腾,都不会影响到业务层代码。

对于前端,我觉得可以适当引入 Data Mapper,将后端数据转换成本地模型(LocalModel),本地模型只与设计图对应,将后端业务与视图完全隔离

这也就解决了 1.3 面临的问题,具体方式如下:

数据层
Model{
  title
  prefix
}
本地模型(与设计图一一对应)
LocalModel{
  //将后端模型转换为本地模型
  title = model.prefix + model.title
}
UI层
View{
  textView = localModel.title
}

LocalModel 相当于一个中间层,通过适配器模式将数据层与视图层做隔离

前端引入 Data Mapper 后,可以脱离后端进行开发,只要需求明确,就可以做视图层的开发,完全不需要担心后端返回什么结构、字段。

并且这种做法是一劳永逸的,比如后端需要对某些字段做调整,我们可以不暇思索直奔数据层,涉及到的调整 100% 不会影响到视图层。

注意点:

当下有一部分公司,为了将前后端分离更彻底,由前端开发人员提供 Java Bean(相当于LocalModel)的结构。

好处很明显,更多的业务内聚到后端,很大程度提升了业务的灵活性,毕竟 App 发一次版成本还是比较大的。面对这种情况,我们其实没必要再编写 Data Mapper

所以任何架构设计都要结合实际情况,适合自己的才是最好的。

1.5 无处安放的业务逻辑

关于业务逻辑其实是一个很笼统的概念,甚至可以将任意一行代码称之为业务逻辑,如此宽泛的概念,我们该如何去理解?

我先大致将它分为两个方面:

  • 界面交互逻辑:视图层的交互逻辑,比如手势控制、吸顶悬浮等等,都是根据业务需要实现的,所以严格来说这部分也属于业务逻辑。但这部分业务逻辑一般在视图层实现。

  • 数据逻辑:这部分是大家常说的业务逻辑,属于强业务逻辑,比如根据不同用户类型,获取不同数据、展示不同界面。加上 Data Mapper 一系列操作,其实就是给后端兜底,帮他们补全剩余逻辑而已。为了方便大家理解下文我将数据逻辑统称为业务逻辑

前面我们说到,Android 应用应该兼具数据层视图层,那业务逻辑放在哪一层比较合适呢?

比如 MVVM 模式下大家都说将业务逻辑放到 ViewModel 处理。这么说也没有太大的问题,但如果一个界面足够复杂,那对应的 ViewModel 代码可能会有成百上千行,看起来会很臃肿,可读性也非常差。最重要的一点这些业务很难编写单元测试用例。

关于业务逻辑,我建议单独写一个 use case 处理。

use case 通常放在 ViewModel/Presenter 与数据层之间,业务逻辑以及 Data Mapper 都应该放在 use case 中,每一个行为对应一个 use case。这样就解决了 ViewModel/Presenter 臃肿的问题,同时更方便编写测试用例。

注意点:

好的设计,都是特定场景解决特定问题。过度设计不仅解决不了任何问题,反而会增加开发成本。

以我目前经验来看 Android 开发至少一半的场景都很简单:请求 → 拿数据 → 渲染视图最多再加个 Data Mapper,流程单一,并且后期改动的可能也不太大,这种情况就没必要写一个 use caseData Mapper扔到数据层即可。

二、合理分层=数据驱动UI做铺垫

先说结论:数据驱动 UI 的本质是控制反转

2.1 什么是 控制反转?

控制即对程序流程的控制。一般由我们开发者承担,此过程为控制。但开发者是人,所以不可避免出现错误,此时可以将角色做一个反转,由成熟的框架负责整个流程,程序员只需要在框架预留的扩展点上,添加跟自己的业务代码,就可以利用框架,来驱动整个程序流程的执行,此过程为反转

控制反转概念,和设计原则中的依赖倒置很相似,只是少了一个依赖抽象。

打个比方:

现有一个 HTTP 请求的需求,如果想自己维护 HTTT 链接、自己管理 TCP Socket、自己处理 HTTP 缓存..... 就是整个 HTTP 协议全部自己封装,先不说这个工程能不能靠个人实现,就算实现也是漏洞百出。

此时可以换个思路:通过 OkHttp 去实现,OkHttp 是一个成熟的框架,用它基本上不会出错。个人封装HTTP协议到使用OkHttp框架,这个过程在控制 HTTP 的角色上发生了一个反转,「个人 → 成熟的框架OkHttp」即控制反转,好处也很明显,框架出错的概率,远低于个人。

2.2 什么是数据驱动 UI?

通俗一点说,就是当数据改变时,对应的 UI 也要跟着变,反过来说,当需要改变 UI 时,只需要改变对应的数据即可。

现在比较流行的 UI 框架如 Flutter、Compose、Vue 其本质,都是基于函数式编程,实现数据驱动 UI,它们共同的目的都是为了解决数据 到 UI 的一致性问题。

在当前的 Android 中,可以使用 DataBinding 实现同样的效果,以 Jetpack MVVM 为例:ViewModel 从 Repository 拿到数据暂存到 ViewModel 对应的 ObservableFiled 即可实现数据驱动 UI。但前提是从 Repository 拿到的数据可以直接用,如果在 Activity 或者 Adapter 做数据二次处理,再 notify UI,已经违背数据驱动 UI 核心思想。

所以想实现数据驱动 UI,必须要有合理的分层(UI层拿到的数据无需处理,可以直接用),Data Mapper 恰好解决这一问题,同时也可规避大量编写 BindAdapter 的现状。

DataBinding 并非函数式编程,它只是通过 AbstractProcessor 生成中间代码,将数据映射到 XML 中

2.3 为什么说数据驱动 UI 底层思想是控制反转?

当前 Android 生态,能实现数据绑定UI的框架只有两个:DataBinding、Compose(暂不讨论)。

在引入 DataBinding 之前渲染一条数据,通常需要两步,如下:

var title = "iOS"
fun setTitle(){
   //第一步更改数据源
   title = "Android"
   //第二个更改UI
   textView = title
}

共需要两步更改数据源、更改 UI,数据源跟 UI 有一个忘记修改便会出现 BUG,千万不要说:“两个我都不会忘记修改”。

当面临复杂的逻辑,以及十几个甚至几十个的数据源,很难保证不出错。这种问题可以通过 DataBinding 解决,只需更改对应的 ObservableFiled UI 便会同步修改,控制 UI 状态也从个人 反转 到的 DataBinding,个人疏忽的事情 DataBinding 可不会。

所以说数据驱动 UI 底层思想是控制反转

2.4 为什么引入 Diff?

引入 diff 之前:

RecyclerView 想要实现动态删除、添加、更新需要分别手动更新数据和 UI,这样在中间插了一道,并且分别更新数据和 UI 已经违背了前面所说的数据驱动 UI,而我们想要的是不管删除、添加或者更新只有一个入口,只要改变数据源就会驱动 UI 做更新,想要满足这一原则只能改变数据源后对 RecyclerView 做全部刷新,但这样会造成性能问题,复杂的界面会感到明显的卡顿。

引入 diff 之后:

Diff 算法,通过对 oldItem 和 newItem 做差异化比对,会自动更新改变的 item,同时支持删除、添加的动画效果,这一特性解决了 RecyclerView 需要实现数据驱动UI的性能问题

三、为什么我建议「函数式编程」

3.1 什么是 函数式编程?

  • 一个入口,一个出口;

  • 不在函数链内部执行与运算本身无关的操作;

  • 不在函数链内部使用外部变量 (实际上这一条很难遵守,可以适当突破);

说的通俗点,就是给定一个初始值,经过函数链的运行,会得到一个目标值,运算的过程中,外部没有插手的权限,同时不做与本身无关的操作,从根本上解决了,不可预期错误的产生。

举个例子:

//Kotlin代码
listOf(10, 20).map {
 it + 1
}.forEach {
 Log.i("list", "$it")
}

上面这种链式编程,就是标准的函数式编程。

输入到输出之间,开发者根本没有插手的机会 (即 Log.i(..) 之前开发者没有权限处理 list),所以整个流程是 100% 安全的。

RxJava、Flow、链式高阶函数都是标准的函数式编程,它们从规范层面解决数据安全问题。所以我建议在 Kotlin 中 碰到数据处理尽量使用链式高阶函数(RxJava、Kotlin Flow亦然)。

其实函数式编程的核心思想就是 门面模式 以及 迪米特法则

3.2 Android 视图开发可以借鉴函数式编程思想

Android 视图开发大都遵循如下流程:请求 → 处理数据 → 渲染 UI,这一流程可以借鉴函数式编程,将请求作为入口,渲染作为出口。

在这个流程中尽量不做与当前行为无关的事 (这也要求 ViewModel,Repository 中的函数要符合单一原则)。

这样说有点笼统,下面举个反例:

View{
  //刷新
  fun refresh(){
    ViewModel.load(true)
  }
  //加载更多
  fun loadMore(){
    ViewModel.load(false)
  }
}

ViewModel{
  //加载数据
  load(isRefresh){
    if (isRefresh){
      //刷新
    }else{
      //加载更多
    }
  }
}

View 层有刷新、加载更多两种行为,load(isRefresh) 一个入口,两个出口。

面临的问题很明显,修改「刷新」或「加载更多」都会对对方产生影响,违反开闭原则中的(对修改关闭:行为没变不准修改源代码),导致存在不可预期的问题产生。

可以借鉴函数式编程思想对其进行改进,将 ViewModel 的 load 函数拆分成 refreshloadMore,这样「刷新」和「加载更多」两种行为、两个入口、两个出口互不干涉,通过函数的衔接,形成两条独立的业务链条。

函数式编程可以约束我们写出规范的代码,面对不能使用函数式编程的场景,我们可以尝试自我约束往函数式编程方向靠拢,大致也能实现相同的效果。

四、综上所述

  • 合理的分层可以提升复用性、降低模块间耦合性;

  • Data Mapper 可以让视图层脱离于后端进行开发;

  • 复杂的业务逻辑应该写到 use case 中;

  • 数据驱动 UI 的本质是控制反转;

  • 通过函数式编程可以写出更加安全的代码;

-- End --

本文对你有帮助吗?留言、转发、点好看是最大的支持,谢谢!

推荐阅读:

三句话,让面试官觉得我懂 SparseArray!

学 Flutter 不理解 Widget/Element/Render 三棵树?啥也不是!

Jetpack Compose实现的天气动画!可爱~

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值