Android 组件化实践 - 回溯 设计 实现

Posted Nipuream

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android 组件化实践 - 回溯 设计 实现相关的知识,希望对你有一定的参考价值。

android 组件化实践 - 回溯 设计 实现

回溯

由于去年滴滴频繁出事,国家对出行安全相关的事情非常关注,并且制定了一些行业的标准,用于保障出行安全。我们团队做的是车载智能设备,将国家监管平台和出租车业务串联起来,包括实现平台取流预览对车辆实时监控,还有对历史视频的存储,对司机违规操作图片上传至平台(人脸识别处理),还有对接一些外设相关的业务,包括计价器、顶灯、安全模块,有线报警按钮,可以将运营数据上传至平台,也可以做一些网约车相关的业务。另外还有个MCU处理器,一个对车载设备电源的管理,另外有些城市的监管部门并不使用计价器来计价,而是使用车身CAN数据来使用软件计算每次载客之后的运营数据,和结算相关的逻辑。整体的架构图如下:

其中平台和车载设备的通信,传输控制协议是根据不同地级市监控平台决定的,就目前而言,我们做的项目中,有TCP通信的、UDP通信的、MQTT通信的;传输协议不同地级市可能也会不同,这就涉及到解析逻辑的改动,外设通信相关的协议也是不同厂家自己定制不同的协议的。Recorder模块和MCU相关协议是我们制定的,这部分我们也可以控制不变。

下图是我们之前的软件实现架构:

之前将和平台相关功能代码、业务逻辑放在一个进程去处理,例如平台的连接、通信、解析、粘包断包的处理等等。业务逻辑放在一个进程去处理,例如司机的刷卡签到、注册人脸识别算法、各种违规运营的报警判别逻辑又放在Business进程中去处理。另外还有三个进程分别用于和不同的硬件模块去通信处理、其中Recorder和MCU相关是我们这边去定义的,可以保持不变,External Device 主要和不同的外设厂商对接,用于解析不同的协议。其中他们的通信方式都是通过binder来进行跨进程通信的。

这种软件实现架构确实是将平台相关的逻辑和本地相关的处理逻辑给区分开了,但是缺点也十分明显。

  • 耦合性特别的强,为什么耦合性强呢?因为你需要定义彼此间的通信接口呀,不同厂商协议不同,不同平台协议不同,不同业务的逻辑实现,必定导致接口的不同、数据类型定义的不同。
  • 基础业务功能很难达成沉淀,每次有新的项目,负责项目的同事就去切分支,去改Protocol、Business、External Device项目代码。其实有的功能需求是可以沉淀下来,其他项目也可以直接调用的,但是不同的切分支,不同同事负责不同的项目,很难知道彼此之间实现了什么功能。
  • 对新人接手极其不利,随着业务增长越来越快,分支越来越多,需要维护一张excel表格,描述不同项目的分支名,功能需求等,最后需要实现一样的功能,切到别人的分支移植代码也需要看很久才能移植,搞不好就会出现错误,增加了排查的成本。

设计

思考

其实个人觉得一个好的软件架构必定是解决项目中的痛点,并非是为了炫技亦或者随波逐流。像MVP、MVVM这些架构提供的只是一种思路,并非是最终的解决方案。例如,项目很小很简单,非要用个MVP去实现,白白写那么多接口,我觉没有必要;或者,项目中和UI交互的比较少,你非要用MVVM这种架构去实现,这不是硬套么?

就像我们的项目中,平台相关的业务逻辑和本地的业务逻辑确实达到了解耦。但是在这里并非单单解耦就能解决问题所在,这个项目最主要的是将不变的部分和变的部分区分开来,并非不变的部分能够沉淀下来,提高代码的复用率;变的地方通过切分支的方式来解决,这部分往往是根据客户需求来变化的,然后根据客户需求的实现来决定是否要将这部分实现沉淀下去。这样最终的目的就是不变的部分,或者以后可能用的到的地方沉淀下来,用不到的地方通过切分支,使用壳app来写相关的代码。

