前言:本文结合个人在架构设计上的思考和理解,介绍如何从0到1设计一个大型Android项目架构。
本文篇幅较长,可结合下表引导快速了解全文主脉络。
该章节主要对一个Android项目架构从0到1再到N的演进历程做出总结(由于项目的开发受业务、团队和排期等各方面因素影响,因此该总结不会严格匹配每一步的演进历程,但足以说明项目发展阶段的一般性规律)。
1 单项目阶段
对于一个新开启项目而言,每端的开发人员通常非常有限,往往只有1-2个。这时候比项目的架构设计和各种开发细节更重要的是开发周期,快速将idea进行落地是该阶段最重要的目标。现阶段项目的架构往往是这样
此时项目中几乎所有的代码都会写在一个独立的app模块中,在时间为王的背景下,最原始的开发模式往往就是最好最高效的。
2 抽象基础库阶段
随着项目最小化MVP已经开发完成,接下来打算继续完善App。此时大概率会遇到以下几个问题:
基于以上的一种或多种原因,我们往往会把那些相对于整个项目而言,一旦开发完成后就很少再改动的功能进行模块化封装。
我们把原本只包含一个应用层的项目,向下抽取了一个包含网络库、图片加载库和UI库等众多原子能力库的基础层。这样做之后,对于协同开发、整包构建和代码复用都起到了很大的改善作用。
3 拓展核心能力阶段
业务初具规模之后,App已经投入到线上并且有持续稳定的DAU。
在这个时候往往非常关键,随着业务增长、客户使用量增大、迭代需求增多等各方面挑战。如果项目没有一套良性的架构设计,开发的人效会随着团队规模的扩大而反向降低,之前单位时间内1个人能开发5个需求,现在10个人用同样的时间甚至连20个需求都开发不完,单纯的依靠加人是很难彻底解决这个问题的。这时候着重需要做的两件事
该层会涉及到很多核心能力的建设,这里不做过多赘述,下文会对以上各个模块做详细展开。
注:从全局视角来看,基础层和核心层也能作为一个整体,共同支撑上层业务。这里将其分为两层,主要考虑到前者是必选项,是整体架构的必要组成部分;后者是可选项,但同时也是衡量一个App中台能力的核心指标。
4 模块化阶段
随着业务规模继续扩大,App的产品经理(下简称PD)会从一个变为多个,每个PD负责独立的一条业务线,比如App中包含首页、商品和我的等多个模块,则每个PD会对应这里的一个模块。但该调整会带来一个很严重的问题
项目的版本迭代时间是确定的,只有一个PD的时候,每个版本会提一批需求,开发能按时交付就上线,不能交付就把这个迭代适当顺延,这样不会有什么问题;
但如今多个业务线并行,很难在绝对意义上保证各个业务线的需求迭代都能正常交付,就好像你组织一个活动约定了几点集合,但总会有人会遇到一些特殊的情况不能及时赶到。同理,这种难以完全保持一致的情况在项目开发中也会遇到。在当前的项目架构下,业务上虽然拆分了业务线,但我们工程项目的业务模块还是一个整体,内部包含着各种错综复杂的依赖关系网,即使每个业务线按分支区分,也很难规避这个问题。
这时候我们需要在架构层面做项目的模块化,使得多业务线不相互依赖,如图
业务层中,可以按照开发人员或者小组进行更细粒度的划分,以保证业务间的解耦合和开发职责的界定。
5 跨平台开发阶段
业务规模和用户体量继续扩大,为了应对随之而来的是业务需求暴增,整个端侧团队开始考虑研发成本问题。
为什么每个业务需求都至少需要Android和iOS两端都实现一遍?有没有什么方案能够满足一份代码能运行在多个平台?这样岂不是既降低了沟通成本,又提升了研发效率。答案当然是肯定的,此时端侧部分业务开始进入了跨平台开发的阶段。
至此,一个相对完整的端侧系统架构已经初具雏形了。后续业务上会继续有着更多的迭代,但项目的整体结构基本都不会偏离太多,更多的是针对于当前架构中的某些节点做更深层次的改进和完善。
以上是对Android项目架构迭代过程的总结,接下来我会对最终的架构图按照自下而上的层级顺序进行逐一展开,并对每层中涉及到的核心模块和可能遇到的问题进行分析和总结。
1 基础层
基础UI模块
抽取出基础的UI模块,主要有两个目的:
统一App全局基础样式
比如App的主色调、普通正文的文字颜色和大小、页面的内外边距、网络加载失败的默认提示文案、空列表的默认UI等等,尤其是在下文提到项目模块化之后这些基础的UI样式统一会变得非常重要。
复用基础UI组件
在项目和团队规模逐渐发展扩大时,为了提高上层业务的开发效率,秉承DRY的开发原则,我们有必要对一些高频UI组件进行统一封装,以供给业务上层调用;另外一个角度来看,必要的抽象封装还能够降低最终构建的安装包大小,以免一份语义的资源文件在多处出现。
基础UI组件通常包含内部开发和外部引用两部分,内部开发无可厚非,根据业务需求进行开发和封装即可;外部引用要着重强调一下,Github上有大量可复用、经过很多项目验证过的优秀UI组件库,如果是为了快速满足业务开发诉求,这些都将不失为一种很不错的选择。
选择一个合适的UI库,会给整个开发进程带来很大的加速,自己手动去实现也许没问题,但会非常花费时间和精力,如果不是为了研究实现原理或深度定制,建议优先选择成熟的UI库。
网络模块
绝大多数的App应用都需要联网,网络模块几乎成为了所有App必不可少的部分。
框架选择
基础框架的选择往往参考几个大原则:
这里不做具体展开,如果不是基础层对网络层有自己额外的定制,则推荐直接使用Retrofit2作为网络库首选,上层Java Interface风格的Api,面向开发者非常友好;下层依赖功能强大的Okhttp框架也几乎能够满足绝大多数场景的业务诉求。官网的用例参考
用例中对Retorfit声明式接口的优势做了很好的展现,不需要手动实现接口,声明即可使用,其背后的原理是基于Java的动态代理来做的。
统一拦截处理
无论上一步选择的是什么网络库,都需要考虑到该网络库对于统一拦截的能力支持。比如我们想在App的整个运行过程中,打印所有请求的日志,就需要有一个支持配置类似Interceptor这样的全局拦截器。
举一个具体的例子,在现如今服务端很多分布式部署的场景,传统的session方式已经无法满足对客户端状态记录的诉求。有一个比较公认的解决方案是JWT(JSON WEB TOKEN),它需要客户端侧在登录认证之后,把包含用户状态的请求头信息传递给服务端,此时就需要在网络层做类似于下面的统一拦截处理。
Retrofit retrofit = new Retrofit.Builder()
.baseUrl("https://xxx.xxxxxx.xxx")
.client(new OkHttpClient.Builder()
.addInterceptor(new Interceptor() {
@NonNull
@Override
public Response intercept(@NonNull Chain chain) throws IOException {
// 添加统一请求头
Request newRequest = chain.request().newBuilder()
.addHeader("Authorization", "Bearer " + token)
.build();
return chain.proceed(newRequest);
}
})
.build()
)
.build();
此外还有一点需要额外说明,如果应用中有一些跟业务强相关的信息,也建议根据实际业务情况考虑直接通过请求头进行统一传递。比如社区App的社区Id、门店App的门店Id等,这类参数有个普遍性特点,一旦切换过来之后,接下来的很多业务网络请求都会需要该参数信息,而如果每个接口都手动传入将会降低开发效率,也更容易引发一些不必要的人为错误。
图片模块
图片库和网络库不同的是,目前行业里比较流行的几个库差异性并没有那么大,这里建议根据个人喜好和熟悉度自行选择。以下是我从各个图片库官网整理出来的使用示例。
Picasso
Picasso.get().load("http://i.imgur.com/DvpvklR.png").into(imageView);
Fresco
Uri uri = Uri.parse("https://raw.githubusercontent.com/facebook/fresco/main/docs/static/logo.png");
SimpleDraweeView draweeView = (SimpleDraweeView) findViewById(R.id.my_image_view);
draweeView.setImageURI(uri);
Glide
Glide.with(fragment)
.load(myUrl)
.into(imageView);
另外,这里附上各个库在Github上的star,供参考。
图片库的选型比较灵活,但是它的基础原理我们需要弄清楚,以便在图片库出问题时有足够的应对解决策略。
另外需要着重提出来的是,对于图片库最核心的是对图片缓存的设计,有关该部分的延伸可以参考下文的「核心原理总结」章节。
异步模块
在Android开发中异步会使用的非常之多,同时其中也包含很多知识点,因此这里将该部分单独抽出来讲解。
1)Android中的异步定理
总结下来一句话就是,主线程处理UI操作,子线程处理耗时任务操作。如果反其道而行之就会出现以下问题:
2)子线程调用主线程
如果当前在子线程,想要调用主线程的方法,一般有以下几种方式
1.通过主线程Handler的post方法
private static final Handler UI_HANDLER = new Handler(Looper.getMainLooper());
@WorkerThread
private void doTask() throws Throwable {
Thread.sleep(3000);
UI_HANDLER.post(new Runnable() {
@Override
public void run() {
refreshUI();
}
});
}
2.通过主线程Handler的sendMessage方法
private final Handler UI_HANDLER = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(@NonNull Message msg) {
if (msg.what == MSG_REFRESH_UI) {
refreshUI();
}
}
};
@WorkerThread
private void doTask() throws Throwable {
Thread.sleep(3000);
UI_HANDLER.sendEmptyMessage(MSG_REFRESH_UI);
}
3.通过Activity的runOnUiThread方法
public class MainActivity extends Activity {
// ...
@WorkerThread
private void doTask() throws Throwable {
Thread.sleep(3000);
runOnUiThread(new Runnable() {
@Override
public void run() {
refreshUI();
}
});
}
}
4.通过View的post方法
private View view;
@WorkerThread
private void doTask() throws Throwable {
Thread.sleep(3000);
view.post(new Runnable() {
@Override
public void run() {
refreshUI();
}
});
}
3)主线程调用子线程
如果当前在子线程,想要调用主线程的方法,一般也对应几种方式,如下
1.通过新开线程
@UiThread
private void startTask() {
new Thread() {
@Override
public void run() {
doTask();
}
}.start();
}
2.通过ThreadPoolExecutor
private final Executor executor = Executors.newFixedThreadPool(10);
@UiThread
private void startTask() {
executor.execute(new Runnable() {
@Override
public void run() {
doTask();
}
});
}
3.通过AsyncTask
@UiThread
private void startTask() {
new AsyncTask< Void, Void, Void>() {