2024年1月

一、目的

最开始接触Android时,仅仅是知道Android系统存在的点击事件、触摸事件,但是并不清楚这些事件的由来。
之后,在面试Oppo和美图时,皆有问到Android的事件分发机制,但是都被问得很懵逼,归根到底都是对于其实现逻辑的不理解。
随后,想去弥补该模块的不足,浏览很多关于Android事件分发的博文,但仍存在一些疑惑,就想着去阅读下源码,整理下笔记,希望对同学们有帮助。

二、环境

  1. 版本:Android 11
  2. 平台:展锐 SPRD8541E

三、相关概念

3.1 事件分发

Android 中 View 的布局是一个树形结构,各个 ViewGroup 和 View 是按树形结构嵌套布局的,从而会出现用户触摸的位置坐标可能会落在多个 View 的范围内,这样就不知道哪个 View 来响应这个事件,为了解决这一问题,就出现了事件分发机制。

四、详细设计

4.1应用布局

4.1.1 应用布局结构

如下为一个Activity打开后,其对应视图的层级结构。

4.1.2 LayoutInspector

Layout Inspector是google提供给我们进行布局分析的一个工具,也是目前google在弃用Hierarchy View后推荐使用的一款布局分析工具。

4.2 关键View&方法

4.2.1 相关View

组件 描述
Activity Android事件分发的起始端,其为一个window窗口,内部持有Decorder视图,该视图为当前窗体的根节点,同时,它也是一个ViewGroup容器。
ViewGroup Android中ViewGroup是一个布局容器,可以嵌套多个 ViewGroup 和 View,事件传递和拦截都由 ViewGroup 完成。
View 事件传递的最末端,要么消费事件,要么不消费把事件传递给父容器

4.2.2 相关方法

方法 描述
dispatchTouchEvent 分发事件
onInterceptTouchEvent 拦截事件
onTouchEvent 触摸事件

4.2.3 View与方法关系

组件 dispatchTouchEvent onInterceptTouchEvent onTouchEvent
Activity
ViewGroup
View

4.3 事件分发概念图

4.3.1 事件分发类图

4.3.2 事件分发模型图

Android的ACTION_DOWN事件分发如图,从1-9步骤,描述一个down事件的分发过程,如果大家能懂,就不用看下面文字描述了(写完这个篇幅,感觉文字好多,不好理解!)

  1. ACTION_DOWN事件触发。
    当我们手指触摸屏幕,tp驱动会响应中断,通过ims输入系统,将down事件的相关信息发送到当前的窗口,即当前的Activity。
  2. Activity事件分发。
    会引用dispatchTouchEvent()方法,对down事件分发。Activity本身会持有一个window对象,window对象的实现类PhoneWindow会持有一个DecorView对象,DecorView是一个ViewGroup对象,即我们可以理解为,Activity最终会将事件分发给下一个节点——ViewGroup。
  3. ViewGroup事件拦截。
    ViewGroup接收到事件后,会先引用onInterceptTouchEvent(),查看当前的视图容器是否做事件拦截。
  4. ViewGroup消费事件。
    如当前的ViewGroup对事件进行拦截,即会调用onTouchEvent(),对事件消费。
  5. ViewGroup事件不拦截。
    则ViewGroup会继续遍历自身的子节点,并且当事件的坐标位于子节点上,则继续下发到下一个节点。ViewGroup的子节点有可能是View,也可能是ViewGroup(当然,ViewGroup最后也是继承于View的,突然感觉有点废话)。
  6. ViewGroup事件分发。
    目标视图如果是ViewGroup,会引用其super类的dispatchTouchEvent()方法,即事件下发,不管目标视图是View或者ViewGroup最终引用的是View类的分发方法。
  7. View事件消费。
    在View的dispatchTouchEvent()方法中会根据当前View是否可以点击、onTouch()是否消费、onTouchEvent()是否消费等条件,来判断当前是否为目标View。
  8. View事件未消费。
    View事件未消费,则其父节点,即ViewGroup会调用onTouchEvent()方法,并根据返回值来决定是否消费事件。
  9. ViewGroup事件未消费。
    ViewGroup事件未消费,择其父节点,即Actviity会调用onTouchEvent()方法

PS:
(1)
ACTION_MOVE

ACTION_UP
事件,流程与ACTION_DOWN的分发过程基本一致,MOVE和UP事件也是通过Activity开始,借助DOWN事件产生的目标View,逐级分发。
(2)
ACTION_CANCEL
事件,是在down与up、move事件切换过程中,事件被拦截,两次的touchTarget目标view不一致,而产生的事件。用于对之前的目标View做恢复处理,避免down与up/move事件不对称。

4.4 Activity组件

4.4.1 Activity->dispatchTouchEvent()

底层上报的事件信息,最终会引用到该方法。Activity会持有一个根视图DecordView,事件最终会往该ViewGroup分发,如所有的View都未消费该事件,则最终由Activity的onTouchEvent()
来兜底处理。

@frameworks\base\core\java\android\app\Activity.java
public boolean dispatchTouchEvent(MotionEvent ev) {
    ...
    if (getWindow().superDispatchTouchEvent(ev)) {//Step 1. 查看Window对应的View是否分发该事件
        return true;
    }
    return onTouchEvent(ev);//Step 2. 如果没有组件消费事件,则由Activity兜底处理
}

4.4.2 Activity->getWindow()

我们每次启动一个Activity的组件,会先打开一个window窗口,而PhoneWindow是Window唯一的实现类。

@frameworks\base\core\java\android\app\Activity.java
public Window getWindow() {
    return mWindow;
}

final void attach(Context context, ActivityThread aThread...) {
    ...
    mWindow = new PhoneWindow(this, window, activityConfigCallback);//PhoneWindow是Window窗口唯一的实现类
    ...
}

PhoneWindow对象内部持有DecorView对象,而该View正是该窗口对应的视图容器,也是根节点。(此部分不具体分析)

@frameworks\base\core\java\com\android\internal\policy\PhoneWindow.java
public class PhoneWindow extends Window implements MenuBuilder.     Callback {
    ...
    private DecorView mDecor;//
    ...
    @Override
    public boolean superDispatchTouchEvent(MotionEvent event) {
        return mDecor.superDispatchTouchEvent(event);//往View的根节点分发事件
    }
}

4.4.3 Activity->onTouchEvent()

Activity的onTouchEvent方法,是在没有任何组件消费事件的情况下,触发的方法。

@frameworks\base\core\java\android\app\Activity.java
public boolean onTouchEvent(MotionEvent event) {
    if (mWindow.shouldCloseOnTouch(this, event)) {
        finish();
        return true;
    }
    return false;
}

4.5 ViewGroup组件

ViewGroup组件在整个事件分发的模型中,既有分发事件的责任,又要具备处理事件的能力,真的典型的当爹又当妈。
当Activity调用superDispatchTouchEvent,即最终会使用到DecorView的superDispatchTouchEvent方法,而DecorView是继承于ViewGroup,即最终会引用ViewGroup的dispatchTouchEvent方法。

4.5.1 ViewGroup->dispatchTouchEvent()

