雪球卡片注入框架探索二三事

avatar
@雪球财经

引言

又到了需求评审的日子,组长如约来到了会议室,与产品同学开启了融洽的沟通 ...

产品 A:“本次需求重点是货架页改版。”

组长:“好的。” (内心:也不知这次要改啥,先听听!)

产品 A:“本次改动重点在于丰富基金货卡的形式和内容。”

组长:微笑 + 点头。

产品 A:“基于内容丰富度,设计美观性,我们整理了九种卡片样式,大家请看....”

组长:“嗯。”(内心:看起来不错的样子。)

产品 B:“补充一下,我们期望基金卡片可以随处展示,九种卡片可以随意组合。这个需要技术评估下。”

组长:“这个没问题,技术上完全可行,计划什么时候上线?”

产品们:“越快越好。”

组长:“额,好的。”

一、需求拆解

会议结束后,组长立即着手对需求进行拆解,发现有两处关键点:

  1. 基金 View 承载着基础信息 + 卡片信息,可以自由的展示在不同位置
  2. 多种卡片与基金 View 自由组合

依据拆解,组长提出了设计目标:

  1. 卡片进行抽象定义,与基金 View 隔离
  2. 卡片注入动态化,扩展无需改动现有代码

二、“卡片创建”实现方案演进

设计目标确定后,组长把卡片创建需求交给了小何:

组长:“小何,卡片创建的需求交给你负责了。”(内心:要求设计优秀,代码优雅)

小何:“放心吧组长,肯定没问题。”(内心:这需求简单,好做)

于是小何兴冲冲地开启了需求开发... 同时开启了代码迭代之路...

2.1 控制流

根据卡片类型,创建不同卡片,思路再清晰不过了,于是小何轻松地写出了下面的代码,并自认为很好地实现了业务需求。

// CardOutView.kt
fun getCard(type: Int): View? {
    var card: View = null
    if (type == 1) {
        card = Card1()
    } else if (type == 2) {
        ...
    } else {
        throw IllegalArgumentException("type error!")
    }
    return card
}

不过很快,针对上面的代码,同事提出了几个疑问,让我们一起思考下:

同事 A: “假如我们新增一种卡片,这是否意味着你必须要修改这部分代码 ?”

小何一愣,还没来得及解释,有人接着问

同事 B:“假设卡片设计变动,例如修改名称,这段逻辑是否需要同步改动?”

同事 C:“实际业务中,所有卡片都需要设置数据,如何对卡片设计者进行规则限制?”

小何陷入了思考之中,一时间没法回答。

其实,上面的设计存在几个明显的问题

  1. 不符合 开闭原则,可以预见,如果后续新增卡片的话,需要直接修改主干代码,而我们提倡代码应该是对修改封闭的
  2. 不符合 单一责任原则, 卡片创建与 CardOutView 耦合严重,我们提倡代码是低耦合高内聚的
  3. 没有对卡片进行 抽象,无法对卡片设计者提供有效约束,并导致可读性、扩展性差

最后,小何结合各位同事提出的问题和建议,着手对程序设计进行改进 ...

2.2 抽象 & 工厂模式 & 反射

没有抽象的代码设计,其可读性和扩展性都会受到限制。同时抽象是程序解耦的必备前提。因此优化的第一步就是对卡片进行抽象,定义接口,并结合业务逻辑对接口内容进行补充。

在接口定义好后,小何引入 工厂模式 来创建卡片,这里采用简单工厂方法,接收卡片类型,返回抽象卡片接口。

进一步优化,小何用反射替换 new Card() 创建具体对象。

优化后的代码结构如下:

// 卡片接口
interface ICard {
    fun setData(data: CardData)
    fun getView(): View?
}


// CardOutView.kt
fun getCardViewAndSetData(type: Int, data: CardData): View? {
    val card: ICard? = CardFactory.getCard(type)
    card?.setData(data)
    return card?.getView()
}


// CardFactory.kt
fun getCard(type: Int): ICard?{
    val clazz = classMap[index] // 获取 class 信息。
    val c: Constructor<out ICard>? = clazz?.getConstructor(Context::class.java)
    return c?.newInstance()
}

优化后的结构类图如下:

代码经过优化后,虽然结构和设计上比之前要复杂不少,但考虑到健壮性和拓展性,还是非常值得的。 此方案可以有效避免控制流实现的缺点,其优点主要有:

  1. 符合 开闭原则,新增卡片无需改动 CardOutView 类和 CardFactory.getCard() 方法
  2. 符合 单一责任原则, 卡片创建由 CardFactory统一管理
  3. 对卡片进行 抽象,对卡片设计者提供有效约束,增强代码可读性,可扩展性

至此,经过同事的反馈与建议,小何终于得到了一套低耦合高内聚,同时符合开闭原则的设计。

