自定义ViewGroup扩展,基于事件伪造实现事件录制与回放

应用内基于自定义ViewGroup和事件伪造实现事件的录制与回放

前言

在之前的文章中我们复(学)习了 ViewGroup 的事件、滚动、惯性、多手指等操作。今天我们复(学)习一下事件的传递与伪造。

之前在群里遇到有这样一种需求,我们需要把一段时间内的用户操作记录下来,并且可以回放操作。

例如用户点击按钮或滑动,一旦出现闪退或其他问题,监控平台就可以通过用户的操作在云端或本地复现用户的操作,结合埋点与崩溃信息能更好的发现代码与逻辑漏洞并进行修复。

通过录制的按钮与图标、滚动的坐标等信息再结合当前的设备进行对应的适配并回放。

当然我们不需要搞那么复杂,我们简单的撸一下本机的 App 的基于 ViewGroup 的录制与回放。

本文就基于本 App 应用内的事件录制,并且还是 ViewGroup 级别的,其实并不适用于正式开发,这里是仅供学习与参考,后面会推荐更好的方案。

ViewGroup 的事件录制与回放就难免涉及到事件的分发、录制、读取、与事件的伪造了,在自定义 ViewGrop 的基础上简单的实现方便大家复(学)习 ViewGroup 的相关事件传递、录制、预处理。伪造事件相关逻辑。

那么话不多说,Let's go

300.png

一、点击与长按的录制与实现

最初的点击与长按事件我获取,我们可以通过 OnTouchEvent 的方法直接获取,我们把对应的 MotionEvent 替换为自己的对象,然后保存为 Json 文件存储起来,当然大家可以存储为其他格式,甚至可以加密和压缩。

再通过读取对应的脚本文件,封装对应的 MotionEvent.obtain 动作,通过 dispatchTouchEvent 去分发我们伪造的事件实现对应的功能呢。

由于点击和长按不涉及到时间轴的问题,我们只需要简单的处理即可,下面是关键代码:

public class EventRecorder {
    private List<Event> events = new ArrayList<>();

    // 方法用于记录点击事件的方法
    public void recordClick(float x, float y, long timestamp) {
        events.add(new Event("click", x, y, timestamp, 0));
    }

    // 方法用于记录长按事件的方法
    public void recordLongPress(float x, float y, long timestamp, long duration) {
        events.add(new Event("longPress", x, y, timestamp, duration));
    }

    // 将事件序列化为JSON字符串
    public String serializeEventsToJson() {
        Gson gson = new Gson();
        return gson.toJson(events);
    }

    // 保存事件到本地文件
    public void saveEventsToFile(Context context, String filename) {
        String json = serializeEventsToJson();
        try (FileOutputStream fos = context.openFileOutput(filename, Context.MODE_PRIVATE)) {
            fos.write(json.getBytes());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

class Event {
    String type;
    float x, y;
    long timestamp; //事件发生的时间戳
    long duration;  //事件时间
    List<MotionEvent> moveEvents; // 用于滚动事件

    // 点击和长按事件的构造函数
    Event(String type, float x, float y, long timestamp, long duration) {
        this.type = type;
        this.x = x;
        this.y = y;
        this.timestamp = timestamp;
        this.duration = duration;
    }
}

我们定义一个回放解析类:

public class EventPlayer {

    private List<Event> loadEventsFromFile(Context context, String filename) {
        try (FileInputStream fis = context.openFileInput(filename)) {
            byte[] bytes = new byte[fis.available()];
            fis.read(bytes);
            String json = new String(bytes);
            Gson gson = new Gson();

            Type eventType = new TypeToken<List<Event>>() {
            }.getType();
            return gson.fromJson(json, eventType);
        } catch (IOException e) {
            e.printStackTrace();
            return Collections.emptyList();
        }
    }


    //解析脚本分发时间
    public void playEvents(Context context, String filename, EventLayout layout) {
        List<Event> events = loadEventsFromFile(context, filename);
        for (Event event : events) {
            layout.performTouchEvent(event);
        }
    }
}

通过我们的自定义ViewGroup的来执行这些事件:

public class EventLayout extends LinearLayout {

    private boolean isRecording = false; // 是否正在录制标志
    private List<MotionEvent> moveEvents; // 存储ACTION_MOVE事件

    public EventLayout(Context context) {
        super(context);
    }

    public EventLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public EventLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    public void performTouchEvent(Event event) {
        MotionEvent motionEvent;
        YYLogUtils.w("当前回放的Event:" + event.type + " x:" + event.x + " y:" + event.y);

        long downTime = event.timestamp;

        switch (event.type) {
            case "click":
                // 模拟点击事件
                //模拟按下
                motionEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, event.x, event.y, 0);
                dispatchTouchEvent(motionEvent);
                motionEvent.recycle();

                postDelayed(() -> {
                    //模拟抬起
                    MotionEvent upEvent = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, event.x, event.y, 0);
                    dispatchTouchEvent(upEvent);
                    upEvent.recycle();

                }, 150);

                break;

            case "longPress":
                // 模拟长按动作
                long longPressDuration = event.duration;

                // 模拟按下
                motionEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, event.x, event.y, 0);
                dispatchTouchEvent(motionEvent);
                motionEvent.recycle();

                postDelayed(() -> {

                    //模拟抬起
                    MotionEvent upEvent = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, event.x, event.y, 0);
                    dispatchTouchEvent(upEvent);
                    upEvent.recycle();

                }, longPressDuration);

                break;

        }
    }


}