此方法为事件分发最核心的代码。其主要处理如下四件事情:
Setp 1. 重置事件。
一次完整触摸的事件:DOWN -> MOVE -> UP,即我们可以理解为DOWN是所有触摸事件的起始事件。当输入事件是ACTION_DOWN时,重置触摸事件状态信息,避免产生干扰。
Step 2. 拦截事件。
拦截事件是ViewGroup特有的方法,用于拦截事件,并将该事件分发给自己消费,防止事件继续下发。
Step 3.查找目标View。
查找目标View主要针对于Down事件。当ViewGroup未拦截事件,且输入事件是ACTION_DOWN时,会遍历该ViewGroup的所有子节点,并根据触摸位置的坐标,来决定当前子节点是否是下一级目标View。当找到目标View节点后,会分发Down事件,并记录该节点信息。
Step 4.下发事件。
如果目标View未找到的话,则会将事件交由自己的onTouchEvent()处理;如果目标View已经找到,则Down事件就此结束(此处暂不考虑多指场景);Move和Up事件将继续下发(默认情况下Move、Up和Down事件是成对出现的,如果目标View已经存在,则Down事件已经下发,即意味着Move和Up事件也需要下发给对应的目标View)。

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    ...
    if (actionMasked == MotionEvent.ACTION_DOWN) {//Step 1.重置事件信息,避免影响下一次事件
        cancelAndClearTouchTargets(ev);
        resetTouchState();
    }

    if (actionMasked == MotionEvent.ACTION_DOWN
            || mFirstTouchTarget != null) {
        final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
        if (!disallowIntercept) {
            intercepted = onInterceptTouchEvent(ev);//Step 2.拦截事件
            ev.setAction(action); // restore action in case it was changed
        }
    } 
    ...
    if (!canceled && !intercepted) {//Step 3.查找目标View
        if (actionMasked == MotionEvent.ACTION_DOWN
                || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
                || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
            ...
            if (newTouchTarget == null && childrenCount != 0) {
                ...
                for (int i = childrenCount - 1; i >= 0; i--) {//遍历所有的子节点
                    ...
                    if (!child.canReceivePointerEvents()
                            || !isTransformedTouchPointInView(x, y, child, null)) {// 子节点不可以接收事件,或者触摸位置不在子节点的范围上
                        continue;
                    }
                    ...
                    if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {//找到目标View
                        ...
                        break;
                    }
                }
                ...
            }
            ...
        }
    }
    //Step 4.根据找到的目标View情况,继续下发事件
    if (mFirstTouchTarget == null) {
        // No touch targets so treat this as an ordinary view.
        handled = dispatchTransformedTouchEvent(ev, canceled, null,
                TouchTarget.ALL_POINTER_IDS);//没有找到目标View或者事件被拦截,事件下发给自己
    } else {
        ...
        while (target != null) {//多组数据,一般是指多指场景
            final TouchTarget next = target.next;
            if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {//此场景一般是down事件
                handled = true
            } else {
                if (dispatchTransformedTouchEvent(ev, cancelChild,
                        target.child, target.pointerIdBits)) {//此场景一般是move、up事件
                    handled = true;
                }
                ...
            }
            predecessor = target;
            target = next;
        }
        ...
    }
    ...
    return handled;
}

4.5.2 ViewGroup->dispatchTransformedTouchEvent()

事件分发关键方法,主要用于向目标View分发事件,具体逻辑如下:
Step 1.Cancel事件分发。
之前我们提过Down和Up事件是成对存在的,如果Down事件已经下发的情况下,Up事件却因为事件拦截等原因,未能下发给目标View,目标View未收到Up事件,此时就可能产生一些按压状态的异常问题,故,在当前场景下,将会分发一个ACTION_CANCEL事件给目标View。
Step 2.事件处理。
如果事件未找到目标View,则child会为null,此时的事件将由自身处理。
Step 3.事件分发。
如果事件还存在目标View,则此时的事件会再分发。

    private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
            View child, int desiredPointerIdBits) {
        ...
        if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {//Step 1.下发取消事件
            event.setAction(MotionEvent.ACTION_CANCEL);
            if (child == null) {
                handled = super.dispatchTouchEvent(event);
            } else {
                handled = child.dispatchTouchEvent(event);
            }
            event.setAction(oldAction);
            return handled;
        }
        ...
        if (child == null) {//Step 2.如果事件未找到目标View,则触摸事件会发给自己
            handled = super.dispatchTouchEvent(transformedEvent);
        } else {
            final float offsetX = mScrollX - child.mLeft;
            final float offsetY = mScrollY - child.mTop;
            transformedEvent.offsetLocation(offsetX, offsetY);
            if (! child.hasIdentityMatrix()) {
                transformedEvent.transform(child.getInverseMatrix());
            }
            handled = child.dispatchTouchEvent(transformedEvent);//Step 3.找到目标View,事件下发给子节点
        }
        ...
        return handled;
    }

4.6 View组件

View组件在事件处理模型中,主要是处理事件。我们知道ViewGroup,也是继承于View,所以ViewGroup也是同样具备View的处理事件能力。

4.6.1 View->dispatchTouchEvent()

Step 1.触发onTouch()方法。
如果当前的View是可点击的,且配置了onTouch事件监听,则触发该View的onTouch()方法。
Step 2.触发onTouchEvent()方法。
如果该事件在上一步的onTouch()函数中未被消费,则触发onTouchEvent()方法。

public boolean dispatchTouchEvent(MotionEvent event) {
    boolean result = false;
    ...
    if (onFilterTouchEventForSecurity(event)) {
        ...
        ListenerInfo li = mListenerInfo;
        if (li != null && li.mOnTouchListener != null
                && (mViewFlags & ENABLED_MASK) == ENABLED
                && li.mOnTouchListener.onTouch(this, event)) {//Step 1.触发onTouch事件
            result = true;
        }

        if (!result && onTouchEvent(event)) {//Step 2.如onTouch未消费,触发onTouchEvent事件
            result = true;
        }
    }
    ...
    return result;
}

4.6.2 OnTouchListener->onTouch()

View可以设置事件监听,用于监听onTouch事件的回调,当然,像我们常见的onClick()、onLongClick()等事件也可监听,其相关源码如下:

@frameworks\base\core\java\android\view\View.java
public void setOnTouchListener(OnTouchListener l) {//设置onTouch监听
    getListenerInfo().mOnTouchListener = l;
}

ListenerInfo getListenerInfo() {
    if (mListenerInfo != null) {
        return mListenerInfo;
    }
    mListenerInfo = new ListenerInfo();
    return mListenerInfo;
}

public interface OnTouchListener {//Touch接口,用于回调onTouch事件
    boolean onTouch(View v, MotionEvent event);
}

4.6.3 View->onTouchEvent()

事件如未被onTouch消费掉,则会引用到onTouchEvent()方法,该方法会涉及ACTION_UP、ACTION_DOWN、ACTION_CANCEL、ACTION_MOVE事件的处理,View的onClick()、onLongClick()也是由该方法触发。此外,如果当前的View是可点击的话,则直接消费该事件。

