一文聊聊 Android 项目架构的方方面面

10,558 阅读14分钟

一、前言

笔者也算是工作多年的老Android了,经历过多个从0-1的项目,这些项目中既有工作初期杀毒等工具类应用,也有新闻类应用直到最近几年的协作办公类应用。借着自己微薄的经验总结一下自己认知到的Android项目架构,抛砖引玉。

二、架构模式选择

架构模式

Android 中常见的架构模式有以下三种:

  • MVP
  • MVVM
  • MVI

自18年开始,Google JetPack框架开始推广使用,基于ViewModel、LiveData、DataBinding、Room构建MVVM,使MVVM几乎成为主流。

在这里笔者有一个相对不主流的观点:

针对比较简单的应用无论选择MVP、MVVM还是MVI 个人认为都是对的,差别不大。只需要选择团队成员比较熟悉的,直接使用就可以。

而在业务逻辑比较复杂的应用中,选择哪个实际上也不是很重要。业务复杂通常是本地需要维护数据,处理数据相关逻辑等,Model层逻辑将会非常庞大,其代码量在整个工程中占比将会远远多于UI层。

基础组件

在开始构建一个新的项目工程时,在考虑使用MVP还是MVVM之外,就要开始梳理整个工程中需要使用到哪些能力,这些能力是否已经具有较好的封装的组件。

有的组件可能项目组内部本身有自行封装的比较成熟的那么就直接应用,如果没有可以看看公司内外部是否有较为成熟的。

总结下来一个项目大致需要考虑以下基础组件

  • UI组件
  • 网络
  • 图库
  • 日志
  • 异步线程
  • 路由
  • KV存储
  • 数据库
  • 崩溃收集与治理
  • 埋点上报
  • 全局工具类
  • 上传、下载
  • 图片编辑
  • 播放器
  • 扫码
  • 动画lottie
  • AndroidX相关:RecyclerView等

以上罗列了一部分需要考虑的组件,当然不是每个工程都一定需要以上能力,每个工程都要基于自己的UI与业务需要引入或者定制一些能力。下面我针对上面的组件依次做一个介绍。

三、基础架构拆解

1、UI层

UI层的封装在笔者看来,在项目初期时是需要投入大量精力的。因为每个App都有自己的设计语言,很多UI组件都是需要基于新版做一些定制。

1)、BaseActivity

一般笔者在新工程UI层中,第一步就是封装对应的BaseActivity。

示例代码:

abstract class BaseActivity<T : ViewDataBinding> : AppCompatActivity() {

    lateinit var dataBinding : T

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        dataBinding = DataBindingUtil.setContentView(this, getLayoutId())
        init()
    }

    abstract fun getLayoutId() : Int

    abstract fun init()

    override fun onDestroy() {
        super.onDestroy()
    }

}

在BaseActivity我们可以考虑做几个事情

  • 获取DataBinding对象
  • 动态设置全屏等模式
  • ...

在封装BaseActivity的同时还可以封装BaseFragment,如果可以预见工程中列表页面较多,也可以考虑封装BaseListFragment

2)、UI组件

在封装完成BaseActivity以及BaseFragment之后,就要考虑其他的UI组件了,例如

Toast

一些工程对于Toast有额外定制,不能完全使用系统的样式,所以如果UI上有需求也要考虑提前封装

Dialog

针对Dialog的封装,在笔者看来主要考虑以下两个方面

  • 基于工程统一样式要求,提供基础的能力:Title、SubTitle、Sure、Cancel等
  • 处理好Dialog容易出现的 BadTokenException
PopupWindow

针对PopupWindow的封装主要也是提供基础功能,同时支持特定的定位逻辑,例如支持在某个控件之上/之下展示等。

BaseImageView

可能叫BaseImageView有些不符合,具体可能要基于工程需要封装。例如笔者所负责的工程,就需要支持圆角能力,同时可能会基于此进一步封装支持通用的业务展示能力。

封装RecyclerView相关能力

原生中使用列表组件不如Compose等,定制能力强通常就意味着写起来比较复杂,所以通常针对RecyclerView进一步封装。同时在笔者的工程也会引入MultiTypeAdapter用于支持一对一、一对多Item的能力。

