Android项目架构设计深入浅出
Posted 阿里云云栖号
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android项目架构设计深入浅出相关的知识,希望对你有一定的参考价值。
简介:本文结合个人在架构设计上的思考和理解,介绍如何从0到1设计一个大型android项目架构。
作者 | 璞珂
来源 | 阿里技术公众号
前言:本文结合个人在架构设计上的思考和理解,介绍如何从0到1设计一个大型Android项目架构。
一 引导
本文篇幅较长,可结合下表引导快速了解全文主脉络。
二 项目架构演进
该章节主要对一个Android项目架构从0到1再到N的演进历程做出总结(由于项目的开发受业务、团队和排期等各方面因素影响,因此该总结不会严格匹配每一步的演进历程,但足以说明项目发展阶段的一般性规律)。
1 单项目阶段
对于一个新开启项目而言,每端的开发人员通常非常有限,往往只有1-2个。这时候比项目的架构设计和各种开发细节更重要的是开发周期,快速将idea进行落地是该阶段最重要的目标。现阶段项目的架构往往是这样
此时项目中几乎所有的代码都会写在一个独立的app模块中,在时间为王的背景下,最原始的开发模式往往就是最好最高效的。
2 抽象基础库阶段
随着项目最小化MVP已经开发完成,接下来打算继续完善App。此时大概率会遇到以下几个问题:
- 代码的版本控制问题,为保证项目加快迭代,团队新招1-3名开发同学,多人同时在一个项目上开发时,Git代码合并总会出现冲突,非常影响开发效率;
- 项目的编译构建问题,随着项目代码量逐渐增多,运行App都是基于源码编译,以至于首次整包编译构建的速度逐渐变慢,甚至会出现为了验证一行代码的改动而需要等待大几分钟或者更久时间的现象;
- 多应用的代码复用问题,公司可能同时在进行多个App的开发,同样的代码总是需要通过复制粘贴的方式进行复用,维持同一个功能在多个App之间的逻辑一致性也会存在问题;
基于以上的一种或多种原因,我们往往会把那些相对于整个项目而言,一旦开发完成后就很少再改动的功能进行模块化封装。
我们把原本只包含一个应用层的项目,向下抽取了一个包含网络库、图片加载库和UI库等众多原子能力库的基础层。这样做之后,对于协同开发、整包构建和代码复用都起到了很大的改善作用。
3 拓展核心能力阶段
业务初具规模之后,App已经投入到线上并且有持续稳定的DAU。
在这个时候往往非常关键,随着业务增长、客户使用量增大、迭代需求增多等各方面挑战。如果项目没有一套良性的架构设计,开发的人效会随着团队规模的扩大而反向降低,之前单位时间内1个人能开发5个需求,现在10个人用同样的时间甚至连20个需求都开发不完,单纯的依靠加人是很难彻底解决这个问题的。这时候着重需要做的两件事
- 开发职责分离,团队成员需要分为两部分,分别对应业务开发和基础架构。业务开发组负责完成日常业务迭代的支撑,以业务交付为目标;基础架构组负责底层基础核心能力建设,以提升效率、性能和核心能力拓展为目标;
- 项目架构优化,基于1,要在应用层和基础层之间,抽象出核心架构层,并将其连带基础层一起交由基础架构组负责,如图;
该层会涉及到很多核心能力的建设,这里不做过多赘述,下文会对以上各个模块做详细展开。
注:从全局视角来看,基础层和核心层也能作为一个整体,共同支撑上层业务。这里将其分为两层,主要考虑到前者是必选项,是整体架构的必要组成部分;后者是可选项,但同时也是衡量一个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必不可少的部分。
框架选择
基础框架的选择往往参考几个大原则:
- 维护团队和社区比较大,遇到问题后能够有足够多的自助解决空间;
- 底层功能强大,支撑尽可能多的上层应用场景;
- 拓展能力灵活,支持在框架基础上做能力拓展和AOP处理;
- Api侧友好,降低上层的理解和使用成本;
这里不做具体展开,如果不是基础层对网络层有自己额外的定制,则推荐直接使用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操作,子线程处理耗时任务操作。如果反其道而行之就会出现以下问题:
- 主线程做网络请求,会出现NetworkOnMainThreadException异常;
- 主线程做耗时任务,很可能会出现ANR(全称Application Not Responding,指应用无响应);
- 子线程做UI操作,会出现CalledFromWrongThreadException异常(这里只做一般性讨论,实际上在满足某些条件下子线程也能更新UI,参《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>()
@Override
protected Void doInBackground(Void... voids)
doTask();
return null;
.execute();
异步编程痛点
Android开发使用的是Java和Kotlin这两种语言,如果我们的项目中引入了Kotlin当然是最好,对于异步调用时只需要按照如下方式进行调用即可。
Kotlin方案
val one = async doSomethingUsefulOne()
val two = async doSomethingUsefulTwo()
println("The answer is $one.await() + two.await()")
这里适当延伸一下,类似于async + await的异步调用方式,在其他很多语言都已经得到了支持,如下
Dart方案
Future< String> fetchUserOrder() =>
Future.delayed(const Duration(seconds: 2), () => 'Large Latte');
Future< String> createOrderMessage() async
var order = await fetchUserOrder();
return 'Your order is: $order';
function resolveAfter2Seconds(x)
return new Promise(resolve =>
setTimeout(() => resolve(x); , 2000);
);
async function f1()
var x = await resolveAfter2Seconds(10);
console.log(x); // 10
f1();
但是如果我们的项目中还是纯Java项目,在复杂的业务交互场景下,常常会遇到串行异步的业务逻辑,此时我们的代码可读性会变得很差,一种可选的应对方案是通过引入RxJava来解决,参考如下
RxJava方案
source
.operator1()
.operator2()
.operator3()
.subscribe(consumer)
2 核心层
动态配置
业务开关、ABTest
对于线上功能的动态配置
背景
- Android(Native开发)不同于Web能够随时发布上线,Android发布几乎都要走应用平台的审核;
- 业务上很多时候需要做AB测试或一些配置开关,以满足业务的多样性;
基于以上几点,就决定了我们在Android开发过程中,对代码逻辑有动态配置的诉求。
基于这个最基本的模型单元,业务上可以演化出非常丰富的玩法,比如配置启动页停留时长、配置商品中是否展示大图、配置每页加载多少条数据、配置要不要是否允许用户进入某个页面等等。
分析
客户端获取配置信息通常有两种方案,分别对应推和拉。
推是指通过建立客户端与服务端的长连接,服务端一旦有配置发生变化,就将变化的数据推到客户端以进行更新;
拉是指客户端每次通过主动请求来读取最新配置;
基于这两种模式,还会演化出推拉结合的方式,其本质就是两种方式都使用,技术层面没有新变化,这里不做赘述。下面将推拉两种方式进行对比
综合来看,如果业务上对时效性要求没有非常高的情况下,我个人还是倾向于选择拉的方式,主要原因更改配置是低频事件,为了这个低频事件去做C-S的长连接,会有种牛刀杀鸡的感觉。
实现
推配置的实现思考相对清晰,有配置下发客户端更新即可,但需要做好长连接断开后的重连逻辑。
拉配置的实现,这里有些需要我们思考的地方,这里总结以下几点:
- 按照namespace进行多模块划分配置,避免全局一个大而全的配置;
- 每个namespace在初始化和每次改动时都会有个flag标识,以标识当前版本;
- 客户端每个业务请求都在请求头处统一拉上各flag或他们共同组合的md5等标识,为了在服务端统一拦截时进行flag时效性校验;
- 服务端时效性检验结果通过统一响应头下发,与业务接口隔离,上层业务方不感知;
- 客户端收到时效性不一致结果时,再根据具体的namespace进行拉取,而不是每次全量拉取;
全局拦截
背景
App与用户联系最紧密的就是交互,它是我们的App产品与用户之间沟通的桥梁。
用户点击一个按钮之后要执行什么动作,进入一个页面之后要展示什么内容,某个操作之后要执行什么请求,请求之后要执行什么提示,这些都是用户最直观能看到的东西。全局拦截就是针对于这些用户能接触到的最高频的交互逻辑做出可支持通过前面动态配置来进行定制的技术方案。
交互结构化
具体的交互响应(如弹出一个Toast或Dialog,跳转到某个页面)是需要通过代码逻辑来控制的,但该部分要做到的就是在App发布之后还能实现这些交互,因此我们需要将一些基础常见的交互进行结构化处理,然后在App中提前做出通用的预埋逻辑。
我们可以做出以下约定,定义出Action的概念,每个Action就对应着App中能够识别的一个具体交互行为,比如
1.弹出Toast
"type": "toast",
"content": "您好,欢迎来到XXX",
"gravity": "< 这里填写toast要展示的位置, 可选项为(center|top|bottom), 默认值为center>"
2.弹出Dialog
这里值得注意的是,Dialog的Action中嵌套了Toast的逻辑,多种Action的灵活组合能给我们提供丰富的交互能力。
"type": "dialog",
"title": "提示",
"message": "确定退出当前页面吗?",
"confirmText": "确定",
"cancelText": "取消",
"confirmAction":
"type": "toast",
"content": "您点击了确定"
3.关闭当前页面
"type": "finish"
4.跳转到某个页面
"type": "route",
"url": "https://www.xxx.com/goods/detail?id=xxx"
5.执行某个网络请求 同2,这里也做了多Action的嵌套组合。
"type": "request",
"url": "https://www.xxx.com/goods/detail",
"method": "post",
"params":
"id": "xxx"
,
"response":
"successAction":
"type": "toast",
"content": "当前商品的价格为$response.data.priceDesc元"
,
"errorAction":
"type": "dialog",
"title": "提示",
"message": "查询失败, 即将退出当前页面",
"confirmText": "确定",
"confirmAction":
"type": "finish"
统一拦截
交互结构化的数据协议约定了每个Action对应的具体事件,客户端对结构化数据的解析和封装,进而能够将数据协议转化为与用户的产品交互,接下来要考虑的就是如何让一个交互信息生效。参考如下逻辑
1.提供根据页面和事件标识来获取服务端下发的Action的能力,这里用到的DynamicConfig即为前面提到的动态配置。
@Nullable
private static Action getClickActionIfExists(String page, String event)
// 根据当前页面和事件确定动作标识
String actionId = String.format("hook/click/%s/%s", page, event);
// 解析动态配置中, 是否有需要下发的Action
String value = DynamicConfig.getValue(actionId, null);
if (TextUtils.isEmpty(value))
return null;
try
// 将下发Action解析为结构化数据
return JSON.parseObject(value, Action.class);
catch (JSONException ignored)
// 格式错误时不做处理 (供参考)
return null;
2.提供包装点击事件的处理逻辑(performAction为对具体Action的解析逻辑,功能比较简单,这里不做展开)
/**
* 包装点击事件的处理逻辑
*
* @param page 当前页面标识
* @param event 当前事件标识
* @param clickListener 点击事件的处理逻辑
*/
public static View.OnClickListener handleClick(String page, String event,
View.OnClickListener clickListener)
// 这里返回一个OnClickListener对象, 降低上层业务方的理解成本和代码改动难度
return new View.OnClickListener()
@Override
public void onClick(View v)
// 取出当前事件的下发配置
Action action = getClickActionIfExists(page, event);
if (action != null)
// 有配置, 则走配置逻辑
performAction(action);
else if (clickListener != null)
// 无配置, 则走默认处理逻辑
clickListener.onClick(v);
;
有了上面的基础,我们便能够快速实现支持远端动态改变App交互行为的功能,下面对比一下上层业务方在该能力前后的代码差异。
// 之前
addGoodsButton.setOnClickListener(new View.OnClickListener()
@Override
public void onClick(View v)
Router.open("https://www.xxx.com/goods/add");
);
// 之后
addGoodsButton.setOnClickListener(ActionManager.handleClick(
"goods-manager", "add-goods", new View.OnClickListener()
@Override
public void onClick(View v)
Router.open("https://www.xxx.com/goods/add");
));
可以看到,业务侧多透传一些对于当前上下文的标识参数,除此之外没有其他更多的改动。
截止目前,我们对于addGoodsButton这一按钮的点击事件就已经完成了远端的hook能力,如果现在突然出现了一些原因导致添加商品页不可用,则只需要在远端动态配置里添加如下配置即可。
"hook/click/goods-manager/add-goods":
"type": "dialog",
"title": "提示",
"message": "由于XX原因,添加商品页面暂不可用",
"confirmText": "确定",
"confirmAction":
"type": "finish"
此时用户再点击添加商品按钮,就会出现如上的提示信息。
上面介绍了对于点击事件的远端拦截思路,与点击事件对应的,还有跳转页面、执行网络请求等常见的交互,它们的原理都是一样的,不再一一枚举。
本地配置
在App开发测试阶段,通常需要添加一些本地化配置,从而实现一次编译构建允许兼容多种逻辑。比如,在与服务端接口联调过程中,App需要做出常见的几种环境切换(日常、预发和线上)。
理论上,基于前面提到的动态配置也能实现这个诉求,但动态配置主要面向的是线上用户,而如果选择产研阶段使用该种能力,无疑会增加线上配置的复杂度,而且还会依赖网络请求的结果才能实现。
因此,我们需要抽象出一套支持本地化配置的方案,该套方案需要尽可能满足以下能力
- 对本地配置提供默认值支持,未做任何配置时,配置返回默认值。比如,默认环境为线上,如果没有更改配置,就不会读取到日常和预发环境。
- 简化配置的读写接口,让上层业务方尽可能少感知实现细节。比如,我们不需要让上层感知到本地配置的持久化信息写入的是SharedPreferences还是SQLite,而只需提供一个写入的API即可。
- 向上层暴露进入本地配置页的API方式,以满足上层选择性进入的空间。比如,上层通过我们暴露出去的API可以选择实际使用App过程中,是通过点击页面的某个操作按钮、还是手机的音量键或者是摇一摇来进入配置页面。
- 对于App中是否拥有本地配置能力的控制,尽可能放到编译构建级别,保证线上用户不会进入到配置页面。比如,如果线上用户能够进入到预发环境,那很可能会酝酿着一场安全事故即将发生。
版本管理
在移动客户端中,Android应用不同于iOS只能在AppStore进行发布,Android构建的产物.apk文件支持直接安装,这就给App静默升级提供了可能。基于该特性,我们可以实现用户在不通过应用市场即可直接检测和升级新版本的诉求,缩短了用户App升级的路径,进而能够提升新版本发布时的覆盖率。
我们需要在应用中考虑抽象出来版本检测和升级的能力支持,这里需要服务端提供检测和获取新版本App的接口。客户端基于某种策略,如每次刚进入App、或手动点击新版本检测时,调用服务端的版本检测接口,以判断当前App是否为最新版本。如果当前是新版本,则提供给App侧最新版本的apk文件下载链接,客户端在后台进行版本下载。下面总结出核心步骤的流程图
日志监控
环境隔离、本地持久化、日志上报
客户端的日志监控主要用来排查用户在使用App过程中出现的Crash等异常问题,对于日志部分总结几项值得注意的点
- 环境隔离,release包禁止log输出;
- 本地持久化,对于关键重要日志(如某个位置错误引起的Crash)要做本地持久化保存;
- 日志上报,在用户授权允许的情况下,将暂存用户本地的日志进行上传,并分析具体的操作链路;
这里推荐两个开源的日志框架:
logger
timber
埋点统计
服务端能查询到的是客户端接口调用的次数和频率,但无法感知到用户具体的操作路径。为了能够更加清晰的了解用户,进而分析分析产品的优劣势和瓶颈点,我们可以将用户在App上的核心操作路径进行收集和上报。
比如,下面是一个电商App的用户成交漏斗图,通过客户端的埋点统计能够获取到漏斗各层的数据,然后再通过数据制作做出可视化报表。
分析以下漏斗,我们可以很明显的看出成交流失的关键节点是在「进入商品页」和「购买」之间,因此接下来就需要思考为什么「进入商品页」的用户购买意愿降低?是因为商品本身问题还是商品页的产品交互问题?会不会是因为购买按钮比较难点击?还是因为商品页图片太大导致商品介绍没有展示?这些流失流量的页面停留时长又是怎样的?对于这些问题的思考,会进一步促使我们去在商品页添加更多的ABTest和更细粒度的埋点统计分析。总结下来,埋点统计为用户行为分析和产品优化提供了很重要的指引意义。
在技术侧,对于该部分做出以下关键点总结
- 客户端埋点一般分为P点(页面级别)、E点(事件级别)和C点(自定义点);
- 埋点分为收集和上报两个步骤,用户量级较大时要注意对上报埋点进行合并、压缩等优化处理;
- 埋点逻辑是辅逻辑,产品业务是主逻辑,客户端资源紧张时要做好资源分配的权衡;
热修复
热修复(Hotfix)是一种对已发布上线的App在不进行应用升级的情况下进行动态更新原代码逻辑的技术方案。主要同于以下场景
- 应用出现重大缺陷并严重影响到用户使用时,比如,在某些系统定制化较强的机型(如小米系列)上一旦进入商品详情页就出现应用Crash;
- 应用出现明显阻塞问题并影响到用户正常交互时,比如,在某些极端场景下,用户无法关闭页面对话框;
- 应用出现资损、客诉、舆论风波等产品形态问题,比如将价格单位“元”误显示为“分”;
有关热修复相关的技术方案探究,可以延展出很大篇幅,本文的定位是Android项目整体的架构,因此不做详细展开。
3 应用层
抽象和封装
对于抽象和封装,主要取决于我们日常Coding过程中对一些痛点和冗余编码的感知和思考能力。
比如,下面是一段Android开发过程中常写的列表页面的标准实现逻辑
public class GoodsListActivity extends Activity
private final List< GoodsModel> dataList = new ArrayList<>();
private Adapter adapter;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState)
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_goods_list);
RecyclerView recyclerView = findViewById(R.id.goods_recycler_view);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
adapter = new Adapter();
recyclerView.setAdapter(adapter);
// 加载数据
dataList.addAll(...);
adapter.notifyDataSetChanged();
private class Adapter extends RecyclerView.Adapter< ViewHolder>
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int position)
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
View view = inflater.inflate(R.layout.item_goods, parent, false);
return new ViewHolder(view);
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position)
GoodsModel model = dataList.get(position);
holder.title.setText(model.title);
holder.price.setText(String.format("%.2f", model.price / 100f));
@Override
public int getItemCount()
return dataList.size();
private static class ViewHolder extends RecyclerView.ViewHolder
private final TextView title;
private final TextView price;
public ViewHolder(View itemView)
super(itemView);
title = itemView.findViewById(R.id.item_title);
price = itemView.findViewById(R.id.item_price);
这段代码看上去没有逻辑问题,能够满足一个列表页的功能诉求。
面向RecyclerView框架层,为了提供框架的灵活和拓展能力,所以把API设计到足够原子化,以支撑开发者千差万别的开发诉求。比如,RecyclerView要做对多itemType的支持,所以内部要做根据itemType开分组缓存vitemView的逻辑。
但实际业务开发过程中,就会抛开很多特殊性,我们页面要展示的绝大多数列表都是单itemType的,在连续写很多个这种单itemType的列表之后,我们就开始去思考一些问题:
- 为什么每个列表都要写一个ViewHolder?
- 为什么每个列表都要写一个Adapter?
- 为什么Adapter中对itemView的创建和数据绑定要在onCreateViewHolder和onBindViewHolder两个方法中分开进行?
- 为什么Adapter每次设置数据之后,都要调用对应的notifyXXX方法?
- 为什么Android上实现一个简单的列表需要大几十行代码量?这其中有多少是必需的,又有多少是可以抽象封装起来的?
对于以上问题的思考最终引导我封装了RecyclerViewHelper的辅助类,相对于标准实现而言,使用方可以省去繁琐的Adapter和ViewGolder声明,省去一些高频且必需的代码逻辑,只需要关注最核心的功能实现,如下
public class GoodsListActivity extends Activity
private RecyclerViewHelper< GoodsModel> recyclerViewHelper;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState)
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_goods_list);
RecyclerView recyclerView = findViewById(R.id.goods_recycler_view);
recyclerViewHelper = RecyclerViewHelper.of(recyclerView, R.layout.item_goods,
(holder, model, position, itemCount) ->
TextView title = holder.getView(R.id.item_title);
TextView price = holder.getView(R.id.item_price);
title.setText(model.title);
price.setText(String.format("%.2f", model.price / 100f));
);
// 加载数据
recyclerViewHelper.addData(...);
上面只是一个引子,实际开发过程中我们会遇到非常多类似的情况,还有一些常见的封装。比如,封装全局统一的BaseActivity和BaseFragment,包含但不限于以下能力
- 页面埋点,基于前面提到的埋点统计,在用户进入、离开页面等时机,将用户页面的交互情况进行收集上报;
- 公共UI,页面顶部状态栏和ActionBar、页面常用的下拉刷新能力实现、页面耗时操作时的加载进度条;
- 权限处理,进入当前页面所需要的权限申请,用户授权后的回调逻辑和拒绝后的异常处理逻辑;
- 统一拦截,结合前面提到的统一拦截对进入页面后添加支持动态配置交互的定制能力;
模块化
背景
这里提到的模块化是指,基于App的业务功能对项目工程进行模块化拆分,主要为了解决大型复杂业务项目的协同开发困难问题。
在项目结构的改造如上图,将原来承载所有业务的 app 模块拆分为 home、goods、mine等多个业务模块。
通用能力下沉
前面「抽象和封装」章节提到的 BaseActivity、BaseFragment 等通用业务能力在项目模块化之后,也需要同步做改造,要下沉到业务层中单独的一个 base 模块中,以便提供给其他业务模块引用。
隐式路由改造
模块化之后,各模块间没有相互依赖关系,此时跨模块进行页面跳转时不能直接引用其他模块的类。
比如,在首页展示某一个商品推荐,点击之后要跳转到商品详情页,在模块化之前的写法是
但在模块化之后,在首页模块无法引用 GoodsActivity 类,因此页面跳转不能再继续之前的方式,需要对页面进行隐式路由改造,如下
1.注册 Activity 标识,在 AndroidManifest.xml 中注册 Activity 的地方添加 action 标识
2.替换跳转逻辑,代码中根据上一步注册的 Activity 标识进行隐式跳转
基于这两步的改造, 便能够达到模块化之后仍能正常跳转业务页面的目的。
更进一步,我们将隐式跳转的逻辑进行抽象和封装,提炼出一个专门提供隐式路由能力的静态方法,参考如下代码
public class Router
/**
* 根据url跳转到目标页面
*
* @param context 当前页面上下文
* @param url 目标页面url
*/
public static void open(Context context, String url)
// 解析为Uri对象
Uri uri = Uri.parse(url);
// 获取不带参数的url
String urlWithoutParam = String.format(
"%s://%s%s", uri.getScheme(), uri.getHost(), uri.getPath());
Intent intent = new Intent(urlWithoutParam);
// 解析url中的参数, 并通过Intent传递至下个页面
for (String paramKey : uri.getQueryParameterNames())
String paramValue = uri.getQueryParameter(paramKey);
intent.putExtra(paramKey, paramValue);
// 执行跳转操作
context.startActivity(intent);
此时外部页面跳转时,只需要通过如下一句调用即可
Router.open(this, "https://www.xxx.com/goods/detail?goodsId=" + model.goodsId);
这次封装可以
- 抽象统一方法,降低外部编码成本;
- 统一收口路由逻辑,便于结合前面的「动态配置」和「统一拦截」章节进行线上App路由逻辑的动态更改;
- 标准化 Android 端页面跳转参数格式,统一使用 String 类型,解除目标页面解析参数时的类型判断歧义;
- 对跳转页面所需要的数据做了标准化支持,iOS 端再配合同步改造后,页面跳转逻辑将支持完全由业务服务端进行下发;
模块通信
模块化后另一个需要解决是模块通信问题,没有直接依赖关系的模块间是拿不到任何对方的 API 进行直接调用的。对于该问题往往会按照如下类别进行分析和处理
1、通知式通信,只需要将事件告知对方,并不关注对方的响应结果。对于该种通信,一般采用如下方式实现
- 借助 framework 层提供的通过 Intent + BroadcastReceiver (或 LocalBroadcastManager)发送事件;
- 借助框架 EventBus 发送事件;
- 基于观察者模式自实现消息转发器来发送事件;
2、调用式通信,将事件告知对方,同时还关注对方的事件响应结果。对于该种通信,一般采用如下方式实现
- 定义出 biz-service 模块,将业务接口 interface 文件收口到该模块,再由各接口对应语义的业务模块进行接口的实现,然后再基于某种机制(手动注册或动态扫描)完成实现类的注册;
-
抽象出 Request => Response 的通信协议,协议层负责完成
- 先将通过调用方传递的 Request 路由到被调用方的协议实现层;
- 再将实现层返回结果转化为泛化的 Response对象;
- 最后将 Response 返回给调用方;
相对于 biz-service,该方案的中间层不包含任何业务语义,只定义泛化调用所需要的关键参数。
4 跨平台层
跨平台层,主要是为了提高开发人效,一套代码能够在多平台运行。
跨平台一般有两个接入的时机,一个是在最开始的前期项目调研阶段,直接技术选型为纯跨平台技术方案;另一个是在已有Native工程上需要集成跨平台能力的阶段,此时App属于混合开发的模式,即Native + 跨平台相结合。
有关更多跨平台的选型和细节不在本文范畴内,具体可以参考《移动跨平台开发框架解析与选型》,文中对于整个跨平台技术的发展、各框架原理及优劣势讲得很详细。参跨平台技术演进图
对于目前主流方案的对比可参考下表
上面对项目架构中各层的主要模块进行了逐一的拆解和剖析,接下来会重点对架构设计和实际开发中用到的一些非常核心的原理进行总结和梳理。
四 核心原理总结
在Android开发中,我们会接触到的框架不计其数,并且这些框架还还在不断的更新迭代,因此我们很难对每个框架都能了如指掌。
但这并不影响我们对Android中核心技术学习和研究,如果你有尝试过深入剖析这些框架的底层原理,就会发现它们中很多原理都是相通的。一旦我们掌握了这些核心原理,就会发现绝大多数框架只不过是利用这些原理,再结合框架要解决的核心问题,进而包装出来的通用技术解决方案。
下面我把在SDK框架和实际开发中一些高频率使用的核心原理进行梳理和总结。
1 双缓存
双缓存是指在通过网络获取一些资源时,为提高获取速度而在内存和磁盘添加双层缓存的技术方案。该方案最开始主要用于上文「图片模块」提到的图片库中,图片库利用双缓存来极大程度上提高了图片的加载速度。一个标准双缓存方案如下图示
双缓存方案的核心思想就是,对时效性低或更改较少的网络资源,尽可能采取用空间换时间的方式。我们知道一般的数据获取效率:内存 > 磁盘 > 网络,因此该方案的本质就是将获取效率低的渠道向效率高的取到进行资源拷贝。
基于该方案,我们在实际开发中还能拓展另一个场景,对于业务上一些时效性低或更改较少的接口数据,为了提高它们的加载效率,也可以结合该思路进行封装,这样就将一个依赖网络请求页面的首帧渲染时长从一般的几百ms降到几十ms以内,优化效果相当明显。
2 线程池
线程池在Android开发中使用到的频率非常高,比如
- 在开发框架中,网络库和图片库获取网络资源需用到线程池;
- 在项目开发中,读写SQLite和本地磁盘文件等IO操作需要用到线程池;
- 在类似于AsyncTask这种提供任务调度的API中,其底层也是依赖线程池来完成的;
如此多的场景会用到线程池,如果我们希望对项目的全局观把握的更加清晰,熟悉线程池的一些核心能力和内部原理是尤为重要的。
就其直接暴露出来的API而言,最核心的方法就两个,分别是线程池构造方法和执行子任务的方法。
// 构造线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize,
keepAliveTime, keepAliveTimeUnit, workQueue, threadFactory, rejectedExecutionHandler);
// 提交子任务
executor.execute(new Runnable()
@Override
public void run()
// 这里做子任务操作
);
其中,提交子任务就是传入一个 Runnable 类型的对象实例不做赘述,需要重点说明也是线程池中最核心的是构造方法中的几个参数。
// 核心线程数
int corePoolSize = 5;
// 最大线程数
int maximumPoolSize = 10;
// 闲置线程保活时长
int keepAliveTime = 1;
// 保活时长单位
TimeUnit keepAliveTimeUnit = TimeUnit.MINUTES;
// 阻塞队列
BlockingDeque< Runnable> workQueue = new LinkedBlockingDeque<>(50);
// 线程工厂
ThreadFactory threadFactory = new ThreadFactory()
@Override
public Thread newThread(Runnable r)
return new Thread(r);
;
// 任务溢出的处理策略
RejectedExecutionHandler rejectedExecutionHandler = new ThreadPoolExecutor.AbortPolicy();
网上有关线程池的文章和教程有很多,这里不对每个具体参数做重复表述;但我下面要把对理解线程池内部原理至关重要的——子任务提交后的扭转机制进行单独说明。
上图表明的是往线程池中不断提交子任务且任务来不及执行时线程池内部对任务的处理机制,该图对理解线程池内部原理和配置线程池参数尤为重要。
3 反射和注解
反射和注解都是Java语言里一种官方提供的技术能力,前者用来在程序运行期间动态读写对象实例(或静态)属性、执行对象(静态)方法;后者用来在代码处往类、方法、方法入参、类成员变量和局部变量等指定域添加标注信息。
通过反射和注解技术,再结合对代码的抽象和封装思维,我们可以非常灵活的实现很多泛化调用的诉求,比如
- 前面「热修复」章节,基于ClassLoader 的方案,其内部实现几乎全是通过反射进行 dex 更改的;
- 前面「网络模块」章节,Retorfit 只需要声明一个接口再添加注解即可使用,其底层也是利用了反射注解和下面要介绍的动态代理技术;
- 依赖注入框架 dagger 和 androidannotations 利用 Java 的 APT 预编译技术再结合编译时注解做注入代码生成;
- 如果了解 Java 服务端开发,主流的开发框架 SpringBoot 其内部大量运用了注射和注解的技术;
反射和注解在开发中适用的场景有哪些?下面列举几点
依赖注入场景
普通方式
public class DataManager
private UserHelper userHelper = new UserHelper();
private GoodsHelper goodsHelper = new GoodsHelper();
private OrderHelper orderHelper = new OrderHelper();
注入方式
public class DataManager
@Inject
private UserHelper userHelper;
@Inject
private GoodsHelper goodsHelper;
@Inject
private OrderHelper orderHelper;
public DataManager()
// 注入对象实例 (内部通过反射+注解实现)
InjectManager.inject(this);
注入方式的优势是,对使用方屏蔽依赖对象的实例化过程,这样方便对依赖对象进行统一管理。
调用私有或隐藏API场景
有个包含私有方法的类。
public class Manager
private void doSomething(String name)
// ...
我们拿到 Manager 的对象实例后,希望调用到 doSomething 这个私有方法,按照一般的调用方式如果不更改方法为 public 就是无解的。但利用反射可以做到
try
Class< ?> managerType = manager.getClass();
Method doSomethingMethod = managerType.getMethod("doSomething", String.class);
doSomethingMethod.setAccessible(true);
doSomethingMethod.invoke(manager, "< name参数>");
catch (Exception e)
e.printStackTrace();
诸如此类的场景在开发中会有很多,可以说熟练掌握反射和注解技术,既是掌握 Java 高阶语言特性的表现,也能够让我们在对一些通用能力进行抽象封装时提高认知和视角。
4 动态代理
动态代理是一种能够在程序运行期间为指定接口提供代理能力的技术方案。
在使用动态代理时,通常都会伴随着反射和注解的应用,但相对于反射和注解而言,动态代理的作用相对会比较晦涩难懂。下面结合一个具体的场景来看动态代理的作用。
背景
项目开发过程中,需要调用到服务端接口,因此客户端封装一个网络请求的通用方法。
public class HttpUtil
/**
* 执行网络请求
*
* @param relativePath url相对路径
* @param params 请求参数
* @param callback 回调函数
* @param < T> 响应结果类型
*/
public static < T> void request(String relativePath, Map< String, Object> params, Callback< T> callback)
// 实现略..
由于业务上有多个页面都需要查询商品列表数据,因此需要封装一个 GoodsApi 的接口。
public interface GoodsApi
/**
* 分页查询商品列表
*
* @param pageNum 页面索引
* @param pageSize 每页数据量
* @param callback 回调函数
*/
void getPage(int pageNum, int pageSize, Callback< Page< Goods>> callback);
并针对于该接口添加 GoodsApiImpl 实现类。
public class GoodsApiImpl implements GoodsApi
@Override
public void getPage(int pageNum, int pageSize, Callback< Page< Goods>> callback)
Map< String, Object> params = new HashMap<>();
params.put("pageNum", pageNum);
params.put("pageSize", pageSize);
HttpUtil.request("goods/page", params, callback);
基于当前封装,业务便能够直接调用。
问题
业务需要再添加如下的查询商品详情接口。
我们需要在实现类添加实现逻辑。
紧接着,又需要添加 create 和 update 接口,我们继续实现。
不仅如此,接下来还要加 OrderApi、ContentApi、UserApi 等等,并且每个类都需要这些列表。我们会发现业务每次需要添加新接口时,都得写一遍对 HttpUtil#request 方法的调用,并且这段调用代码非常机械化。
分析
前面提到接口实现代码的机械化,接下来我们尝试着将这段机械化的代码,抽象出一个伪代码的调用模板,然后进行分析。
透过每个方法内部代码实现的现象看其核心的本质,我们可以抽象归纳为以上的“模板”逻辑。
有没有一种技术可以让我们只需要写网络请求所必需的请求协议相关参数,而不需要每次都要做以下几步重复琐碎的编码?
- 手动写一个 Map;
- 往 Map 中塞参数键值对;
- 调用 HttpUtil#request 执行网络请求;
此时动态代理便能解决这个问题。
封装
分别定义路径和参数注解。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Path
/**
* @return 接口路径
*/
String value();
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Param
/**
* @return 参数名称
*/
String value();
基于这两个注解,便能封装动态代理实现(以下代码为了演示核心链路,忽略参数校验和边界处理逻辑)。
@SuppressWarnings("unchecked")
public static < T> T getApi(Class< T> apiType)
return (T) Proxy.newProxyInstance(apiType.getClassLoader(), new Class[]apiType, new InvocationHandler()
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
// 解析接口路径
String path = method.getAnnotation(Path.class).value();
// 解析接口参数
Map< String, Object> params = new HashMap<>();
Parameter[] parameters = method.getParameters();
// 注: 此处多偏移一位, 为了跳过最后一项callback参数
for (int i = 0; i < method.getParameterCount() - 1; i++)
Parameter parameter = parameters[i];
Param param = parameter.getAnnotation(Param.class);
params.put(param.value(), args[i]);
// 取最后一项参数为回调函数
Callback< ?> callback = (Callback< ?>) args[args.length - 1];
// 执行网络请求
HttpUtil.request(path, params, callback);
return null;
);
效果
此时需要通过注解在接口声明处添加网络请求所需要的必要信息。
public interface GoodsApi
@Path("goods/page")
void getPage(@Param("pageNum") int pageNum, @Param("pageNum") int pageSize, Callback< Page< Goods>> callback);
@Path("goods/detail")
void getDetail(@Param("id") long id, Callback< Goods> callback);
@Path("goods/create")
void create(@Param("goods") Goods goods, Callback< Goods> callback);
@Path("goods/update")
void update(@Param("goods") Goods goods, Callback< Void> callback);
外部通过 ApiProxy 获取接口实例。
// 之前
GoodsApi goodsApi = new GoodsApiImpl();
// 现在
GoodsApi goodsApi = ApiProxy.getApi(GoodsApi.class);
相比之前,上层的调用方式只有极小的调整;但内部的实现却有了很大的改进,直接省略了所有的接口实现逻辑,参考如下代码对比图。
前面讲了架构设计过程中涉及到的核心框架原理,接下来会讲到架构设计里的通用设计方案。
五 通用设计方案
我们进行架构设计的场景下通常是不同的,但有些问题的底层设计方案是相通的,这一章节会对这些相通的设计方案进行总结。
通信设计
一句话概括,通信的本质就是解决 A 和 B 之间如何调用的问题,下面按抽象出来的 AB 模型依赖关系进行逐一分析。
直接依赖关系
关系范式:A => B
这是最常见的关联关系,A 类中直接依赖 B,只需要通过最基本的方法调用和设置回调即可完成。
场景
页面 Activity (A)与按钮 Button (B)的关系。
参考代码
间接依赖关系
关系范式:A => C => B
通信方式同直接依赖,但需要添加中间层进行透传。
以上是关于Android项目架构设计深入浅出的主要内容,如果未能解决你的问题,请参考以下文章