public boolean onTouchEvent(MotionEvent event) {
    ...
    final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
        || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
        || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;//当前View是否可点击
    ...
    if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
        switch (action) {
            case MotionEvent.ACTION_UP://抬起
                ...
                if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
                    if (!focusTaken) {
                        removeLongPressCallback();//若有长按事件未处理,则移除长按事件
                        if (mPerformClick == null) {
                            mPerformClick = new PerformClick();
                        }
                        if (!post(mPerformClick)) {//通过Hanlder将点击事件发送到主线程执行
                            performClickInternal();//如果不成功,则直接引用点击事件
                        }
                    }
                }
                if (mUnsetPressedState == null) {
                    mUnsetPressedState = new UnsetPressedState();//更新按钮的按压事件
                }
                ...
                break;
            case MotionEvent.ACTION_DOWN://按下
                ...
                if (isInScrollingContainer) {//在可滚动的容器内,为了容错,延迟点击
                    ...
                    postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
                } else {
                    setPressed(true, x, y);//设置按下的状态
                    checkForLongClick(
                            ViewConfiguration.getLongPressTimeout(),
                            x,
                            y,
                            TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__LONG_PRESS);//开启一个长按延时事件
                }
                break;

            case MotionEvent.ACTION_CANCEL://取消
                ...
                break;
            case MotionEvent.ACTION_MOVE://移动
                ...
                break;
        }
        return true;//如果是可点击的View,即消费事件
    }
    ...
    return false;
}

4.7 例子-点击事件时序图

如下是Android的点击事件时序图,如果能够理解单击事件的由来,对于整个事件分发的知识要点已大体掌握。

五、小结&问题点

  1. 事件分发流程?包括ACTION_DWON、ACTION_UP、ACTION_MOVE事件的处理过程;
  2. ACTION_CANCEL事件的使用场景?父控件对move事件拦截场景?
  3. 单击、长按、触摸事件的产生过程?
  4. 点击一个View未抬起,同时move该事件直至离开当前View的范围,处理过程如何?
  5. 如果所有View都未消费事件,流程如何?
  6. ViewPage+ListView,左右滑动和上下滑动冲突的解决问题?即事件拦截过程?
  7. 普通的View是根据什么来决定是否消费事件,例如Button?
    =>答:如无重写onTouchEvent事件,根据当前的View是否可点击,来决定是否消费事件。


我最开始没有看源码,直接去看博客上的内容,弯弯绕绕,似懂非懂。在面试的过程中,面试官举个场景分析流程,我都懵逼,分析不出来,现场很尴尬。之后看源码,整体流程代码量很少,感叹于Android事件分发流程的设计,很少的代码量,却承载了很重要的功能,而没有见过该模块发生过异常。
多读书,多看报,少吃零食,多睡觉!

六、代码仓库地址

Demo地址:
https://gitee.com/linzhiqin/custom-demo

七、参考资料

https://zhuanlan.zhihu.com/p/623664769?utm_id=0
事件分发视频(总结很好,但是得先理解基本概念,才方便学习)
https://www.bilibili.com/video/BV1sy4y1W7az?p=1&vd_source=f222e3bf3083cad8d9f660629bc47c16

Prometheus 是一套开源的系统监控报警框架,非常适合大规模集群的监控。它也是第二个加入CNCF的项目,受欢迎度仅次于 Kubernetes 的项目。本文讲解完整prometheus 监控和告警服务的搭建。
prometheus 监控是当下主流监控系统,它是多个服务组合使用的体系。整体架构预览如下:

本篇教程监控系统搭建,包括的服务有:

  1. prometheus
    监控的主体,负责数据汇总,保存,监控数据,产生告警信息
  2. exporter
    监控的采集端,负责数据采集
  3. grafana
    数据可视化,负责以丰富的页面展示采集到的数据
  4. alertmanager
    告警管理,负责告警信息处理,包括告警周期,消息优先级等
  5. prometheusAlert
    告警的具体发送端,负责配置告警模板,发出告警信息

除了监控采集节点,其他服务均通过docker-compose部署。部署系统信息:

  1. 系统
    :ubuntu20.04
  2. 服务器IP
    :172.16.9.124
  3. docker版本
    :20.10.21
  4. docker-compose版本
    :1.29.2
  5. 配置文件路径
    :/root/prometheus

部署prometheus

prometheus主要负责数据采集和存储,提供PromQL查询语言的支持。部署prometheus分为两个步骤:

  1. 准备配置文件
  2. 启动prometheus

准备配置文件

整个体系的配置文件在
/root/prometheus
,首先新建prometheus服务的配置文件路径
/root/prometheus/prometheus
,并在这个目录下新建:

  • config 用于放置服务主要配置文件 prometheus.yml
  • data 用于放置服务的数据库文件
root@ubuntu-System-Product-Name:~/prometheus# tree . -L 3
.
├── docker-compose.yaml
└── prometheus
    ├── config
    │   └── prometheus.yml
    └── data

新建prometheus.yml,prometheus服务的主配置文件

global:
  scrape_interval:     30s # 每30s采集一次数据
  evaluation_interval: 30s # 每30s做一次告警检测


scrape_configs:
  # 配置prometheus服务本身
  - job_name: prometheus
    static_configs:
      - targets: ['172.16.9.124:9090']
        labels:
          instance: prometheus

修改 data 目录的文件权限,让容器有权限在data目录里生成数据相关数据

chmod 777 data

创建 docker-compse.yml

version: '3'
services:
  prometheus:
    image: prom/prometheus
    container_name: prometheus
    restart: always
    ports:
      - "9090:9090"
    volumes:
      - /root/prometheus/prometheus/config:/etc/prometheus
      - /root/prometheus/prometheus/data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
      - '--web.enable-lifecycle'

参数说明:
command:

  • --config.file=/etc/prometheus/prometheus.yml 指定使用的配置文件
  • --storage.tsdb.path=/prometheus 指定时序数据库的路径
  • --web.enable-lifecycle 支持配置热加载

volumes:

  • /root/prometheus/prometheus/config:/etc/prometheus 映射配置文件所在目录
  • /root/prometheus/prometheus/data:/prometheus 映射数据库路径参数

启动prometheus

启动 docker-compse
docker-compose up -d
查看日志:

root@ubuntu-System-Product-Name:~/prometheus# docker ps
CONTAINER ID   IMAGE                                           COMMAND                  CREATED         STATUS                 PORTS                                                        NAMES
776772d69b20   prom/prometheus                                 "/bin/prometheus --c…"   5 minutes ago   Up 5 minutes           0.0.0.0:9090->9090/tcp, :::9090->9090/tcp                    prometheus

查看容器的日志:
docker logs -f 776

