还能这么用?骚操作玩这么花?Android基于Act实现事件的录制与回放

1,792 阅读9分钟

基于Activity封装实现录制与回放

前言

在前文中我们通过 ViewGroup 实现过自己的录制与回放,但是那只是用于复(学)习,并不能真正在实际开发中应用上,或者说能用但是不好用需要大量的修改,

而大厂实现的录制与回放方案有很多种但大多都没有开源。一般在大厂会对应用的稳定性进行监控,不管是测试还是线上监控,都离不开用户操作的录制与回放。

一个 App 开发完成上架之后,一般我们会收集用户设备的内存帧率,崩溃信息,ANR信息等,这些都是基操,但是现在平台会提出了更高的要求,录制用户操作与回放用户操作,很多大厂都在进行这方面的探索。

目前业内做的比较好的录制与回放稳定性平台搭建包括不限于美团,爱奇艺,字节,网易,货拉拉等。

不同于测试阶段可以用 PC + ADB 实现录制与操作的思路,在应用内部我们就需要预先埋点用户的事件操作与回放逻辑,并且生成对应的日志信息。

那么实现录制与回放有哪些方法?哪一种更方便呢?本文只是探讨一下基于 Activity 实现的,比较简单的、比较基本的录制与回放功能,方便大家参考。

当然本文只是基于 Demo 性质,只用于本机录制本机回放,如果真要做到兼容多平台多设备,如需要ORC文本识别与图片识别进行定位,屏幕大小适配坐标等其他一系列的深入优化就不在本文的探讨范围。其实只要实现了核心功能,其他都是细枝末节需要时间打磨。

那么话不多说,Let's go

300.png

一、定义事件

在前文 ViewGroup 的文章中,我们知道了事件的伪造与保存,如何定制伪造事件时间轴,如何分发伪造事件,本文也是一个思路。

整体思路基于前文 ViewGroup 的例子,还是把事件用对象封装起来,只是我们封装的对象换成了 MotionEvent ,并且不需要修改内部的操作时间了,我们用事件对象的 time 时间来制作伪造事件触发的时间轴。

这样对于事件的录制我们就能直接通过 Activity 的事件分发 dispatchTouchEvent 中直接保存我们的事件对象了。

基于这个思路,我们的事件的对象封装:

public class EventState {
    public MotionEvent event;  //事件
    public long time;  //开始录制到该事件发生的时间
}

Activity的事件集合,方便后期扩展为多个Activity的事件队列,如果只需要录制一个 Activity 的事件那么则可以无需双重队列。

/**
 * 以Activity为单位,以队列的形式存储MotionEvent
 */
public class ActEventStates {
    /**
     * 存储元素为一个队列,存放一个Act中的操作状态。如果有多个Act,则是双重队列
     */
    public static Queue<Queue<EventState>> eventStates = new LinkedList<>();

    public static boolean isRecord = false;  //是否在录制

    public static boolean isPlay = false;    //是否在播放
}

为什么要用 Queue ?

首先我们只需要回放一次,如果想回放多次可以用持久化存储,对于已经回放过的事件我们不希望还存在内存中,特别是后期做多 Activity 之间的跳转之后的回放,如果之前的事件还存在内存中会有重复回放的问题,而用 List 去手动管理没有 Queue 方便。

二、录制

先定义一个开始与停止的方法:

  //开启录制
    fun startRecord() {
        //如果是录制状态
        if (ActEventStates.isRecord) {
            ActEventStates.isPlay = false

            //初始化队列,对应一个Act是一个队列
            activityEvents = LinkedList()
            // Act录制事件的开始时间
            startTime = System.currentTimeMillis()
            //保存到内存中
            ActEventStates.eventStates.add(activityEvents)
        }
    }

    //停止录制
    fun stopRecord() {
        val state = EventState()
        state.event = null
        state.time = System.currentTimeMillis() - startTime
        activityEvents?.add(state)
    }