使用的效果:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b4ae65f94dbe4cd482f25826cb93577e~tplv-k3u1fbpfcp-jj-mark:3024:0:0:0:q75.awebp#?w=434&h=764&s=284974&e=gif&f=79&b=f9f9f9

二、滑动的录制与回放

滑动与点击事件的处理方式不同,我们需要记录按下时间戳和事件时间戳,用于后续事件来计算持续时间和速度等信息。这样模拟出的滚动事件将会有更加真实的感觉,更接近用户的实际滚动行为。

那么我们能不能把之前的 MotionEvent 事件存起来,后续回放的时候直接拿出来播放?

好想法!

java.lang.IllegalArgumentException: pointerIndex out of range pointerIndex=0 pointerCount=0 at android.view.MotionEvent.nativeGetAxisValue(Native Method) at android.view.MotionEvent.getY(MotionEvent.java:1974)

因为这个事件已经被回收了,取值都不行何况拿出来复用,所以我们需要自己定义 Event 来保存相关信息。

class Event {
    String type;
    float x, y;
    long timestamp; //按下的发生的时间戳
    long eventimestamp; //事件发生的时间戳
    long duration;  //事件时间
    int action;
   ...

比如录制器我们修改为:

public class EventRecorder {
    private List<Event> events = new ArrayList<>();

    // 方法用于记录点击事件的方法
    public void recordClick(float x, float y, long timestamp) {
        events.add(new Event("click", x, y, timestamp, 0));
    }

    // 方法用于记录长按事件的方法
    public void recordLongPress(float x, float y, long timestamp, long duration) {
        events.add(new Event("longPress", x, y, timestamp, duration));
    }

    // 方法用户记录滚动的一系列事件的方法
    public void recordScroll(float x, float y, long timestamp, long eventimestamp, int action) {
        events.add(new Event("scroll", x, y, timestamp, eventimestamp, action));
    }

在滚动的监听中我们修改保存的逻辑:

 public void setRecording(boolean recording) {
        isRecording = recording;
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        if (isRecording) {
            // 当处于录制模式时,根据事件类型记录
            recordEvent(ev);
        }
        return super.onTouchEvent(ev);
    }

    private void recordEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                eventRecorder.clear();
                recordMotionEvent(event); // 记录ACTION_DOWN事件
                break;
            case MotionEvent.ACTION_MOVE:
                recordMotionEvent(event); // 记录ACTION_MOVE事件
                break;
            case MotionEvent.ACTION_UP:
                recordMotionEvent(event); // 记录ACTION_UP事件
                saveScrollEvent(); // 存储整个滚动动作
                break;
        }
    }

    private void recordMotionEvent(MotionEvent event) {
        eventRecorder.recordScroll(event.getX(), event.getY(), event.getDownTime(),
                event.getEventTime(), event.getAction());
    }

    private void saveScrollEvent() {
        isRecording = false;

        eventRecorder.saveEventsToFile(getContext(), "scroll_event");
    }