ts=2023-12-25T10:21:17.560Z caller=main.go:478 level=info msg="No time or size retention was set so using the default time retention" duration=15d
ts=2023-12-25T10:21:17.560Z caller=main.go:515 level=info msg="Starting prometheus" version="(version=2.32.1, branch=HEAD, revision=41f1a8125e664985dd30674e5bdf6b683eff5d32)"
ts=2023-12-25T10:21:17.561Z caller=main.go:520 level=info build_context="(go=go1.17.5, user=root@54b6dbd48b97, date=20211217-22:08:06)"
ts=2023-12-25T10:21:17.561Z caller=main.go:521 level=info host_details="(Linux 5.15.0-56-generic #62~20.04.1-Ubuntu SMP Tue Nov 22 21:24:20 UTC 2022 x86_64 776772d69b20 (none))"
ts=2023-12-25T10:21:17.561Z caller=main.go:522 level=info fd_limits="(soft=1048576, hard=1048576)"
ts=2023-12-25T10:21:17.561Z caller=main.go:523 level=info vm_limits="(soft=unlimited, hard=unlimited)"
ts=2023-12-25T10:21:17.562Z caller=web.go:570 level=info component=web msg="Start listening for connections" address=0.0.0.0:9090
ts=2023-12-25T10:21:17.562Z caller=main.go:924 level=info msg="Starting TSDB ..."
ts=2023-12-25T10:21:17.562Z caller=tls_config.go:195 level=info component=web msg="TLS is disabled." http2=false
ts=2023-12-25T10:21:17.564Z caller=head.go:488 level=info component=tsdb msg="Replaying on-disk memory mappable chunks if any"
ts=2023-12-25T10:21:17.564Z caller=head.go:522 level=info component=tsdb msg="On-disk memory mappable chunks replay completed" duration=1.305µs
ts=2023-12-25T10:21:17.564Z caller=head.go:528 level=info component=tsdb msg="Replaying WAL, this may take a while"
ts=2023-12-25T10:21:17.564Z caller=head.go:599 level=info component=tsdb msg="WAL segment loaded" segment=0 maxSegment=1
ts=2023-12-25T10:21:17.564Z caller=head.go:599 level=info component=tsdb msg="WAL segment loaded" segment=1 maxSegment=1
ts=2023-12-25T10:21:17.564Z caller=head.go:605 level=info component=tsdb msg="WAL replay completed" checkpoint_replay_duration=14.305µs wal_replay_duration=301.534µs total_replay_duration=327.342µs
ts=2023-12-25T10:21:17.565Z caller=main.go:945 level=info fs_type=EXT4_SUPER_MAGIC
ts=2023-12-25T10:21:17.565Z caller=main.go:948 level=info msg="TSDB started"
ts=2023-12-25T10:21:17.565Z caller=main.go:1129 level=info msg="Loading configuration file" filename=/etc/prometheus/prometheus.yml
ts=2023-12-25T10:21:17.565Z caller=main.go:1166 level=info msg="Completed loading of configuration file" filename=/etc/prometheus/prometheus.yml totalDuration=217.62µs db_storage=666666ns remote_storage=860ns web_handler=182ns query_engine=371ns scrape=90.382µs scrape_sd=10.238µs notify=450ns notify_sd=788ns rules=737ns
ts=2023-12-25T10:21:17.565Z caller=main.go:897 level=info msg="Server is ready to receive web requests."


日志很重要!日志很重要!日志很重要!

前言

本文以
Kafka 官方文档
的内容为基石,结合参考文献处文章和笔者自身实践凝练而成,涵盖内容全面,详略得当。

这也是《一文搞懂》系列的第一篇技术长文,期待您的关注。

一、Kafka 简介

一个十分钟的视频带你了解 Kafka

image-20240109164254021

Apache Kafka 是一个开源的分布式事件流平台,被数千家公司用于高性能数据管道、流分析、数据集成和关键任务应用。

image-20240109160136265

1.1 事件流平台

  • 什么是事件流


    1. 从技术上讲,事件流是以事件流的形式从数据库、传感器、移动设备、云服务和软件应用程序等事件源实时捕获数据的实践;持久存储这些事件流以供以后检索;实时和回顾性地操作、处理事件流并对其做出反应;并根据需要将事件流路由到不同的目标技术。

    2. 因此,事件流可确保数据的连续流动和解释,以便正确的信息在正确的时间出现在正确的地点。

  • 事件流可以做什么


    1. 实时处理支付和金融交易,例如在证券交易所、银行和保险中。
    2. 连接、存储并提供公司不同部门生成的数据。
    3. 作为数据平台、事件驱动架构和微服务的基础。
    4. 收集客户互动和订单并立即做出反应,等等。
  • Apache Kafka
    作为一个事件流平台有什么功能:

    1. 发布(写入)和订阅(读取)事件流,包括从其他系统持续导入/导出数据。
    2. 根据需要持久可靠地存储事件流
    3. 在事件发生时或回顾性地处理事件流。

☞ Github ☜
☞ Gitee ☜

说明

Binder
作为
Android
系统跨进程通信的核心机制。网上也有很多深度讲解该机制的文章,如:

这些文章和系统源码可以很好帮助我们理解Binder的实现原理和设计理念,为拦截做准备。借助Binder拦截可以我们可以扩展出那些能力呢:

  1. 虚拟化的能力,多年前就出现的应用免安装运行类产品如:
    VirtualApp
    /
    DroidPlugin
    /平行空间/双开大师/应用分身等。
  2. 测试验证的能力,通常为
    Framework
    层功能开发。
  3. 检测第三方
    SDK
    或模块系统服务调用访问情况(特别是敏感
    API
    调用)。
  4. 逆向分析应用底层服务接口调用实现。
  5. 第三方
    ROM
    扩展
    Framework
    服务。

现有方案

一直以来实时分析和拦截进程的
Binder
通信是通过
Java
层的
AIDL
接口代理来实现的。借助于
Android
系统
Binder
服务接口设计的规范,上层的接口均继承于
IBinder

如一下为代理目标对象的所有的接口
API
的方法:

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;

private static void getInterface(Class<?> cls, final HashSet<Class<?>> ss) {
    Class<?>[] ii;
    do {
        ii = cls.getInterfaces();
        for (final Class<?> i : ii) {
            if (ss.add(i)) {
                getInterface(i, ss);
            }
        }
        cls = cls.getSuperclass();
    } while (cls != null);
}

private static Class<?>[] getInterfaces(Class<?> cls) {
    final HashSet<Class<?>> ss = new LinkedHashSet<Class<?>>();
    getInterface(cls, ss);
    if (0 < ss.size()) {
        return ss.toArray(new Class<?>[ss.size()]);
    }
    return null;
}

public static Object createProxy(Object org, InvocationHandler cb) {
    try {
        Class<?> cls = org.getClass();
        Class<?>[] cc = getInterfaces(cls);
        return Proxy.newProxyInstance(cls.getClassLoader(), cc, cb);
    } catch (Throwable e) {
        Logger.e(e);
    } finally {
        // TODO release fix proxy name
    }
    return null;
}

1、对于已经生成的
Binder
服务对象,在应用进程可参与实现逻辑之前就已经缓存了,我们需要找到并且进行替换(
AMS、PMS、WMS等
),如
AMS
在Android 8.0之后的缓存如下:

// source code: http://aospxref.com/android-9.0.0_r61/xref/frameworks/base/core/java/android/app/ActivityManager.java
package android.app;

public class ActivityManager {