其他
  • 全局统一空页面
  • 全局统一网络加载失败
  • 下拉刷新、上拉加载更多
  • 全局统一TitleBar
  • 等等UI组件

2、网络

关于网络库还是建议选择 OkHttp,同时还会搭配Retrofit使用。确认OkHttp后,接下来就要考虑基于此进行封装。

1)、OkHttpUtils

一般工程中会统一封装一个OkHttp对象,确保OkHttp单例使用。在前面文章中我们有提到过,一个OkHttp对象背后有对应多个线程,通常工程中仅会创建一个OkHttp单例对象尽可能减少线程数量。

一个OkHttpClient对应的线程有:

  • OkHttp Dispatcher
  • OkHttp TaskRunner
  • OkHttp ConnectionPool
  • Okio Watchdog

2)、通用Response字段设计

一般Http接口的Response,我们希望能够所有的业务接口都有一些通用的字段设计,这样业务层在使用时会方便很多。

例如通用的Response Json字段设计可以是:

{
    "status": 1,
    "message": "成功",
    "data": {
        
    }
}
  • status:用于业务状态码 1 为成功 0 为失败
  • message:与status配对使用,当status不为1时,客户端可以将其写入日志中,保存Http接口异常信息,便于排查问题
  • data:真正的业务数据

示例代码

open class BaseBean {
    
    @SerializedName("status")
    val status = 0

    @SerializedName(value = "message")
    val message: String? = null

    fun isSuccess(): Boolean {
        return status == 1
    }
}

class BeanFactory<T> : BaseBean(){

    @SerializedName("data")
    var data: T? = null
}

status错误码,可以基于业务需要来进行划分。例如可以规定1xxx错误码代表A业务,2xxx错误码代表B业务。同时也可以规定一些全局通用的错误码,例如3xx代表登录过期等。

同时以上结构还可以继续扩展:

{
    "status": 1,
    "message": "成功",
    "data": {

    },
    "error": {
        "type": 0
    }
}

当接口调用出错后,可以添加error相关字段,例如type == 0则通过Toast提示用户,type == 1通过弹框提示用户等做进一步全局控制。

3)、通用Header设计

App中Http接口一般需要添加一些通用的请求头,依然选择通过拦截器添加通用请求头。一般请求头中大概率可以包含以下几个字段:

  • AppVersion 应用版本号,例如 1.5.3
  • DeviceType 设备类型 Android/IOS
  • DeviceVersion 设备版本 12、13
  • Accept-Language App当前语言
  • User-Agent 自定义UA

4)、OAuth2

在Http接口中,也会考虑引入OAuth认证,在项目初期就要考虑好。OAuth2 比较重要的有三个字段

  • accessToken 访问令牌
  • refreshToken 更新令牌
  • expiresIn 过期时间

我们可以基于OkHttp拦截器,去检查accessToken是否即将过期或者是否已经过期,然后通过refreshToken去刷新accessToken。

5)、DNS优化

针对DNS优化主要是解决以下几个问题

  • 由于DNS劫持或者故障不可用时,影响用户体验
  • 由于DNS调度导致性能不稳定

DNS优化一般是通过HttpDNS服务替换运营商的LocalDNS服务,这样可以有效的防止域名劫持。

在OkHttp中,我们有两种方式接入HttpDNS

  • 通过拦截器,拦截所有的请求,将域名替换为ip
  • 通过OkHttp提供的dns接口

6)、QUIC使用

QUIC(Quick Udp Internet Connection)基于UDP协议的新一代网络协议。其目的是提高网络传输效率和安全性,降低网络延迟和丢包率。 为了应对弱网可以考虑部分场景引入QUIC协议,在Android中我们可以使用Cronet库来做为QUIC的网络库。

引入QUIC

implementation "org.chromium.net:cronet-embedded:101.4951.41",
implementation "org.chromium.net:cronet-common:101.4951.41"

如果是具有GooglePlayService的手机可以直接:

implementation "com.google.android.gms:play-services-cronet:17.0.1"

Cronet本身也提供了一个Demo:

github.com/GoogleChrom…