最终我想到的是用组件化的方案来搭建我们的架构,整体架构图如下:

其中壳APP就是每次客户需求改变需要切分支改变的地方,我们车载设备有车机,云镜产品,不同芯片也会有不同的芯片提供商的,所以访问底层的能力集也是不同的,Device platform SDK 和 Android Platform 这部分代码很可能是会变的。Library部分都是我们提供的组件,下面来简单的介绍下:

  • platform 平台组件为了适配和兼容不同平台传输协议,例如tcp,udp,mqtt等。
  • protocol 平台协议组件为了兼容不同地级市平台协议,例如标准905协议,昆明市905协议等等,并且向外提供统一的接口,不会随协议改变而改变接口,不同的地方由此组件完成。
  • external protocol 外设协议组件,为了兼容外设协议不同,例如不同厂家计价器、顶灯等。
  • data 组件向外提供数据源、统一接口,屏蔽不同进程调用的差异性。
  • business 业务组件,基础业务的沉淀,方便快速定制。
  • compatiblesdk 适配不同平台sdk接口的差异性,保证业务组件代码的不变性,支持多平台运行。
  • HikRouter 是为了不同组件向壳app提供的接口,数据统一提供组件,各组件不互相引用。

模拟开发

我们可以继续模拟下我们的开发流程,壳App通过HikRouter组件通过初始化platform组件,并告诉它我使用哪种传输控制协议进行和平台交互就成功完成了和平台之间的交互,如果下次又有另外的交互协议,只需要在platform组件中拓展即可;接着通过HikRouter注册 protocol向外提供的回调接口(这些接口一旦定制好了就不会改变),告诉我平台需要我干什么,预览取流还是拍照;external protocol 也是同理;data 是通过 contentprovider向外提供统一的接口; 如果平台需要我们定时上传gps数据,我们只需要调用business组件的接口即可。

那如何做到接口不变的呢?我们通过接口入参传入json 来解决的,json串的好处是我可以定义满足所有需求的入参,对接的某个平台,只需要传入我们这种场景下参数即可,具体实现是交给组件去完成的,组件负责对所有参数的细分控制以及实现,对应business组件也是如此,定义了某种基础业务组件的场景,并且提供了大部分参数,调用者只需要传入你想要实现得参数即可,由业务组件帮你实现。这可能不太好懂,就举个例子,我想要我业务组件business帮我完成对图片的处理,参数有 长宽,缩放比,品质,分辨率,如果我只是想要对长宽进行裁剪,就传入长宽就可以了,其他的并不用输入,那么我业务组件接受到参数后,如果对应的参数没有传入,我就不处理即可。

实现难点

  • 传入json来解决不同数据类型来保证接口的唯一性,想法确实是很好,但是如何让组内其他人知道json参数中类型的定义呢?
  • 随着业务的逐渐增长,协议解析组件和business业务组件逐渐增大,会增大apk体积,影响开机速度和升级增量包的体积,如何解决呢?

见下文实现。

实现

可视化接口参数

./gradlew clean
./gradlew assembleRelease

在公司服务器环境下在宿主app的路径下执行此命令,可动态生成各接口路由表,所有接口的md文档,如图所示:

通过路由表可快速了解组件接口的调用方式和入参,返回值等信息。笔者为了更加方便组内成员阅读,使用c++写了个工具,将路由表的内容生成md文件,在 执行assembleRelease task 之后执行这个工具,具体的实现我已经提交到github上,感兴趣可以看看 自动生成文档

也可通过安装android studio 插件阅读md文档,如果使用window环境下的Typora软件可方便转为pdf文档阅读:

从文档上,我们可方便的知道应该调用 IRecorder接口的imageRetrive方法就可以完成根据时间范围检索图片文件的业务场景,入参和返回类型也一目了然。

动态注入

可以根据文档中需要的接口,利用注解 router自动帮我们注入实例,不需要调用繁杂的接口方法。如下图所示:

@Autowire(path = IRecorder.PATH)
IRecorder recorder;

@Override
protected void onCreate(Bundle savedInstanceState) 
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    //inject 才能动态注入 recorder
    HikRouter.inject(this);


/**
     * 测试代码:
     * 直接调用 业务组件 业务场景
     * @param view
     */
public void queryImage(View view)

  if(recorder == null)
    Printer.info(TAG, "recorder is null, inject failed.");
    return ;
  

  //...填充路由表中需要的参数
  maps.put(RecorderDefine.ImageRetrive.startTime,  startTime);
  maps.put(RecorderDefine.ImageRetrive.endTime, System.currentTimeMillis());
  // invoke.
  recorder.imageRetrive(new JSONObject(maps).toJSONString(), new BaseCalB<FileRetriveModelB>() 

    @Override
    public void response(String result) 
      FileRetriveModelB modelB = JSON.parseObject(result, FileRetriveModelB.class);
      Printer.info(TAG, "model : "+ modelB.toString());
    
  );

动态加载

由于组件化特性,我们是面对接口编程,我们壳app中并不需要完成对其他组件的依赖便可完成编译。所以可以在宿主app刚启动的时候动态加载其他组件的代码。

public class ProtocolApp extends RouterApp 

    @Override
    protected void attachBaseContext(Context base) 
        super.attachBaseContext(base);
        try 
            //load plugin dex.
            Log.i("ProtocolApp", "####### START LOAD PLUGIN ##########");
            HikRouter.loadPlugin(this.getClassLoader(), this,
                    HikRouter.PLUGIN_FLAG.DATA_PLUGIN
                            | HikRouter.PLUGIN_FLAG.COMPATIBLE_SDK_PLUGIN
                            | HikRouter.PLUGIN_FLAG.BUSINESS_PROCESSOR_PLUGIN);
         catch (Exception e) 
            e.printStackTrace();
        
    

    @Override
    public void onCreate() 
        super.onCreate();
        Log.i("ProtocolApp", "!!!  HOST APPLICATION ONCREATE !!!");
    


这样做的目的是因为,随着项目的越来越多,定制化需求,基础业务的沉淀越来越庞大,依赖这些组件完成编译打包,会影响到app的大小。所以这些组件完成可以通过dx工具生成dex文件,在运行时完成注入。只要在宿主app的工作目录下执行完以下命令即可动态生成dex文件,所有的工作脚本都已经帮我们完成。

./gradlew clean
./gradlew assembleRelease

生成的dex文件路径:

在调试过程中,只要在window环境下双击install_dex.bat,然后重启应用即可完成更新组件,在打包的过程中,需要将dex组件置于系统镜像中,然后应用动态加载。

如何使用

初始化router

//初始化router.
HikRouter.initRouter();

壳app在调用此方法后,将会查找各个组件实现类的具体路径,为反射生成实例类做准备。

初始化数据库

//创建数据库,需要AndroidManifest.xml中创建 contentprovider 结点
HikRouter.installDb();

//需要在清单文件中注明 DataProvider
<provider
  android:name="com.hikvision.auto.data.DataProvider"
  android:authorities="com.hikvision.auto.dataprovider"
  android:exported="true" />

如果想要在此壳app中创建数据库,并维护数据,调用此方法。需要注意的是,此宿主app必须在清单文件中注明 DataProvider,然后其他任何地方,任何进程想要获取数据均可通过路由表和data组件提供的方法获取。

调用方式:

@Autowire(path = IData.PATH)
private IData DATA;

@Override
public boolean settingPhoneBook(String json) 

