AOP思想与插件化技术在安卓上的实践应用

对于大多数高级语言开发者,把一个文字描述的需求转化为代码时,需要从顶向下进行模块、类、方法的设计,去思考每个类的职责,去设计模块之间、类之间的接口,以保证后续业务变化时,可以尽可能地减少对存量代码的修改。在这个过程中,常用的都是面向对象的编程思维(Object Oriented Programming,OOP)。

但是,对于数据打点、日志输出、性能监测、权限申请、行为统计、数据缓存等特殊的场景,需要在工程的各个角落插入一些与本身业务逻辑相关性不高的模板代码,天然存在极强的入侵性。如何降低对已有代码结构的入侵?避免增加重复的模板代码?此时,一种更好的解决思维则是面向切面编程(Aspect Oriented Programming,AOP)。

AOP作为Spring的关键组件之一,在服务端开发中已经广泛应用。在安卓平台上也有一些AOP的工具框架,包括AspectJ,JavaSsist,ASMDex,DexMaker。本文则介绍了AOP的一些基本概念,然后结合使用AspectJ和dynamic feature插件化技术,设计开发一款安卓app可视化的时延测试工具。其中aspectJ解决类与方法级别的解耦问题,插件化则解决模块级别的解耦问题,结合两者使用最终将测试工具app与需要监测行为的app做到极致解耦的效果。


何为AOP?

下图是我们提到的问题场景,采用OOP的解决方案需要在多个类的不同方法中插入一些模板代码,此时我们面对的不是一个类或者一个对象,而是多个对象中的多个方法。编程过程就像把这些类切开,然后在每个切点插入一些模板代码。

AOP的解决方式就是让这些模板代码只在一处实现,但是多次注入。代码注入是解决问题的关键之处。根据注入的时机,可以将代码注入区分为:

  • 运行时注入:必须明确调用额外的代码,比如采用动态代理。这个可能不算真正意义上的代码注入。
  • 加载时注入:当Dalvik或者ART虚拟机加载class文件时进行修改,编织二进制文件或者dex文件。
  • 编译时注入:在编译过程中添加自定义的步骤,生成apk之前进行修改,编织源文件。


AOP的一些基本名词概念

  • JointPoint:代码连接点,注入代码的地方,如上图中的functionA。
  • PointCut:切点,声明切入规则,匹配连接点的一个表达式。可以指定具体的包名类名方法名,也可以是具有相似名称的方法集合,或者拥有相同注解的方法集合。
  • Advice:切面在切入点采取代码注入的操作方式,常用的有before、after、around,分别表示在切入点之前、之后、或者切入点前后均执行代码注入。
  • Aspect:切面,pointcut和advice组合成一个aspect,在aspectj中是用@Aspect注解的一个类。
  • Weaving:编织,将代码注入到切入点的整个过程。


实践应用

本案例来自于实际项目开发中碰到的问题。问题背景是app中从用户执行点击操作到最终展示结果概率性时延超标,需要分析中间哪些环节发生了问题,整个业务流程包括相机拍照、图片处理、网络请求、界面初始化、加载网页等多个耗时操作。通常做法是去抓取日志进行分析,但是发生超标的场景不稳定,只是概率出现,每次都要重复分析大量log,定位多个环节,效率低下。

这时候就希望有一款可视化工具,在用户操作的同时把各环节时延显示出来。将该问题场景简化为一个网页加载的demo。下图所示,点击app中的button,加载对应的网页,左上方的工具则会把网页加载各个资源的耗时显示出来。可以看出,从点击按钮到最终百度网页完成加载耗时773ms。


模块设计

Demo里模块设计如下图所示,将工具显示界面的代码全部放在插件apk中,使用一个service作为插件入口,service通过悬浮窗加载一个recyclerview,接收来自baseapk的消息,添加显示到recyclerview中。baseapk中包含插件入口类TimeLagPluginEntry,该类提供绑定插件入口service与发送消息的接口,入口类采用单例模式,方便调用。baseapk中的织入类TimePointAspect制定切点规则及织入的代码,最终在MainActiviy中标记好的连接点方法中插入sendMessage模板代码。