同时Cronet团队提供了一个库,使得OkHttp用户可以使用Cronet作为他们的传输层,从中受益于QUIC/HTTP3支持或连接迁移等特性。

implementation "com.google.net.cronet:cronet-okhttp:0.1.0"

3、图片

开源三方常用的图片库:

  • Glide
  • Fresco
  • Picasso
  • Coil

在笔者所在的项目,目前是选择了Glide,大部分同事对于Glide也更加熟悉。

对于图片库,笔者建议是最好可以针对业务进行进一步的封装,例如在我们的项目中,为了节省带宽提高图片加载速度,我们会在图片URL后面拼接相关参数,将图片统一转为Webp格式。同时针对不同的场景还会拼接压缩参数。

压缩以及转Webp 添加的相关参数都是项目中使用的图床服务器本身具有的能力。

4、日志

针对日志我们通常需要考虑的环境隔离以及本地持久化。

  • 环境隔离:Debug包输出详细日志,Release包控制态不要输出任何日志
  • 本地持久化:在线上的Release包中,需要将一部分日志持久化到日志文件中,并在必要的时候上传,用于协助研发同学排查业务逻辑异常

关于日志文件中的日志,需要考虑一下几个方面:

  • 规范Tag。Tag要有一定意义,可以通过Tag过滤出来某个业务流程。例如我需要排查页面与内存的关系,那么我可以过滤页面相关的Tag与内存相关的Tag就可以得到想要的结果。
  • 页面生命周期日志记录
  • 用户操作日志
  • 各个逻辑关键日志
  • 异常错误日志包含错误信息:HTTP接口异常,逻辑异常,Exception等

5、异步线程

基本每个项目中都需要使用线程来访问网络、处理耗时逻辑等。在项目初期就要考虑好整个工程中如何使用异步线程。

Thread & 线程池

如果项目比较简单,初期仅是验证某些猜想、功能等,直接new Thread来使用异步也没有问题。不过商业App再如何简单不建议初期也不要直接new Thread来处理异步逻辑(自己玩的Demo当然随便啦),随着业务增长后期优化起来会特别头疼。

选择使用JDK中的ThreadPoolExecutor 是一个不错的选择。

Kotlin协程

当然也可以直接选择Kotlin协程。

RxJava

RxJava在6-7年前也是顶流了,虽然有一些学习成本。笔者所在的项目中依然是使用RxJava异步任务库,整个App可以说是异步任务都是构建在RxJava之上的。

RxJava中默认提供几类线程:

  • Schedulers.io()
  • Schedulers.computation()
  • Schedulers.single()
  • Schedulers.newThread()
  • Schedulers.trampoline()
  • AndroidSchedulers.mainThread()

io对应的线程池是一个无界的线程池,全局均使用io()方法和每个任务new Thread差别不大,最终会导致线程无限创建。

Schedulers.computation() 对应的是一个有界的线程池,线程数量与Runtime.getRuntime().availableProcessors()相同。

在项目使用中,建议基于RxJava自定义线程池使用,Schedulers类中有提供静态方法from,支持传入Executor对象,然后返回Scheduler对象。

public static Scheduler from(@NonNull Executor executor) {
    return new ExecutorScheduler(executor);
}

项目可以考虑将网络使用线程池与操作数据库等其他耗时逻辑的线程拆分,防止弱网时网络请求任务将线程池打满,阻塞其他任务执行。同时统一使用有界线程池也可以防止线程数量无限扩散。

6、本地存储

本地存储主要涉及两个方面:

  • 便捷的KV存储
  • 数据库

KV存储

SharedPreferences 针对KV存储,Google首先提供了SharedPreferences,使用XML来存储KV字段。原生提供的SharedPreferences接口性能一般,尤其是当数据量较大时,在主线程写入数据时有卡顿甚至ANR的风险。同时其不支持多进程并发写。

Jetpack DataStore 基于SharedPreferences的现状,Google推出了DataStore来替换SharedPreferences。 DataStore一共有两种类型:Preferences DataStore和Proto DataStore,具体使用时可以依据项目来选择。