在回放的过程中,我们拿现在的时间戳需要重新处理一下,为每一个事件重新赋值。

    //解析脚本分发时间
    public void playEvents(Context context, String filename, RecordableScrollView scrollView) {
        List<Event> events = loadEventsFromFile(context, filename);

        long now = SystemClock.uptimeMillis();
        for (int i = 0; i < events.size(); i++) {
            Event event = events.get(i);
            long offset = event.eventimestamp - event.timestamp;
            event.timestamp = now;
            event.eventimestamp = now + offset;
            scrollView.performTouchEvent(event);
        }

    }

在自定义布局中就可以直接播放了,这里并不完善并没有根据时间轴来播放,所以滑动的时间会缩短。

 public void performTouchEvent(Event event) {

        final int action = event.action;

        // 构建一个新的 MotionEvent 对象来包含当前时间信息
        MotionEvent playbackEvent = MotionEvent.obtain(
                event.timestamp,
                event.eventimestamp,
                action,
                event.x,
                event.y,
                0
        );

        YYLogUtils.w("滚动的事件2:" + playbackEvent.toString());

        // 派发触摸事件
        dispatchTouchEvent(playbackEvent);

        // 事件回收
        playbackEvent.recycle();

    }

效果:

https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/39451cad6a9543c88bc6585dc34c8dda~tplv-k3u1fbpfcp-jj-mark:3024:0:0:0:q75.awebp#?w=434&h=764&s=231137&e=gif&f=43&b=fcfbfb

由于并没有处理回放的时间轴,可以看到回放的时间是比录制时间短的,下面我们就完善一下播放时间轴。

三、整合全流程完整版

点击与滚动的录制与回放最基本的逻辑实现了,但是有很多问题,比如回放的时间轴不对,我录制了3秒的操作但是回放只有1秒,加快了动作的播放,为了解决这个问题我们需要在伪造事件的时候根据 Handler 延时发送事件。

并且点击事件与滚动事件分开录制不优雅,那我们能不能合并录制流程呢?

既然滚动都可以全部录制了,我们能不能把点击的相关事件也一起顺便录制了,答案是可以的,但是有点绕不能直接在 OnTouch 中录制,因为按钮的点击事件被消费之后不会传递到父布局,所以父布局 OnTouch 中无法接收到点击事件的冒泡。

那我们可以通过 onInterceptTouchEvent 中进行录制吗?

答案也是不可以的,因为它只是判断否进行拦截事件的,只会触发一次 Down 事件,那怎么办?怎么处理?

其实我们可以通过 GestureDetector 代理点击与长按事件,需要注意的是要写到 onInterceptTouchEvent 中进行代理,因为父布局中 OnTouch 无法接收到子布局已经消费过的事件冒泡。

既然解决了这个问题,现在我们把事件分为 等待,点击,长按,滚动 这四种录制类型,对于每一种类型我们视为一个动作,每一个动作中可以有0个或多个Event事件。

在每一个动作中我们都需要定义动作的开始时间和结束时间,当等待的事件开始,我们设置0个事件,当点击和长按的时候我们就设置一个UP一个Down一共两个事件,而当滚动动作开始我们就记录滚动全过程的全部事件了。

按照这个思路我们修改我们的动作类:

class EventGroup {
    List<Event> events = new ArrayList<>();  //一个动作是由多个事件组成的
    public long startTimestamp;  //一个动作的开始时间
    public long endimestamp;  //一个动作的结束时间
    public String type = "scroll";  //一个动作的类型  click  longpress  scroll  delay

    public void addEvent(Event event) {
        events.add(event);
    }

    public void addAllEvent(List<Event> events) {
        this.events.addAll(events);
    }

    public void clear() {
        events.clear();
        startTimestamp = 0;
        endimestamp = 0;
    }
}