编码实现

引入aspectj,工程顶级build.gradle引入依赖。

dependencies {
    classpath 'com.android.tools.build:gradle:3.4.1'
    classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.8'
}

app module应用插件:

apply plugin: 'android-aspectjx'

添加编译依赖项:

compileOnly 'org.aspectj:aspectjrt:1.8.+'

新建一个注解,用于标记连接点,命名为TimePoint,应用范围为method,保留时间范围为运行时runtime。有两个属性方法,value用来表示当前发生的事件名称,比如用户点击、加载资源。type为0用来标识统计时延的起始点。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TimePoint {
    String value();
    int type();
}

然后在主程序中需要统计时延的方法上添加注解,按钮点击监听。

@TimePoint(value = "跳转淘宝", type = 0)
private void jumpTaoBao() {
    webView.loadUrl(TAOBAO_URL);
}

@TimePoint(value = "跳转京东", type = 0)
private void jumpJingDong() {
    webView.loadUrl(JD_URL);
}

@TimePoint(value = "跳转百度", type = 0)
private void jumpBaiDu() {
    webView.loadUrl(BAIDU_URL);
}

网页加载回调:

webView.setWebViewClient(new WebViewClient(){
    @TimePoint(value = "加载资源", type = 1)
    @Override
    public void onLoadResource(WebView view, String url) {
        super.onLoadResource(view, url);
    }

    @TimePoint(value = "加载完毕", type = 1)
    @Override
    public void onPageFinished(WebView view, String url) {
        super.onLoadResource(view, url);
    }
});

新建aspect织入类,其中* * (..)表示任意方法名、任意返回值、任意入参,被@TimePoint注解过的方法都执行代码注入。

@Pointcut("execution(@com.example.timelag.TimePoint * * (..))")
public void timePoint(){}

Advice设置为around,因为我们需要解析当前方法的注解,获取value和type。调用插件入口类TimeLagPluginEntry发送消息给插件。

@Around("timePoint()")
public void invokeTime(ProceedingJoinPoint proceedJointPoint){
    MethodSignature signature = (MethodSignature) proceedJointPoint.getSignature();
    Method method = signature.getMethod();
    TimePoint annotation = method.getAnnotation(TimePoint.class);
    if (annotation != null) {
        TimeLagPluginEntry.getInstance().sendMessage(annotation.value(), annotation.type());
    }
    try {
        proceedJointPoint.proceed();
    } catch (Throwable e) {
        e.printStackTrace();
    }
}

插件入口类TimeLagPluginEntry采用单例模式,提供bingdservice和sendmessage两个接口,由于baseapk不依赖插件apk,service类要通过类名反射获取。若插件apk已经安装,classloader的path路径会包含插件apk的路径,即可正常启动插件service。若未安装插件,无法绑定service,messenger为空,sendmessage方法什么操作也不执行,代码如下。

public class TimeLagPluginEntry {
    private Messenger serviceMessenger = null;

    private static final TimeLagPluginEntry entry = new TimeLagPluginEntry();

    private TimeLagPluginEntry() {
    }

    public static TimeLagPluginEntry getInstance() {
        return entry;
    }

    private ServiceConnection serviceConnection = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            Log.i(TAG, "onServiceConnected");
            serviceMessenger = new Messenger(service);
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {
            Log.i(TAG, "onServiceDisconnected");
        }
    };

    public void bindService(Context context) {
        try {
            Intent intent = new Intent(context, Class.forName(TIME_LAG_ENTRY_SERVICE));
            context.bindService(intent, serviceConnection, Service.BIND_AUTO_CREATE);
        } catch (ClassNotFoundException e) {
            Log.e(TAG, e.getMessage());
        }
    }

    public void sendMessage(String actionName, int actionType) {
        Log.i(TAG, "sendMessage");
        if (serviceMessenger == null) {
            return;
        }
        Message message = Message.obtain();
        message.what = actionType;
        Bundle bundle = new Bundle();
        bundle.putString(ACTION_NAME, actionName);
        bundle.putLong(ACTION_TIME, System.currentTimeMillis());
        message.setData(bundle);
        try {
            serviceMessenger.send(message);
        } catch (RemoteException e) {
            Log.e(TAG, e.getMessage());
        }
    }
}