    public static IActivityManager getService() {
        return IActivityManagerSingleton.get();
    }

    private static final Singleton<IActivityManager> IActivityManagerSingleton =
            new Singleton<IActivityManager>() {
                @Override
                protected IActivityManager create() {
                    final IBinder b = ServiceManager.getService(Context.ACTIVITY_SERVICE);
                    final IActivityManager am = IActivityManager.Stub.asInterface(b);
                    return am;
                }
            };
}

因此我们需要找到并且替换它,如:

Object obj;
if (Build.VERSION.SDK_INT < 26) {// <= 7.0
    obj = ReflectUtils.getStaticFieldValue("android.app.ActivityManagerNative", "gDefault");
} else {// 8.0 <=
    obj = ReflectUtils.getStaticFieldValue("android.app.ActivityManager", "IActivityManagerSingleton");
}
Object inst = ReflectUtils.getFieldValue(obj, "mInstance");
ReflectUtils.setFieldValue(obj, "mInstance", createProxy(inst));

2、对于后续运行过程中才获取的
Binder
服务,则需要代理
ServiceManager
,源码如下:

// source code: http://aospxref.com/android-9.0.0_r61/xref/frameworks/base/core/java/android/os/ServiceManager.java
package android.os;

public final class ServiceManager {
    private static final String TAG = "ServiceManager";

    private static IServiceManager sServiceManager;
}

因此我们的代理如下:

Class<?> cls = ReflectUtils.findClass("android.os.ServiceManager");
Object org = ReflectUtils.getStaticFieldValue(cls, "sServiceManager");
Object pxy = new createProxy(org);
if (null != pxy) {
    ReflectUtils.setStaticFieldValue(getGlobalClass(), "sServiceManager", pxy);
}

这样每次在第一次访问该服务时,就会调用
IServiceManager
中的
getService
的方法,而该方法已经被我们代理拦截,我们可以通过参数可以识别当前获取的是哪个服务,然后将获取的服务对象代理后在继续返回即可。

但是:
这样的方案并不能拦截进程中所有的
Binder
服务。我们面临几大问题:

  1. 首先,Android源码越来越庞大,了解所有的服务工作量很大,因此有哪些服务已经被缓存排查非常困难。

  2. 其次,厂商越来越钟情于扩展自定义服务,这些服务不开源,识别和适配更加耗时。

  3. 再次,有一部分服务只有
    native
    实现,并不能通过
    Java
    层的接口代理进行拦截(如:
    Sensor/Audio/Video/Camera服务等
    )。

    // source code: http://aospxref.com/android-13.0.0_r3/xref/frameworks/av/camera/ICamera.cpp
    
    class BpCamera: public BpInterface<ICamera>
    {
    public:
        explicit BpCamera(const sp<IBinder>& impl)
            : BpInterface<ICamera>(impl)
        {
        }
      
        // start recording mode, must call setPreviewTarget first
        status_t startRecording()
        {
            ALOGV("startRecording");
            Parcel data, reply;
            data.writeInterfaceToken(ICamera::getInterfaceDescriptor());
            remote()->transact(START_RECORDING, data, &reply);
            return reply.readInt32();
        }
    }
    

新方案:基于底层拦截

原理

我们都知道
Binder
在应用进程运行原理如下图:

不管是
Java
层还是
native
层的接口调用,最后都会通过
ioctl
函数访问共享内存空间,达到跨进程访问数据交换的目的。因此我们只要拦截
ioctl
函数,即可完成对所有
Binder
通信数据的拦截。底层拦截有以下优势:

1)可以拦截所有的Binder通信。

2)底层拦截稳定,高兼容性。从
Android 4.x

Android 14
,近10年的系统版本演进,涉及到
Binder
底层通信适配仅两次;一次是支持64位进程(当时需要同时兼容32位和64位进程访问
Binder
服务)。另一次是华为鸿蒙系统的诞生,华为
ROM

Binder
通信协议中增加了新的标识字段。

要解决的问题

如何拦截

C/C++
层的函数拦截,并不像
Java
层一样系统提供了较为稳定的代理工具,在这里不是我们本期讨论的重点,可以直接采用网上开源的
Hook
框架:

如何过滤

ioctl
函数为系统底层设备访问函数,调用及其频繁,而
Binder
通信调用只是其中调用者之一,因此需要快速识别非
Binder
通信调用,不影响程序性能。

函数定义:

#include <sys/ioctl.h>

int ioctl(int fildes, unsigned long request, ...);

request
的参数定义:

// source code: http://aospxref.com/android-14.0.0_r2/xref/bionic/libc/kernel/uapi/linux/android/binder.h
#define BINDER_WRITE_READ _IOWR('b', 1, struct binder_write_read)
#define BINDER_SET_IDLE_TIMEOUT _IOW('b', 3, __s64)
#define BINDER_SET_MAX_THREADS _IOW('b', 5, __u32)
#define BINDER_SET_IDLE_PRIORITY _IOW('b', 6, __s32)
#define BINDER_SET_CONTEXT_MGR _IOW('b', 7, __s32)
#define BINDER_THREAD_EXIT _IOW('b', 8, __s32)
#define BINDER_VERSION _IOWR('b', 9, struct binder_version)
#define BINDER_GET_NODE_DEBUG_INFO _IOWR('b', 11, struct binder_node_debug_info)
#define BINDER_GET_NODE_INFO_FOR_REF _IOWR('b', 12, struct binder_node_info_for_ref)
#define BINDER_SET_CONTEXT_MGR_EXT _IOW('b', 13, struct flat_binder_object)
#define BINDER_FREEZE _IOW('b', 14, struct binder_freeze_info)
#define BINDER_GET_FROZEN_INFO _IOWR('b', 15, struct binder_frozen_status_info)
#define BINDER_ENABLE_ONEWAY_SPAM_DETECTION _IOW('b', 16, __u32)
#define BINDER_GET_EXTENDED_ERROR _IOWR('b', 17, struct binder_extended_error)

对应的源码:

// source code: http://aospxref.com/android-14.0.0_r2/xref/frameworks/native/libs/binder/IPCThreadState.cpp
void IPCThreadState::threadDestructor(void *st) {
	ioctl(self->mProcess->mDriverFD, BINDER_THREAD_EXIT, 0);
}

status_t IPCThreadState::getProcessFreezeInfo(pid_t pid, uint32_t *sync_received, uint32_t *async_received) {
    return ioctl(self()->mProcess->mDriverFD, BINDER_GET_FROZEN_INFO, &info);
}

status_t IPCThreadState::freeze(pid_t pid, bool enable, uint32_t timeout_ms) {
    return ioctl(self()->mProcess->mDriverFD, BINDER_FREEZE, &info) < 0);
}

void IPCThreadState::logExtendedError() {
    ioctl(self()->mProcess->mDriverFD, BINDER_GET_EXTENDED_ERROR, &ee) < 0);
}

status_t IPCThreadState::talkWithDriver(bool doReceive) {
    // 实际Binder调用通信
    return ioctl(mProcess->mDriverFD, BINDER_WRITE_READ, &bwr);
}

快速过滤:

static int ioctl_hook(int fd, int cmd, void* arg) {
    if (cmd != BINDER_WRITE_READ || !arg || g_ioctl_disabled) {
        return g_ioctl_func(fd, cmd, arg);
    }
}
如何解析

目标源码:
http://aospxref.com/android-14.0.0_r2/xref/frameworks/native/libs/binder

重点解析发送(即
BC_TRANSACTION

BC_REPLY
)和接收(即
BR_TRANSACTION

BR_REPLY
)的类型数据。

如何修改数据

修改数据分为以下几种:

1)修复调用时参数数据。

2)修复调用后返回的结果数据。

如果数据修复不改变当前数据的长度,只是内容的变化,则可以直接通过地址进行修改。否则需要创建新的内存进行修改后将新的数据地址设置到
BINDER_WRITE_READ
结构的
buffer
成员。此时处理好内存的释放问题。

3)直接拦截本次调用。

为了保障稳定性,不打断
Binder
的调用流程(通常这也是拦截和逆向方案保障稳定的最重要原则之一)。我们可以将目标函数
code
修改成父类处理的通用方法,然后通过修复调用的返回值即可完成拦截。

方案实现

数据解析

Binder调用数据结构如下:

解析
bwr

bwr

binder_write_read
,从源码中了解到
ioctl

BINDER_WRITE_READ
类型的
arg
数据结构为:

struct binder_write_read {
  // 调用时传入的数据
 	binder_size_t write_size;// call data
 	binder_size_t write_consumed;// call data
 	binder_uintptr_t write_buffer;// call data
  
	// 结果返回数据
 	binder_size_t read_size;// recv data
 	binder_size_t read_consumed;// recv data
 	binder_uintptr_t read_buffer;// recv data
};

不管是传入还是返回的数据,都是一组BC命令或BR命令,也就是说一次调用上层会打包几个命令一起传递。因此我们需要通过循环来找到我们的命令。

void binder_find_for_bc(struct binder_write_read& bwr) {
    binder_uintptr_t cmds = bwr.write_buffer;
    binder_uintptr_t end = cmds + (binder_uintptr_t)bwr.write_size;

    binder_txn_st* txn = NULL;
    while (0 < cmds && cmds < end && !txn) {
        // 由于每次Binder通信数据量的限制,Binder设计每次调用有且仅包含一个有效的参数命令,因此只要找到即可,其他类型则直接跳过忽略
        cmds = binder_parse_cmds_bc(cmds, txn);
    }
}

dump
数据如下:

write_buffer:0xb400007107d1d400, write_consumed:68, write_size:68
00000000:  00 63 40 40 14 00 00 00  00 00 00 00 00 00 00 00  .c@@............
00000010:  00 00 00 00 01 00 00 00  12 00 00 00 00 00 00 00  ................
00000020:  00 00 00 00 54 00 00 00  00 00 00 00 00 00 00 00  ....T...........
00000030:  00 00 00 00 00 4d 3a ac  70 00 00 b4 00 00 00 00  .....M:.p.......
00000040:  00 00 00 00                                       ....
BR_NOOP: 0x720c
BR_TRANSACTION_COMPLETE: 0x7206
BR_REPLY: 0
解析
txn

txn

binder_transaction_data
,Binder方法调用的方法参数信息定义如下:

struct binder_transaction_data {
 union {
     __u32 handle;
     binder_uintptr_t ptr;
 } target;// 目标服务句柄,server端使用

 binder_uintptr_t cookie;// 缓存的Binder进行访问
 __u32 code;//方法编号

 __u32 flags;// 标识,如是否为 oneway
 __s32 sender_pid;
 __u32 sender_euid;
 binder_size_t data_size;// 数据长度
 binder_size_t offsets_size;// 若包含对象,则对象数据大小
  
 union {
     struct {
         binder_uintptr_t buffer;// Binder方法参数值地址
         binder_uintptr_t offsets;// Binder方法参数对象数据地址
     } ptr;
     __u8 buf[8];
 } data;
};

dumo
数据如下:

Trace   : target:       1   cookie:       0   code:      23   flags:   0x12(READ REPLY)
Trace   :    pid:       0      uid:       0   size:     196    offs:8
Trace   : 00000000:  00 00 00 80 ff ff ff ff  54 53 59 53 1c 00 00 00  ........TSYS....
Trace   : 00000010:  61 00 6e 00 64 00 72 00  6f 00 69 00 64 00 2e 00  a.n.d.r.o.i.d...
Trace   : 00000020:  61 00 70 00 70 00 2e 00  49 00 41 00 63 00 74 00  a.p.p...I.A.c.t.
Trace   : 00000030:  69 00 76 00 69 00 74 00  79 00 4d 00 61 00 6e 00  i.v.i.t.y.M.a.n.
Trace   : 00000040:  61 00 67 00 65 00 72 00  00 00 00 00 85 2a 62 73  a.g.e.r......*bs
Trace   : 00000050:  13 01 00 00 00 38 dd 2a  71 00 00 b4 00 05 e9 31  .....8.*q......1
Trace   : 00000060:  71 00 00 b4 01 00 00 0c  1a 00 00 00 63 00 6f 00  q...........c.o.
Trace   : 00000070:  6d 00 2e 00 69 00 66 00  6d 00 61 00 2e 00 74 00  m...i.f.m.a...t.
Trace   : 00000080:  72 00 61 00 6e 00 73 00  65 00 63 00 2e 00 63 00  r.a.n.s.e.c...c.
Trace   : 00000090:  6f 00 6e 00 74 00 61 00  69 00 6e 00 65 00 72 00  o.n.t.a.i.n.e.r.
Trace   : 000000a0:  00 00 00 00 08 00 00 00  73 00 65 00 74 00 74 00  ........s.e.t.t.
Trace   : 000000b0:  69 00 6e 00 67 00 73 00  00 00 00 00 00 00 00 00  i.n.g.s.........
Trace   : 000000c0:  01 00 00 00                                       ....
Trace   : binder object offs:0x4c  type:0x73622a85  flags:0x113  ptr:0x2add3800  cookie:0x31e90500
解析服务名

Binder
通信数据头如下,即可解析出目标服务名:

void find_server_name(const binder_txn_st* txn) {
		const int32_t* ptr = reinterpret_cast<const int32_t*>(txn->data.ptr.buffer);
  	++ ptr;// skip strict model
    if (29 <= sdkVersion()) ++ ptr;// 10.0 <=, skip flags(ff ff ff ff)
		
  	int32_t nameLen = *ptr;
    const uint16_t* name16 = (const uint16_t*)(ptr+1);
}
解析方法名

Binder
通信数据中标识该服务方法的参数是
txn->code

AIDL
定义类在编译后会为每个方法自动生成静态的方法。

如定义的
Binder
接口方法为:

interface IDemo {
  void test();
  void test2();
}

则编译后生成的类为:

class IDemo$Stub {
   void test();
   void test2();
   
  static final int TRANSACTION_test = 1;
  static final int TRANSACTION_test2 = 2;
}

因此我们可以通过反射的方式,找到服务名对应的类所有静态成员变量,然后找到与
code
值相等的成员即为此方法。