基于Act的录制,直接在分发事件的时候把事件从 Activity 级别就录制进去,这样只要在 Activity 层级之下的操作都能实现录制与回放了:

    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        //只有在录制状态下才会保存事件并添加到队列中
        if (ActEventStates.isRecord && activityEvents != null) {
            //不要直接存原始的 MotionEvent,因为用过就回收的,之前我们是通过自定义 Event 来做的,这里简单一点直接重新伪造一次其实更方便
            val obtain = MotionEvent.obtain(ev)
            //初始化自己的 EventState 用于保存当前事件对象
            val state = EventState()
            //赋值当前事件,用伪造过的事件
            state.event = obtain
            //赋值当前事件发生的时间
            state.time = System.currentTimeMillis() - startTime
            //把每一次事件 EventState 对象添加到队列中
            activityEvents?.add(state)
        }
        return super.dispatchTouchEvent(ev)
    }

每一行代码都尽量给出注释。

三、回放

其实和我们之前的 ViewGroup 的思路是一致的,只是把自定义的事件换成原生的 MotionEvent 来保存,还是根据 Handler 分发不同事件的时间轴。

    //回放录制
    fun playRecord() {
        //如果是播放状态
        if (ActEventStates.isPlay) {
            ActEventStates.isRecord = false

            //延时1秒开始播放
            handler.postDelayed({
                Thread {
                    if (!ActEventStates.eventStates.isEmpty()) {
                        //遍历每一个Act的事件,支持多个Act的录制与回放
                        val pop = ActEventStates.eventStates.remove()
                        while (!pop.isEmpty()) {
                            val state = pop.remove()
                            //根据事件的时间顺序播放
                            handler.postDelayed({
                                if (state.event == null) {
                                    YYLogUtils.w("没了,回放录制完成")
                                } else {
                                    dispatchTouchEvent(state.event)
                                }
                            }, state.time)

                        }
                    }
                }.start()
            }, 1000)
        }
    }

在当前的 Activity 中录制与回放的效果,具体的使用与效果:

    startRecode.click {
        ActEventStates.isRecord = true
        toast("开始录制")
        startRecord()
    }

    endRecode.click {
        ActEventStates.isRecord = false
        toast("停止录制")
        stopRecord()
    }

    //点击回放
    btnReplay.click {
        ActEventStates.isPlay = true
        toast("回放录制")
        playRecord()
    }

act_record01.gif

单独的 Activity 上录制与回放是可以了,但是我们的应用又不是 Compose 或 Flutter,我们大部分项目还是多 Activity 的,如何实现多 Activity 跳转之后的录制与回放才是真正的问题。

四、多Activity的录制与回放

由于我们之前定义的数据格式就是 Queue 队列,所以我们很方便的就能实现多 Activity 的录制与回放效果,只需要在每一个 Activity 的 onResume 方法中尝试录制与播放即可。

由于当前的 Queue 的数据格式的性质,回放完成之后就没有了,跳转 Activity 之后就无需从头开始播放,特别适合这个场景。

只是需要注意的点是 Activity 的返回除了 Appbar 的页面返回按钮点击,我们还能使用系统的返回键或国产OS的左侧右侧滑动返回操作,所以我们需要对系统的返回操作单独做处理,修改之后的核心代码如下:

abstract class BaseActivity<VM : BaseViewModel> : AbsActivity() {

    ...

    // ================== 事件录制 ======================

    var handler = Handler(Looper.getMainLooper())

    /**
     * 存放当前activity中的事件
     */
    private var activityEvents: Queue<EventState>? = null

    /**
     * 当前activity可见之后的时间点,每次 onResume 之后都创建一个新的队列,同时也赋值新的statetime
     */
    private var startTime: Long = 0


    override fun onResume() {
        super.onResume()
        startRecord()  //尝试录制
        playRecord()  //尝试回放
    }

    //开启录制
    protected fun startRecord() {
        //如果是录制状态
        if (ActEventStates.isRecord) {
            ActEventStates.isPlay = false

            //初始化队列,对应一个Act是一个队列
            activityEvents = LinkedList()
            // Act录制事件的开始时间
            startTime = System.currentTimeMillis()
            //保存到内存中
            ActEventStates.eventStates.add(activityEvents)
        }
    }

    //停止录制
    protected fun stopRecord() {
        val state = EventState()
        state.event = null
        state.time = System.currentTimeMillis() - startTime
        activityEvents?.add(state)
    }

    override fun onBackPressed() {
        val state = EventState()
        state.event = null
        state.isBackPress = true
        state.time = System.currentTimeMillis() - startTime
        activityEvents?.add(state)
        super.onBackPressed()
    }

