Android 项目架构设计深入浅出

前言:本文结合个人在架构设计上的思考和理解,介绍如何从0到1设计一个大型Android项目架构。

一 引导

本文篇幅较长,可结合下表引导快速了解全文主脉络。

二 项目架构演进

该章节主要对一个Android项目架构从0到1再到N的演进历程做出总结(由于项目的开发受业务、团队和排期等各方面因素影响,因此该总结不会严格匹配每一步的演进历程,但足以说明项目发展阶段的一般性规律)。

1 单项目阶段

对于一个新开启项目而言,每端的开发人员通常非常有限,往往只有1-2个。这时候比项目的架构设计和各种开发细节更重要的是开发周期,快速将idea进行落地是该阶段最重要的目标。现阶段项目的架构往往是这样

此时项目中几乎所有的代码都会写在一个独立的app模块中,在时间为王的背景下,最原始的开发模式往往就是最好最高效的。

2 抽象基础库阶段

随着项目最小化MVP已经开发完成,接下来打算继续完善App。此时大概率会遇到以下几个问题:

  1. 代码的版本控制问题,为保证项目加快迭代,团队新招1-3名开发同学,多人同时在一个项目上开发时,Git代码合并总会出现冲突,非常影响开发效率;
  2. 项目的编译构建问题,随着项目代码量逐渐增多,运行App都是基于源码编译,以至于首次整包编译构建的速度逐渐变慢,甚至会出现为了验证一行代码的改动而需要等待大几分钟或者更久时间的现象;
  3. 多应用的代码复用问题,公司可能同时在进行多个App的开发,同样的代码总是需要通过复制粘贴的方式进行复用,维持同一个功能在多个App之间的逻辑一致性也会存在问题;

基于以上的一种或多种原因,我们往往会把那些相对于整个项目而言,一旦开发完成后就很少再改动的功能进行模块化封装。

我们把原本只包含一个应用层的项目,向下抽取了一个包含网络库、图片加载库和UI库等众多原子能力库的基础层。这样做之后,对于协同开发、整包构建和代码复用都起到了很大的改善作用。

3 拓展核心能力阶段

业务初具规模之后,App已经投入到线上并且有持续稳定的DAU。

在这个时候往往非常关键,随着业务增长、客户使用量增大、迭代需求增多等各方面挑战。如果项目没有一套良性的架构设计,开发的人效会随着团队规模的扩大而反向降低,之前单位时间内1个人能开发5个需求,现在10个人用同样的时间甚至连20个需求都开发不完,单纯的依靠加人是很难彻底解决这个问题的。这时候着重需要做的两件事

  1. 开发职责分离,团队成员需要分为两部分,分别对应业务开发和基础架构。业务开发组负责完成日常业务迭代的支撑,以业务交付为目标;基础架构组负责底层基础核心能力建设,以提升效率、性能和核心能力拓展为目标;
  2. 项目架构优化,基于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必不可少的部分。

框架选择

基础框架的选择往往参考几个大原则:

  1. 维护团队和社区比较大,遇到问题后能够有足够多的自助解决空间;
  2. 底层功能强大,支撑尽可能多的上层应用场景;
  3. 拓展能力灵活,支持在框架基础上做能力拓展和AOP处理;
  4. 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操作,子线程处理耗时任务操作。如果反其道而行之就会出现以下问题:

  1. 主线程做网络请求,会出现NetworkOnMainThreadException异常;
  2. 主线程做耗时任务,很可能会出现ANR(全称Application Not Responding,指应用无响应);
  3. 子线程做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>() {
        @
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值