每一个属性都有详细的注释,那么每一个动作的事件我们就可以定义是哪一种Event类型与对应的属性,定义如下:

class Event {
    float x, y;
    long timestamp;  //按下的发生的时间戳
    long evenTimestamp; //事件发生的时间戳
    int action;   //当前事件的类型

    public Event(float x, float y, long timestamp, long evenTimestamp, int action) {
        this.x = x;
        this.y = y;
        this.timestamp = timestamp;
        this.evenTimestamp = evenTimestamp;
        this.action = action;
    }
}

那么在父布局中我们录制事件的时候就要处理延时与其他事件的处理,核心代码如下:

 private void init(Context context) {
        gestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {

            @Override
            public boolean onSingleTapConfirmed(MotionEvent event) {
                if (isRecording) {
                    eventRecorder.recordEndDelay();  //每次按下事件,结束Delay

                    YYLogUtils.w("单击");
                    EventGroup eventGroup = new EventGroup();
                    eventGroup.type = "click";
                    eventGroup.startTimestamp = SystemClock.uptimeMillis();
                    eventGroup.addEvent(new Event(event.getX(), event.getY(), event.getDownTime(),
                            event.getEventTime(), MotionEvent.ACTION_DOWN));
                    eventGroup.addEvent(new Event(event.getX(), event.getY(), event.getDownTime(),
                            event.getEventTime() + 120, MotionEvent.ACTION_UP));
                    eventGroup.endimestamp = eventGroup.startTimestamp + 120;
                    YYLogUtils.w("添加的点击EventGroup:" + eventGroup.toString());
                    eventRecorder.addEventGroup(eventGroup);

                    eventRecorder.recordStartDelay();  //动作的结束,开始Delay
                }
                return super.onSingleTapConfirmed(event);
            }

            @Override
            public void onLongPress(MotionEvent e) {
                if (isRecording) {
                    // 。。。
                }
                super.onLongPress(e);
            }
        });


    }

    public void setRecording(boolean recording) {
        isRecording = recording;
        if (isRecording) {
            eventRecorder.recordStartDelay();
        } else {
            eventRecorder.recordEndDelay();
            eventRecorder.saveEventsToFile(getContext(), "event");
        }

    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        gestureDetector.onTouchEvent(ev);
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        if (isRecording) {
            // 当处于录制模式时,根据事件类型记录
            recordEvent(ev);
        }

        return super.onTouchEvent(ev);
    }

    private void recordEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                eventRecorder.recordEndDelay();  //每次按下事件,结束Delay
                EventGroup eventGroup = new EventGroup();
                eventGroup.startTimestamp = SystemClock.uptimeMillis();
                eventRecorder.addEventGroup(eventGroup);
                recordMotionEvent(event, false); // 记录ACTION_DOWN事件
                break;
            case MotionEvent.ACTION_MOVE:
                recordMotionEvent(event, false); // 记录ACTION_MOVE事件
                break;
            case MotionEvent.ACTION_UP:
                recordMotionEvent(event, true); // 记录ACTION_UP事件
                eventRecorder.recordStartDelay();  //动作的结束,开始Delay

                break;
        }
    }

    private void recordMotionEvent(MotionEvent event, boolean isEnd) {
        EventGroup lastEventGroup = eventRecorder.getLastEventGroup();
        YYLogUtils.w("当前录制事件:" + event.toString() + " lastEventGroup:" + lastEventGroup);

        if (lastEventGroup != null) {
            lastEventGroup.addEvent(new Event(event.getX(), event.getY(), event.getDownTime(),
                    event.getEventTime(), event.getAction()));

            if (isEnd) {
                lastEventGroup.endimestamp = SystemClock.uptimeMillis();
            }
        }
    }

我们的点击与滚动时间的开始和完成我们都要及时的处理Delay的动作,其他就是原来的逻辑,把每一个动作 EventGroup 对象序列化存入本地 Path,我是选用的json格式,大家可以自定义各种格式,可以序列化,可以选择加密,压缩等各种操作,这里就不展开了。