插件内部代码就是service拉起一个悬浮窗,悬浮窗内加载一个recyclerview,都是常规代码,不再详细描述。


打包效果

模块设计将时延测试工具代码使用google的dynamic feature特性来实现,因此要先使用bundle命令打包aab,然后再用bundletool打包apks文件。打包apks命令如下:

java -jar tools/bundletool-all-1.4.0.jar build-apks --bundle app/build/outputs/bundle/release/app.aab --output app/build/outputs/bundle/release/TimeLag.apks

打包apks命令放在工程signapks.bat脚本里,也可以直接执行该脚本。最终打包好的apks文件,解压之后可以看到包含两个apk,base-master.apk与timelagutil-master.apk。baseapk就是我们的项目工程,timelagutilapk则是测试工具插件apk。对外发布可以只选择项目apk,内部测试可以同时安装插件apk。

回顾一下,我们把插件工具全部打包在timelagutil-master.apk中,未对原工程产生任何影响。原工程仅新增了三个类,注解类、织入类、插件入口类。MainAcitivity仅依赖注解类,但是注解类中不包含任何代码。新增的织入类和插件入口类包含代码,但是与原有的MainActivity无任何依赖关系。这样就达到了对原有存量代码的最小入侵,实现极致解耦的效果。


原理探索

源码中我们跳转百度按钮事件监听会执行jumpBaidu方法,相关代码如下所示:

@TimePoint(value = "跳转百度", type = 0)
private void jumpBaiDu() {
    webView.loadUrl(BAIDU_URL);
}

但是我们拿到生成的apk,反编译查看,发现jumpBaidu方法被修改为:

@TimePoint(type = 0, value = "跳转百度")
private void jumpBaiDu() {
    JoinPoint makeJP = Factory.makeJP(ajc$tjp_1, this, this);
    jumpBaiDu_aroundBody3$advice(this, makeJP, TimePointAspect.aspectOf(), (ProceedingJoinPoint) makeJP);
}

被替换为一个静态方法,其中先执行了插入的代码逻辑。

private static final /* synthetic */ void jumpBaiDu_aroundBody3$advice(MainActivity ajc$this, JoinPoint thisJoinPoint, TimePointAspect ajc$aspectInstance, ProceedingJoinPoint proceedJointPoint) {
    TimePoint annotation = (TimePoint) ((MethodSignature) proceedJointPoint.getSignature()).getMethod().getAnnotation(TimePoint.class);
    Log.i(TimePointAspect.TAG, "" + annotation);
    if (annotation != null) {
        TimeLagPluginEntry.getInstance().sendMessage(annotation.value(), annotation.type());
    }
    try {
        jumpBaiDu_aroundBody2(ajc$this, proceedJointPoint);
    } catch (Throwable e) {
        e.printStackTrace();
    }
}

又调用了aroundBody2这个方法,这里才是最初我们编写的代码实现。

private static final /* synthetic */ void jumpBaiDu_aroundBody2(MainActivity ajc$this, JoinPoint joinPoint) {
    ajc$this.webView.loadUrl("www.baidu.com");
}


遗留问题

开发过程中发现gradle7.3.3和aspectj并不太兼容,报错如下:

   An exception occurred applying plugin request [id: 'android-aspectjx']

        > Failed to apply plugin 'android-aspectjx'.

> No such property: FD_INTERMEDIATES for class:com.android.builder.model.AndroidProject

google也没找到解决方法,参考其他demo之后,笔者最终是通过把工程gradle降级到6.1.1来解决该问题。

最后

这里也分享一些珍藏资源,面试简历模板到大厂面经汇总,从大厂内部技术资料到互联网高薪必读书单,以及Android面试核心知识点(844页)和Android面试题合集2022年最新版(354页)等等,这些资料整理给大家,希望踩过的坑不要再踩,遭遇的技术瓶颈一次性消灭。