这里可能需要解决私有API的限制解除问题。

// 可直接使用工程工具类
TstClassPrinter.printStubByCodes("android.app.IActivityManager", 13, 16, 67);

日志输出如下:

解析数据

首先需要借助数据封装类
Parcel

// souce code:
// http://aospxref.com/android-14.0.0_r2/xref/frameworks/native/include/binder/Parcel.h
// http://aospxref.com/android-14.0.0_r2/xref/frameworks/native/libs/binder/Parcel.cpp

借助该类可以解析一些比较简单的数据,快速的找到目标内容。而对于比较复杂的数据,如参数值为
Intent
,该参数类型嵌套了多层的
Parcelable
成员,因此在
native
层通过
Parcel
来解析,兼容性比较差。因此我们选择通过回调到Java层来解析,修改后再格式化为
native

buffer
数据。这里需要处理好
Java

native
层的数据交换问题,以及回收。

native
层:

// 创建
jobject obtain(JNIEnv* env) {
    jclass jcls = env->FindClass("android/os/Parcel");
    jmethodID method = env->GetStaticMethodID(jcls, "obtain", "()Landroid/os/Parcel;");
    if (!method) return NULL;

    mParcelObj = env->CallStaticObjectMethod(jcls, method);
    if (!mParcelObj) return NULL;

    if (0 < mUparcel->dataSize()) {
        method = env->GetMethodID(sParcelClass, "setDataPosition", "(I)V");
        if (method) {
            unmarshall(env, mUparcel->data(), mUparcel->dataSize());
            env->CallVoidMethod(mParcelObj, method, mUparcel->dataPosition());
        }
    }

    return mParcelObj;
}

// 回收
void recycle(JNIEnv* env) {
    jclass jcls = env->FindClass("android/os/Parcel");
    jmethodID method = env->GetMethodID(jcls, "recycle", "()V");
    if (method) {
        env->CallVoidMethod(mParcelObj, method);
    }
    if (mParcelObj) {
        env->DeleteLocalRef(mParcelObj);
    }
    mParcelObj = NULL;
}

Java
层:

public static void clearHttpLink(Parcel p/*IN*/, Parcel q/*OUT*/) {
    try {
        Intent ii = Intent.CREATOR.createFromParcel(pp);
		// TODO something ...
      	
        // write new data
        q.appendFrom(p, p.dataPosition(), p.dataAvail());
    } catch (Throwable e) {
        e.printStackTrace();
    }
}

数据拦截

Binder的数据解析和打印不会改变原数据内容,因此相对简单,如果要对数据进行修改,则相对复杂一些。修复的数据需要替换原数据,因此需要进行如下操作。

1、数据替换。


txn
中方法参数的数据指针指向新创建的数据区。


int binder_replace_txn_for_br(binder_txn_st *txn, ParcelEx* reply, binder_size_t _pos) {
    size_t size = reply->ipcDataSize();
    uint8_t* repData = (uint8_t*)malloc(size + txn->offsets_size);
    memcpy(repData, reply->data(), size);
    if (0 < txn->offsets_size) {
        binder_replace_objects(txn, repData, _pos, ((int)size) - ((int)(txn->data_size)));
    }

    txn->data.ptr.buffer = reinterpret_cast<uintptr_t>(repData);
    txn->data_size = size;
    return 0;
}

2、修正对象指针。

如果传入的参数包含Binder对象,如
register
方法的
Observe
。因此修复的数据可能导致偏移的地址前移或者后移,因此需要重新计算偏移,如:

void replaceObjects(binder_txn_st *txn, uint8_t* objData, binder_size_t _pos, int _off) {
    binder_size_t* offs = reinterpret_cast<binder_size_t*>(txn->data.ptr.offsets);
    unsigned count = txn->offsets_size / sizeof(binder_size_t);

    while (0 < count--) {
        if (0 != memcmp(objData + (int)(*offs), (uint8_t*)txn->data.ptr.buffer + (int)(*offs), sizeof(binder_size_t))) {
            *offs += _off;
        }
        ++ offs;
    }
}

3、内存释放。

需要保存原地址
A
和新的地址
AA
的映射关系到自定义的内存池中。


Binder
通信命令出现
BC_FREE_BUFFER

BR_FREE_BUFFER
时,则通过该命令要释放的
AA
地址,然后从内存池找到与之对应
A
的地址,并设置回去让上层继续释放,完成内存使用的闭环。

case BC_FREE_BUFFER:
{
    uintptr_t* buffPtr = (uintptr_t *)cmd;
    uintptr_t ptr = MemPool::detach(*buffPtr);
    if (__UNLIKELY(0 != ptr)) {
        *buffPtr = ptr;// set origin buffer
    }
    cmd += sizeof(uintptr_t);// move to next command
}   break;

附:

如果你有需要,可以直接使用我们已经封装好的
SDK
来实现相应的功能,该项目已经开源,可以直接使用,参考
【集成文档】

引言

对于编写应用程序,尤其是要部署上线投入生产使用的应用,QA是其中重要的一环,在过去的工作经历中,我参与的项目开发,大多是由测试同学主要来把控质量的,我很少编写前端方面的测试代码,对于测试工具的使用,也基本停留在一个小玩具的样子,所以接触的也少,回忆上一次写单元测试,还是在一个vue3的课程中使用jest实现TDD,记得之前有的时候面试,会被问到有没有在项目中用单测,但是因为以前工作中大多数时候需求排期都只考虑开发的时间,就很少考虑到这方面,然后就,面试中这方面也说不出什么东西,最近因为一个偶然的机会,我接触了puppeteer用来做前端自动化测试,用着还感觉蛮有点小意思。

puppeteer能做什么

puppeteer是一个Node.js库,通过
puppeteer
的文档,我们可以快速的了解我们能使用puppeteer来做些什么:

Most things that you can do manually in the browser can be done using Puppeteer! Here are a few examples to get you started:

  • Generate screenshots and PDFs of pages.
  • Crawl a SPA (Single-Page Application) and generate pre-rendered content (i.e. "SSR" (Server-Side Rendering)).
  • Automate form submission, UI testing, keyboard input, etc.
  • Create an automated testing environment using the latest JavaScript and browser features.
  • Capture a
    timeline trace
    of your site to help diagnose performance issues.
  • Test Chrome Extensions
    .

第一句作为总领,点出了puppeteer可以模拟用户与浏览器的交互。包括页面截图、生成SPA的预渲染内容、触发用户交互事件等等,可以用于进行UI和功能测试,另外可以看出除了普通的前端测试外,还可以作为爬虫工具使用。本文针对简单的用户交互事件的模拟和页面截图,实现一个puppeteer的使用示例。