对动作的回放我们还是老一套逻辑,取出文件中的数据,格式化为对象,然后赋值想要执行的操作,伪造事件的执行时间,并且为了真正播放完整的动作时间轴,我们选择使用 Handler 进行延时的事件发生。

核心代码如下,关键代码都加上了注释:

//解析脚本分发时间
    public void playEvents(Context context, String filename, RecordableScrollView scrollView) {
        List<EventGroup> eventGroups = loadEventsFromFile(context, filename);

        YYLogUtils.w("eventGroups:" + eventGroups);

        long targetTime = 0;
        if (eventGroups != null && !eventGroups.isEmpty()) {
            targetTime = eventGroups.get(0).startTimestamp;
        }
        long now = SystemClock.uptimeMillis();
        long delayTimeStamp = 0;

        //使用Handler队列根据时间执行
        Handler handler = new Handler(Looper.getMainLooper());

        for (int i = 0; i < eventGroups.size(); i++) {
            EventGroup eventGroup = eventGroups.get(i);
            if ("delay".equals(eventGroup.type)) {
                delayTimeStamp += eventGroup.endimestamp - eventGroup.startTimestamp;
                YYLogUtils.w("当前动作的延时:" + delayTimeStamp);
                continue;
            }

            List<Event> events = eventGroup.events;
            for (int j = 0; j < events.size(); j++) {
                Event event = events.get(j);

                //根据evenTimestamp延时发射计算当前Event的延时
                long eventDelay = event.evenTimestamp - event.timestamp;
                YYLogUtils.w("eventDelay:" + eventDelay);

                //真正的Event赋值到现在的时间点
                if (event.timestamp != 0) {
                    long timestampOffset = event.timestamp - targetTime;
                    event.timestamp = now + timestampOffset;
                }
                if (event.evenTimestamp != 0) {
                    long evenTimestampOffset = event.evenTimestamp - targetTime;
                    event.evenTimestamp = now + evenTimestampOffset;
                }

                handler.postDelayed(() -> {
                    scrollView.performTouchEvent(event);
                }, (delayTimeStamp + eventDelay));
                YYLogUtils.w("当前事件的延时:" + (delayTimeStamp + eventDelay));

            }

            //当一个动作完成之后累加到总延时值上
            delayTimeStamp += eventGroup.endimestamp - eventGroup.startTimestamp;
            YYLogUtils.w("当前动作的延时:" + delayTimeStamp);
        }

    }

至于真正执行伪造的事件,还是之前的一套逻辑,我们把数据以及伪造好了,这里直接执行即可:

    public void performTouchEvent(Event event) {

        if (event != null) {
            // 构建一个新的 MotionEvent 对象来包含当前时间信息
            MotionEvent playEvent = MotionEvent.obtain(
                    event.timestamp,
                    event.evenTimestamp,
                    event.action,
                    event.x,
                    event.y,
                    0
            );

            // 派发事件
            dispatchTouchEvent(playEvent);

            // 事件回收
            playEvent.recycle();
        }

    }

完整效果如下:

https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4d20fc8dd5cf423abb0fab43d5fca6c8~tplv-k3u1fbpfcp-jj-mark:3024:0:0:0:q75.awebp#?w=348&h=766&s=1138594&e=gif&f=238&b=fbfafa

后记

应用内的事件录制与回放我们可以通过自定义 View 的方式实现,在 Activity 的 ContentView 中我们可以替换为自定义的布局实现。

如果真的想要实现整个 App 应用的录制与回放,其实用 Activity 的事件传递过程中进行录制要更加方便一点,封装对应的 Activity,特别是单A多F架构的特别合适,例如,Flutter、Compose、或原生的 Navigation 项目。后面我会出一篇基于 Activity 的录制与回放,会更加的方便,可以期待一波。

再次声明,如果要实现类似的录制事件功能,其实很多其他的有更好更方便的方式,本文旨在是复习 ViewGroup 的事件传递,以及事件的伪造、如何获取自定义 ViewGroup 的点击长按与滚动事件,如何录制如何回放的流程与步骤。

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

作者:Newki
链接:https://juejin.cn/post/7329782406541344806
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值