查看原文
其他

骚操作玩这么花的吗?基于Activity实现行为录制与回放!

Newki 鸿洋
2024-08-24

本文作者


作者:Newki

链接:

https://juejin.cn/post/7330104253825646601

本文由作者授权发布。


在前文中我们通过 ViewGroup 实现过自己的录制与回放,但是那只是用于复(学)习,并不能真正在实际开发中应用上,或者说能用但是不好用需要大量的修改,
而大厂实现的录制与回放方案有很多种但大多都没有开源。一般在大厂会对应用的稳定性进行监控,不管是测试还是线上监控,都离不开用户操作的录制与回放。
一个 App 开发完成上架之后,一般我们会收集用户设备的内存帧率,崩溃信息,ANR信息等,这些都是基操,但是现在平台会提出了更高的要求,录制用户操作与回放用户操作,很多大厂都在进行这方面的探索。
目前业内做的比较好的录制与回放稳定性平台搭建包括不限于美团,爱奇艺,字节,网易,货拉拉等。
不同于测试阶段可以用 PC + ADB 实现录制与操作的思路,在应用内部我们就需要预先埋点用户的事件操作与回放逻辑,并且生成对应的日志信息。
那么实现录制与回放有哪些方法?哪一种更方便呢?本文只是探讨一下基于 Activity 实现的,比较简单的、比较基本的录制与回放功能,方便大家参考。
当然本文只是基于 Demo 性质,只用于本机录制本机回放,如果真要做到兼容多平台多设备,如需要ORC文本识别与图片识别进行定位,屏幕大小适配坐标等其他一系列的深入优化就不在本文的探讨范围。其实只要实现了核心功能,其他都是细枝末节需要时间打磨。

那么话不多说,Let's go

1定义事件


在前文 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 方便。

2录制


先定义一个开始与停止的方法:
//开启录制
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)
}
每一行代码都尽量给出注释。
3回放


其实和我们之前的 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()
}

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

4多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()
}
效果:
录制:

回放:

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

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

5后记


回到前文,虽然自动化测试中我们常用到录制与回放的功能,但是对于线上的监控与云真机回放对于的操作,其实与类似Python自动化脚本还是有区别,与 PC + ADB 的方式也有区别,基于App本身实现的可以更好的用于线上的稳定性监控。
当然了由于本文是实验性质并不完善,浅尝辄止,只是提供一个思路,真要实现完整的功能并不是一个人短时间能搞出来的,如果你想要实现类似的功能可以参考实现。
比如后期如我们需要区分事件类型,点击的文本与图标,使用文本或图片识别进行定位,输入框的适配,等等一系列的功能并不是那么的容易还有很长的路要走,想起来都头皮发麻。
好了,关于最基础的功能来说的话,本机的 App 应用的录制与回放就讲到这里,那么除此方式之外还有哪些更方便的实现方式呢?我也很好奇,也欢迎大家交流讨论哦!
而对于本机其他第三方 App 应用的录制与回放又有哪些方式实现呢?这又是完全不同的另一个故事了。

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

https://gitee.com/newki123456/Kotlin-Room


惯例,我如有讲解不到位或错漏的地方,希望同学们可以指出交流。
如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力啦!
Ok,这一期就此完结。


最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读

写个鸿蒙版本的玩 Android,让 httpRequest 支持 Cookie
掌握这10个Android LaunchMode问题,面试轻松搞定
开发一款 SDK 需要注意哪些问题


扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

继续滑动看下一个
鸿洋
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存