如果需要的话,可以顺手帮我点赞评论一下,直接私信我【笔记】免费领取!

Java部分,像序列化、注解、泛型、反射、JVM、编译时、动态代理等等,都是非常重要的,尤其是越往上走越重要,在大厂中是必问的版块,很多中小厂以及校招也会着重考量Java基础

Kotlin部分,刚推出的时候大家都不太愿意学习,现在官方新文档、Sample代码、大厂面试、实际工作都已经纷纷转向Kotlin了,作为官方主推的语言,国外基本都已经转换过来了,但国内稍显慢半拍。一直到现在,Kotlin已经是一个很明显的趋势了,很多新技术都需要结合Kotlin一起使用,还不上车就晚了。

Jetpack+Compose,Jetpack可以让我们可以摆脱不断造轮子抄轮子的窘境,而Compose作为Google I/O 2019 发布的新的声明式的UI框架,目前API已经稳定,构建、预览等开发体验也已经趋于完整,新的声明式UI开发也已是共识,必将是日后App极为重要的编程方式。

Framework,作为框架层,给我们提供了很多的API,但很多机制都是封装好直接用的,如果不深入了解原理的话,很难在这基础上进行优化。Framework的学习不是一蹴而就的,但是当你慢慢理解的时候,就会发现很多日常工作中的问题都迎刃而解了。更何况,兄弟们,面试必问啊!!!

也可以继续向下,Framework开发、SDK开发,不过岗位会比应用要少一点,不过薪资和稳定性会更好一点

如果需要的话,可以顺手帮我点赞评论一下,直接私信我【笔记】免费领取!

性能优化,这块就是软件工程的深水区,也是衡量一个开发技术水平的标准。因为想要搞懂性能优化,必须对各种底层原理有着很深的了解,各种case要有非常丰富的经验,不管是APP从0到1还是从1到N,都离不开性能优化。也是面试中最容易考验出个人技术功底的部分

很多大厂的性能优化专家,真的是可以当大爷……

开源框架+架构设计,各个开源框架,除了会用之外还要主动学习其底层原理、设计思想,一方面是因为面试中经常会问到,一方面也是因为在大厂中,很容易遇到需要自己写框架的情况。相关的原理以及对架构、设计模式的理解,在高工岗是不可或缺的。

退可高工,进可架构,但作为架构师的话,对于知识的广度又有要求了

车载最近很火的细分领域,也可以说是Android的又一春,对于底层要求会更高一点,涉及Framework固件烧写、System UI、桌面程序、底层 Window Display、底层协议USB通信、硬件以及串口通信、蓝牙通信

身边也有转车载的,薪资很香!

音视频,这块自疫情那段时间就突然走上了风口,一方面是突发事件带来的风口,一方面也是5G的带宽带来更好的体验,各厂纷纷入局,但由于音视频这块自学比较困难,很难招到合适的候选人,自然薪资也是水涨船高

涉及C/C++、JNI、H.264、H.265、OpenSL、OpenGL、编解码、网络协议、WebRTC、FFmpeg、IJKPlayer、librtmp等等

跨平台开发,跨平台框架主要解决的是UI和部分业务逻辑的跨平台,和平台相关的比如蓝牙、平台交互、数据存储、打包构建等都离不开原生支持。

所以跨平台和原生是共生的关系,如果原生都没了,我们还跨个der?该不会跨WinPhone吧?

跨平台作为一个老生常谈的问题,主要是增加代码复用,减少我们对多个平台差异适配的工作量,降低开发成本(可能主要是为了企业降本增效~)

尤其是在中小厂,成本有限的情况下,或许会更加倾向于原生开发掌握Flutter的情况

很多大厂也都已拥抱Flutter,掌握Flutter不仅可以帮助到面试,也可以拥抱跨端开发

如果需要的话,可以顺手帮我点赞评论一下,直接私信我【笔记】免费领取!

发表评论
留言与评论(共有 0 条评论) “”
   
验证码:

相关文章

推荐文章