try
    //处理数据
    ContentValues values = new ContentValues();
    values.put(DataDefine.Contact.CONTRACT, peopleName);
    values.put(DataDefine.Contact.FLAG, flag);
    values.put(DataDefine.Contact.PHONE_NUMBER, phoneNumber);
    DATA.insertOrUpdate(Uri.parse("content://com.hikvision.auto.dataprovider/contact"),values,
    DataDefine.Contact.PHONE_NUMBER + " = ?", new String[]phoneNumber);
    Printer.info(TAG,"Insert new people in contract table." + ", flag : "+ flag + ", peopleName : "
    + peopleName + ", phoneNumber : "+ phoneNumber);

catch (Exception e)
e.printStackTrace();
return false;

return true;

接口调用

根据路由表的接口定义,参考 实现-动态注入 使用。

如何拓展

定义接口

/**
 * 行车记录仪相关处理的业务接口
 */
public interface IRecorder 

    /**
     * 生成对应路由表位置,也可根据此路径 HikRouter 自动给接口注入实例
     */
    String PATH = "business/recorder";

    /**
     * 存储图片检索
     * @param json
     * @param cal 图片检索回调
     */
    void imageRetrive(String json, BaseCalB<FileRetriveModelB> cal);

    /**
     * 拍照
     * @param json
     * @param cal
     */
    void takePhoto(String json, BaseCalB<TakePhotoModelB> cal);

    /**
     * 下载文件
     * @param json
     * @param cal
     */
    void downloadFile(String json, BaseCalB<DownLoadModelB> cal);

    /**
     * 司机注册
     * @param json
     */
    void driverRegister(String json, BaseCalB<FaceRegisterModelB> cal);


每个接口中的json数据表示入参,就是路由表中生成的入参,这里先不管,BaseCalB 是返回的参数,表明此接口处理完之后返回的数据。主要注意的是业务组件会根据返回类型类决定此方法调用是同步调用还是异步调用,如果是void类型的接口方法,业务组件会切换一条单独的线程处理,返回结果通过BaseCalB 来返回数据,如果是非void类型的接口方法,会使用调用线程一直阻塞完成,注意不要出现ANR。

定义业务场景

path: com.hikvision.auto.router.business.recorder.RecorderDefine.java

    @Describe(value = "根据时间范围检索图片文件",
              invoke = "com.hikvision.auto.router.base.business.recorder.IRecorder.imageRetrive",
              path = IRecorder.PATH,
              returnType = "com.hikvision.auto.router.base.business.recorder.model.FileRetriveModelB.class")
    public static class ImageRetrive 
        @TypeDefine(value = "Integer", define = "流水号")
        public static String serial = "serial";  //(int)
        @TypeDefine(value = "Integer", define = "摄像头ID")
        public static String cameraId = "cameraId"; //(int)
        @TypeDefine(value = "Integer", define = "拍照原因")
        public static String reason = "reason"; //(int)
        @TypeDefine(value = "Long", define = "开始时间")
        public static String startTime = "startTime"; //(String)
        @TypeDefine(value = "Long", define = "结束时间")
        public static String endTime = "endTime"; //(String)
    

这是在 RecorderDefine.java 中定义的一种业务场景,意思是想通过调用IRecorder的 imageRetrive方法,完成 “根据时间范围检索图片文件”的操作,下面分别描述注解的作用:

@Describe:

  • value 描述业务场景,越详细越好
  • path 路由表路径,执行assembleRelease task,会自动在此路径下生成路由表
  • invoke 想要执行此业务场景所调用的接口方法
  • returnType 返回也是json字符串,可根据fastjson和 returnType 反序列化成对象。

@TypeDefine:

  • value 此字段的类型
  • define 字段解释
  • repeat jsonArray的name

最后

大体上就是这样,可能实现细节也不可能三言两语说清,本文实现的技术一部分上传了github,有兴趣的同学可以去看看。

HikRouter

以上是关于Android 组件化实践 - 回溯 设计 实现的主要内容,如果未能解决你的问题,请参考以下文章

Android组件化框架设计与实践

Android组件化路由实践

Android 组件化最佳实践 ARetrofit 原理

《移动项目实践》实验报告——Android数据存储

Android自己定义组件系列——进阶实践

Android组件化探索与实践