小何:“各位同事觉得这次优化做的怎么样?”

同事们:“合格。但是,依然要戒骄戒躁。”

几天之后...

组长:“我看了代码实现,新增类型虽然无需改动主干代码,但是仍然需要手动维护 CardFactory里面的一个 classMap 啊,能不能改成自动注入?”

小何再次陷入了思考之中 ...

组长:“我觉得可以试试,思路,原理讲解中 ...”

小何:“听起来挺厉害的,可以先试试。”

2.3 Annotation Processing Tool

在方案 2 中,当新增卡片时,仅需要在 classMap 中添加新的键值对,这似乎是传统方案不得不保留的逻辑。那么我们要彻底实现动态化(新增卡片无需改动任何已有代码),应该如何做呢?在这里我们需要摒弃传统的设计思路,从另一个技术角度来思考,利用新的工具来迈出这关键的一步。

这一步的实现原理就是本文的核心: 利用 APT 实现卡片的动态注入

很多第三方库在使用 APT 技术,如 Dagger2ButterKnife,以及雪球公司自研的 snb-router。APT 技术可以在编译时根据 Annotation 信息生成相关的代码。工具使用容易(Easy),实现的功能却并不简单(Simple)。

技术方案确定后,小何对现有类图做了修改,结果如下

可以看到,改动范围并不大,那么上面的设计方案是如何做到自动注入的呢?

其中的关键点是新增了 FundCard_AutoGen ,这个类需要我们利用 APT 技术自动生成,并令其继承于 CardFactory 。同时,我们修改 CardFactoryclassMapstatic 方便后续扩展。

在程序编译时,Annotation Processor 会获取所有卡片注解以及关联的 Class 信息,而这些信息最终会被写入 classMap 对象。最后我们还需要在 Application 添加一行代码来保证生成的代码逻辑得到有效执行:

// Application.kt
override fun onCreate() {
    super.onCreate()
    FundCard_AutoGen.init()
}

在组长的帮助(催促)下,小何整理了设计思路和技术方案,具体实现将在下一章详细介绍。

三、动态注入框架实现

本章讲述利用 APT 实现雪球卡片动态注入框架 CardInject 的详细原理和步骤。

3.1 前置知识

本节介绍 Java 注解的基础知识,了解的读者可以直接跳过。

  • 3.1.1 Annotations in Java

Annotations, a form of metadata, provide data about a program that is not part of the program itself. Annotations have no direct effect on the operation of the code they annotate.。

  1. 注解以 @ 开头
  2. 注解作用是将元数据(metadata)与程序元素(实例变量、构造函数、方法、类)关联起来
  3. 注解不只是注释,注解可以改变编译器处理程序的方式

注解可以实现多种功能,主要包含以下方面:

  1. 为编译器提供信息 - 编译器可以利用注解来检测错误或抑制警告
  2. 编译时和部署时处理 - 软件工具可以处理注释信息以生成代码、XML文件等等
  3. 运行时处理 - 有些注释可以在运行时进行检查

Java 注解的层次结构如下:

如上图所示, @Override 是一个常用的标记注解,下面的代码利用 @Override来展示注解的作用:

  1. 编辑器可以利用注解来检测错误或抑制警告(Line 9)
  2. 编译时,软件工具会借助注解检查错误
// OverWriteTest.java
public class OverWriteTest {
    static class Base {
        public void display() {
            System.out.println("Base display()");
        }
    }

    static class Extend extends Base {
        @Override // Error hint
        public void display(int a) {
            System.out.println("OverWriteTest display()");
        }
    }

    public static void main(String[] args) {
        Base base = new Extend();
        base.display();
    }
}

在 jdk11 环境,运行 java OverWriteTest.java 命令,程序会抛出编译错误,因为我们并没有重写而是重载了 display 方法:

3.1.2 APT 流程示意图

4.1.1 中详细介绍了 Annotation 知识。因此 Annotation Processing Tool 也就很好理解了。主要内容就是对注解的一种应用: 软件工具可以处理注释信息以生成代码、XML文件等等

CardInject 实现原理就是建立在 APT 之上的。主要包含以下步骤:

  1. 自定义注解来标记基金货卡 - 对应图中的 Input Model 部分
  2. 利用处理工具处理注解信息 - 对应图中 APT 部分
  3. 自动生成代码( Type - Card 映射表 classMap )- 对应图中 Out Model 部分

3.2 动态注入具体实现步骤

  1. 新建两个 Java Library

可以只创建一个或者不创建从而把代码写在 App 目录下,这里按照惯例把注解和注解处理器分别放置在单独的 Library 中。如下:

  1. apt-annotaion 中定义卡片注解

package com.example.apt_annotation;