准备工作

  • 首先在使用之前,需要先安装依赖

    npm i puppeteer
    # or using yarn
    yarn add puppeteer
    # or using pnpm
    pnpm i puppeteer
    

    我这里使用yarn global进行了全局的安装。

  • 然后我们来准备待测试的页面

    我这里准备了一个简单的页面,直接预览如下所示:


    puppeteer

    页面分为两部分,最上面是标题,下面展示的是一个canvas。我们即将测试的内容除了基本的请求页面和获取页面元素外,主要有两项功能,分别为:


    • 点击canvas后展示一个弹窗,使用文字描述“土”与其他五行的关系,测试点击事件的模拟和弹窗的展示
    • 点击canvas后在canvas上绘制,使用图像描述“土”与其他五行的关系,测试puppeteer的截图功能并引入
      blink-diff
      模块,用于图像的对比

接下来我们就可以开始编写测试代码。

使用示例

因为是模拟交互,所以会有许多异步的操作,我们可以通过
await
获取结果,所以这个例子中的代码会使用异步函数
async
来包裹。

另外由于要模拟操作,所以选择器也是核心功能,类似于
document.querySelector

document.querySelectorAll
的作用,puppeteer使用css选择器语法的超集进行查询,也就是说我们可以使用
.class

#id
等css选择器来进行元素查询。

基本功能

以下是基本的代码:

/*
 * check.js
*/
const puppeteer = require('puppeteer');

(async () => {
    // Launch the browser and open a new blank page
    const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
    const page = await browser.newPage();
  
    // Set screen size
    await page.setViewport({width: 1920, height: 1080});
  
    // Navigate the page to a URL
    await page.goto('http://0.0.0.0:8080');

    // 关闭puppeteer
    browser.close();

    // ...
})()

在模拟交互前,我们需要先启动浏览器并打开页面,以上代码就可以完成这些操作:

  • puppeteer.launch:启动浏览器

    在启动浏览器时,我们可以设置一些启动参数,这里的
    '--no-sandbox'
    代表取消沙盒模式,放开权限,
    --disable-setuid-sandbox
    也是类似的作用,此两者的区别可以参考这个
    discuss

  • browser.newPage:可以理解为打开一个浏览器tab

  • page.setViewport:设置视窗尺寸

  • page.goto:跳转页面到指定地址,这里跳转到了我们本地启动的8080服务页面

  • browser.close:关闭浏览器。我们可以在获取到数据后就进行关闭操作,再在后续中使用抓取到的数据

可以看到在每步操作之前,我们都使用了await来等待操作完成,每一步都需要等待上一步操作完毕才能开始。

接下来我们就可以开始获取页面上的元素,比如示例页面上的h3标签。

const elm = await page.waitForSelector('h3');
// OR
const elm = await page.$('h3');

console.log(elm);
// CdpElementHandle {
//   handle: CdpJSHandle {},
//   [Symbol(_isElementHandle)]: true
// }
console.log(elm.innerText); // undefined

可以通过
.waitForSelector
或简写的
.$
方法获取元素,可以看到打印出来的并不是DOM对象,而是一个经过封装的CdpElementHandle类型的对象,因此我们无法通过
elm.innerText
的方式来获取h3标签内的文本内容,似乎这个选择器方法只能用于判断页面上是否存在某个或某类匹配的元素。

如果想获取元素对应的DOM属性,可以使用
Page.$eval()
来实现,用法如下所示:

const elmText = await page.$eval('h3', h3 => h3.innerText);
console.log(elmText); // "土"与其他五行的关系

判断DOM属性

在本文的测试页面中,实现了点击canvas显示弹窗的功能,弹窗的显示是通过js代码添加样式类实现的,并且会在2s后关闭弹窗的显示,所以我们需要测试样式类的添加和移除。

同样的,我们需要先获取到canvas元素。

const canvas = await page.$('canvas');

接着模拟点击,并获取弹窗对应div的classList。

await canvas.click();
const popupClassList = await page.$eval('.popup-dialog', popup => popup.classList);
console.log(popupClassList); // { '0': 'popup-dialog', '1': 'visible' }

可以看到弹窗的classList中按照预期出现了代表显示的样式类visible。

接着我们继续测试2s后弹窗关闭。

await new Promise(r => setTimeout(r, 2000));
const postPopupClassList = await page.$eval('.popup-dialog', popup => popup.classList);
console.log(postPopupClassList); // { '0': 'popup-dialog' }

可以看到在2s后,样式类
visible
按照预期被移除了。这里我们使用一个promise来计时。

截图功能

最后我们来使用puppeteer的截图功能。在使用之前,先把测试页面的点击canvas显示弹窗改为绘制图像,然后我们来测试。

在截图之前,我们需要先指定一个目录用于存放截图,这里我直接创建一个imgs文件夹,然后编写以下代码:

const imgDir = './imgs/';
canvas.screenshot({ path: `${imgDir}canvas.png` });

执行
node check.js
后,我们就可以看到imgs目录下生成了一张图片,和我们在浏览器中看到的是一样的。

puppeteer-2

如果这是一个UI效果图,我们可以把他重命名为
target.png
,然后使用代码实现后,配合使用
blink-diff
模块,对比UI设计图与实际代码实现所存在的差异大小;
blink-diff
模块也可以通过NPM来安装。blink-diff是一个轻量级的图片对比工具,以下是一个简单的使用展示:

const puppeteer = require("puppeteer"),
    BlinkDiff = require('blink-diff');

// ...

// 关闭puppeteer
browser.close();

const diff = new BlinkDiff({
	imageAPath: imgDir + 'target.png', // ui
	imageBPath: imgDir + 'canvas.png', // 页面截图
	imageOutputPath: imgDir + 'Diff.png', // 差异对比图
	threshold: 0.02
});

因为已经得到截图,所以此时已经不需要浏览器了,
new BlinkDiff
可以在puppeteer关闭后执行。

imageAPath和imageBPath分别是设计图和页面截图的存放路径,imageOutputPath输出两张图片的差异对比图,threshold是一个百分比阈值,当差异比例低于该值时忽略差异,在这里这就是说,当差异比例低于2%,就认为两张图是相同的。

接下来就通过调用
diff.run()
方法来执行对比:

diff.run(function (error, result) {
    if (error) {
        throw error;
    } else {
        let rel = Math.round((result.differences /
            result.dimension) * 100);
        console.log(result.code);
        console.log(diff.hasPassed(result.code));
        console.log(diff.hasPassed(result.code) ? 'Passed' : 'Failed');
        console.log('总像素:' + result.dimension);
        console.log('发现:' + result.differences + ' 差异,差异占⽐'
            + rel + "%");
    }
});

当正常执行后,会返回一个result对象包含对比结果的信息。

result.differences
表示存在不同的像素数量,
result.dimension
表示像素的总数量,因此这里rel计算得到的就是像素的差异比例。

result.code
就是一个结果状态码,调用
diff.hasPassed
方法会根据diff的配置对状态码进行解析,从而得出通过或失败的判断。

到这里为止就是一个截图功能和图像对比的简单示例,看上去使用起来挺不错的样子,但实际还是存在一些问题,比如我最近遇到的,使用渐变函数设置样式,得到的截图会存在问题,并没有得到应用渐变后的样式截图,不知道是兼容上的问题还是我的使用方式问题,所以暂时我使用了getComputedStyle作为替代方案。

总结

好啦,以上就是puppeteer的简单使用,有感兴趣的小伙伴可以继续阅读
官方文档
深入研究。