    //回放录制
    protected fun playRecord() {
        //如果是播放状态
        if (ActEventStates.isPlay) {
            ActEventStates.isRecord = false

            //延时1秒开始播放
            handler.postDelayed({
                Thread {
                    if (!ActEventStates.eventStates.isEmpty()) {
                        //遍历每一个Act的事件,支持多个Act的录制与回放
                        val pop = ActEventStates.eventStates.remove()
                        while (!pop.isEmpty()) {
                            val state = pop.remove()
                            //根据事件的时间顺序播放
                            handler.postDelayed({
                                if (state.event == null) {
                                    if (state.isBackPress) {
                                        YYLogUtils.w("手动调用系统返回按键")
                                        onBackPressed()  //手动调用系统返回按键
                                    } else {
                                        YYLogUtils.w("没了,回放录制完成")
                                    }

                                } else {
                                    dispatchTouchEvent(state.event)
                                }
                            }, state.time)

                        }
                    }
                }.start()
            }, 1000)
        }
    }

    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        //只有在录制状态下才会保存事件并添加到队列中
        if (ActEventStates.isRecord && activityEvents != null) {
            //不要直接存原始的 MotionEvent,因为用过就回收的,之前我们是通过自定义 Event 来做的,这里简单一点直接重新伪造一次其实更方便
            val obtain = MotionEvent.obtain(ev)
            //初始化自己的 EventState 用于保存当前事件对象
            val state = EventState()
            //赋值当前事件,用伪造过的事件
            state.event = obtain
            //赋值当前事件发生的时间
            state.time = System.currentTimeMillis() - startTime
            //把每一次事件 EventState 对象添加到队列中
            activityEvents?.add(state)
        }
        return super.dispatchTouchEvent(ev)
    }
}

对于事件的封装我们添加了是否是系统返回的标记:

public class EventState {
    public boolean isBackPress;
    public MotionEvent event;  //事件
    public long time;  //开始录制到该事件发生的时间
}

使用的方式就没有变化,我们添加几个 Activity 的跳转试试:

    startRecode.click {
        ActEventStates.isRecord = true
        toast("开始录制")
        startRecord()
    }

    endRecode.click {
        ActEventStates.isRecord = false
        toast("停止录制")
        stopRecord()
    }

    //点击回放
    btnReplay.click {
        ActEventStates.isPlay = true
        toast("回放录制")
        playRecord()
    }

    btnJump1.click {
        TemperatureViewActivity.startInstance()
    }
    btnJump2.click {
        ViewGroup9Activity.startInstance()
    }

效果:

act_record02.gif

为了区分实际手指操作与回放的操作的差异,我打开了开发者选项中的触摸反馈,第一次效果是带触摸反馈的,回放录制的效果是没有触摸反馈的,并且支持 Appbar的返回按键与系统的返回键。

如果想回放多次,则需要在停止录制的时候把事件保存到本地,如何保存对象到本地?和前文一样的思路,可以用Json,可以压缩,可以加密,甚至可以自定义数据格式与解析,这一个步骤就无需我多说了吧。

后记

回到前文,虽然自动化测试中我们常用到录制与回放的功能,但是对于线上的监控与云真机回放对于的操作,其实与类似Python自动化脚本还是有区别,与 PC + ADB 的方式也有区别,基于App本身实现的可以更好的用于线上的稳定性监控。

当然了由于本文是实验性质并不完善,浅尝辄止,只是提供一个思路,真要实现完整的功能并不是一个人短时间能搞出来的,如果你想要实现类似的功能可以参考实现。

比如后期如我们需要区分事件类型,点击的文本与图标,使用文本或图片识别进行定位,输入框的适配,等等一系列的功能并不是那么的容易还有很长的路要走,想起来都头皮发麻。

好了,关于最基础的功能来说的话,本机的 App 应用的录制与回放就讲到这里,那么除此方式之外还有哪些更方便的实现方式呢?我也很好奇,也欢迎大家交流讨论哦!

而对于本机其他第三方 App 应用的录制与回放又有哪些方式实现呢?这又是完全不同的另一个故事了。

言归正传,关于本文的内容如果想查看源码可以点击这里 【传送门】。你也可以关注我的这个Kotlin项目,我有时间都会持续更新。

惯例,我如有讲解不到位或错漏的地方,希望同学们可以指出交流。

如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力啦!

Ok,这一期就此完结。