@Retention(RetentionPolicy.CLASS)
@Target(ElementType.TYPE)
public @interface FundSubView {
    String type();
}

创建自定义注解就是 apt-annotation Library 的唯一改动。

  1. apt-processor 中处理注解

为了能够处理上面自定义的注解 FundSubView 。我们需要做些前期准备:

  1. 在 apt-processor 的 build.gradle 增加如下改动 :
// build.gradle(:apt-processor)
dependencies {
     ...
    // 引入 Google 的注解处理工具。
    implementation 'com.google.auto.service:auto-service:1.0-rc6'
    annotationProcessor 'com.google.auto.service:auto-service:1.0-rc6'
 }
  1. 在 App 中做如下改动:
// build.gradle(:app)
implementation project(path: ':apt-annotation')
kapt project(":apt-processor")
  1. 完成上面两步,基础准备工作就完成了。接下来进行具体的业务处理。首先用注解标记基金卡片:
@FundSubView(type = "1")
public class CardView extends View{
    ...
}
  1. 接下来创建注解处理类 (动态注入实现的核心逻辑)

    1. 创建 FundSubProcessor 继承于 AbstractProcessor
    2. @AutoService(Processor.class) 标记
    3. 重写 getSupportedAnnotationTypes ,确定要处理的注解类型
    4. 必须重写 process ,这里可以获取注解信息,然后根据信息生成相关代码
package com.example.apt_processor;

@AutoService(Processor.class)
public class FundSubProcessor extends AbstractProcessor {

    @Override
    public Set<String> getSupportedAnnotationTypes() {
        Set<String> set = new HashSet<>();
        set.add(FundSubView.class.getCanonicalName());
        return set;
    }

    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnv) {
        Set<? extends Element> temp = roundEnv.getElementsAnnotatedWith(FundSubView.class);
        for (Element element : temp) {
          // 根据注解生成 Java 类

        }
        return true;
    }
}

在生成 java 源文件时,我们利用 Javapoet 工具帮助我们完成,并且让生成的类继承 CardFactory , 因为这样可以少写很多自动生成代码的逻辑。源码可查看文末的 Demo 地址。

3.3 动态注入最终实现成果

  1. 动态生成的代码

public class FundCard_AutoGen extends CardFactory {
  public static void init() {
    register("1", FundRateLineView.class);
    register("2", FundCardContimuousWinHsView.class);
    ...
  }
}
  1. 使用方式

✅ 当新增卡片时,组内同事只需要在新的卡片类上引入 @FundSubView(type = "newType") 即可。无需改动、新增任何代码。

@FundSubView(type = "5")
class FundSubViewNew5 : View {
   ...
}

✅ 获取卡片逻辑在 CardFactory 中,仍然利用反射获取,并且无需维护 classMap 映射表 。

// CardFactory.kt
fun getCard(type: Int):Card?{
    val clazz = classMap[index] // 获取 class 信息。
    val c: Constructor<out Card>? = clazz?.getConstructor(Context::class.java)
    return c?.newInstance()
}

至此,小何通过自己的思考分析 ,结合组内同事们的帮助,终于完成了卡片的动态注入,从此以后再也不用担心修改现有代码啦。

3.4 场景进阶

小何利用 APT 对原有方案进行了比较大的改动。那么除了实现动态注入,还有其他的优点吗?

答案是有的,而且是实际中经常遇到的场景:跨组件调用。目前 Android 项目普遍采用组件化设计,在实现功能隔离的同时,也要额外考虑组件通信与交互。常用的组件通信框架 EventBus 以及阿里的组件路由框架 ARouter 其实现原理都依赖了 APT。同理,基于 APT 实现的卡片动态注入也是可以做到的,在文末的 Demo 中,我们实现了submodule 调用 App 中卡片的情景,实际开发中由于业务耦合,需要根据具体情况分析,但是原理殊途同归,大家有兴趣可以查看。

四 小结

本文从具体项目出发,通过小何与同事之间的对话呈现出实际开发中解决问题和技术演进过程。为了追求“绝对的自动化”,引出了本文的核心 : APT,然后利用此技术一步步实现卡片的动态注入框架。文章一方面给读者提供了从业务出发,对代码进行逐步优化从而衍生出框架的思路;另一方面团队成员在一起积极的对方案设计的讨论也能很好的调用起团队的技术氛围以及鼓励追求极致的匠心精神。

本文对APT 的应用还比较基础。很多其他的优秀设计,仍然需要读者们去研究学习,从而做到举一反三、学以致用。我们也希望通过本文介绍,激发大家的想象空间,帮助大家实现更多优秀的功能。

附:

Demo 地址

FundCardDemo

雪球 App 下载

AppDownLoad

参考文献

APT 实现简易的 FindViewById

Java Annotation

JavaPoet