MMKV MMKV是微信团队基于mmap来开发的,用来替换SharedPreference。性能较SharedPreferences好很多,在选择KV存储库时可以考虑使用MMKV。

数据库

在项目立项后,要基于业务考虑是否需要使用数据库。数据库组件可以考虑使用JetPack Room,也可以考虑使用微信开源的WCDB(有基于Room的版本)。

同时在项目初始就要基于业务考虑好:

  • 整个项目需要几个数据库、如何划分?是基于业务还是基于时间等进行划分。
  • 数据库中有哪些表?这些表是否需要分表,每个表中是否需要索引,索引如何设计等。

数据库前期设计不好,后面改造、迁移就会比较痛苦了。数据方面可以看看以下三篇文章,有更加详细的介绍: Android 数据库系列一:ORM框架的引入与数据库表的设计思考 Android 数据库系列二:全文检索踩坑记录与相关思考 Android 数据库系列三:复杂项目SQL治理与数据库的优化总结

7、Dump治理

Dump治理也是项目初期就要考虑的问题主要是以下三点:

  • 崩溃上报
  • 热修复
  • 崩溃防护

崩溃上报

收集崩溃的能力,很多公司都有自行开发相关的SDK,也有很多开源的三方SDK可以使用。做好崩溃收集是Dump治理的第一步,在项目第一个版本就应该准备好。

热修复

热修复现在不火了,但也是项目中一个很重要的基础能力。热修复本身有多个开源框架了,很多公司内部也有很多自建能力,开源市场上比较有代表的是Tinker、Robust等。研发同学可以基于团队成员熟悉程度,或者业务现状选择使用。

崩溃防护

通过添加崩溃防护,可以有效的降低崩溃率。针对系统、厂商、三方SDK等方向的不可控崩溃异常,在应用层通常无法干预,可以使用崩溃防护组件进行优化。

逻辑图: image 具体可以看一下这篇文章,有详细介绍。Android 复杂项目崩溃率收敛至0.01%实践

8、全局工具类

一般工程中同样需要一些常规的工具类,例如Activity相关、Device相关、图片相关、String相关等。

如果一方本身有相关的封装可以考虑直接引入使用。一方没有相关的封装,也可以考虑引入外部第三方开源的工具类,例如AndroidUtilCode本身的star都特别多。

9、模块化&路由

如果项目初期可以预见后面整体的业务量会非常大,可以考虑在工程中进行模块化划分。通过引入路由组件,为不同的模块之间的通信进行解耦。

10、其他

登录 & 分享

一般App中都需要登录模块,国内需要引入微信、QQ、微博等,海外则是Google、FaceBook等。一般涉及三方授权登录,最好是可以统一封装一套上层的Api,尽可能抹平平台差异。如果存在已经封装好的,可以直接接入使用,尽可能减少接入工作量。

分享也是同样的逻辑。

版本管理

在Android中,由于我们可以直接在App内下载新版本进行更新,所以第一个版本中就要处理好版本升级逻辑。版本管理主要处理两方面逻辑:

  • Apk包体下载(流量、wifi策略)
  • 版本升级逻辑(常规、强制更新)

版本升级逻辑本身也可以进行封装,公司内各个项目集成应用。

四、总结

项目的架构设计涉及到多方面的影响如技术趋势、业务现状,团队成员技术能力等。后续也会随着业务不断地发展迭代,技术的演进增加新的选择。如后续一部分业务会考虑使用跨平台技术,即节省了人力,又可以做到逻辑、风格上完全统一。

如在笔者所在的工程中,基础核心的IM业务依然是由原生负责,我们希望这部分能够给用户最好的体验,同时一些其他业务模块就会选择使用Flutter来构建实现(一些能力通过原生来提供)。同时在工程中UI上面依然选择View体系来迭代需求,我们也基于此构建了大量的UI组件,不太可能立即放弃去拥抱Compose,不过在新的工程中就会考虑选择Compose实现了,至少在体验了Compose编写列表页面,同事们反馈比使用RecyclerView省事很多。

可见任何框架技术的选择都不是一成不变的,也不是一定要逐新,最终要看是否适合这个项目。当然技术上要永不止步,持续学习。