iOS事件传递分析

一个事件总是沿着明确的路径传输直到它被分发给一个对象可以处理它。首先UIApplication的单例从事件处理队列的头部拿到,并为了处理开始分配。一般,它将事件分配给UIAppldelegatekeyWindow对象,由这个对象传递给一个已经初始过的的对象处理它。

  • 触摸事件 对于触摸事件来说,window对象首先尝试将事件分发给产生这个事件的视图,这个视图被称为hit-testview,寻找这个视图的过程就是hit-testing.

  • 动作和远程控制事件 对于这些事件来说,视窗对象通常将这些事件传给第一个响应对象来处理。

Hit-Testing

在父视图之内

Hit-Testing是一个过程,结果就是返回处理事件的hit-testview。寻找的顺序就是从这个触摸点所在的最高层的superView开始,依次遍历其subViews,看其是否包含这个touch,不包含则跳过,如果包含,那就遍历当前这个子视图的subViews, 就这样一直找到包含此touch的最低级的视图(没有子视图了),那么它就是hit-testview了。
这是主要使用hitTest:withEvent:方法返回响应事件的视图,hitTest:withEvent:在被调用开始时在其方法内部调用pointInside:withEvent:方法,如果从hitTest:withEvent:传过来的坐标在这个视图内,那么pointInside:withEvent:将返回YES,然后自己的retrun YES的子视图继续递归调用hitTest:withEvent:
如果坐标点不在视图范围内,首次调用pointInside:withEvent:(也就是最高层父视图调用)就返回NO,这个点就被忽略,hitTest:withEvent:则返回nil。如果父视图的一个子视图返回nil,那么这个子视图上面所有的视图都会忽略。不在这个子视图上,那么就不会在这个子视图的子视图上。这样就意味着任何视图的点如果在其父视图的外部,那么这些点上的事件就不会被接收。

在父视图之外

在父视图之外的子视图上的事件如果想被接收,需要重写hitTest:withEvent:方法。 从父视图拿到这个点,将其手动转换到其归属的视图中坐标,然后再执行hit-testing过程。

OC实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
// 1. 在target视图坐标系统内换算坐标
CGPoint pointForTargetView = [self.targetView convertPoint:point fromView:self];
// 2. 接收事件的点在targetView内
if (CGRectContainsPoint(self.targetView.bounds, pointForTargetView)) {
// 3. 在TargetView内进行事件分发
return [self.targetView hitTest:pointForTargetView withEvent:event];
}
return [super hitTest:point withEvent:event];
}

Swift实现
1
2
3
4
5
6
7
8
9
10
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let pointForTargetView = self.targetView.convert(point, from: self)
if self.targetView.bounds.contains(pointForTargetView) {
return self.targetView.hitTest(pointForTargetView, with: event)
}
return super.hitTest(point, with: event)
}

hit-testView拥有第一优先权响应事件,如果不能响应,那么继续在响应链中分发处理。

响应链

大部分事件都需要依赖响应链来分发。响应链由一系列的响应体组成,从第一个响应体到最后的UIApplication对象。如果第一个响应体不能响应,那么沿着响应链依次传递下去。

UIResponder是所有可响应对象的基类,可以响应并处理事件。UIApplication UIViewController UIView的对象都是响应体,Core Aniamtion Layer不是响应体。
第一响应体被定位为第一个接受事件,一般来说,第一响应体通常是一个UIView对象。一个对象成为第一响应体需要实现:

  • 重写canBecomeFirestResponder方法,并返回YES
  • 接收becomeFirstResponder消息。如果必要,一个对象可以发送自己的这条消息

第一响应体也要先定义再使用

响应链传递(图取自苹果文档)

这两种传递方式都是从一个UIView对象开始到UIApplication对象结束,都是视图层级里面由低到高进行传递。区别点就是当一个UIViewController对象A作为childViewController被另外一个UIViewController对象B管理时,如果A中view属性对象不能处理事件时,它会将事件先返回给B,由B中视图继续传递。

当自定义视图去处理事件时,不要直接将事件或者消息通过nextResponder在传递链中传递,调用父类的当前处理事件方法实现,让UIKit替你自动处理响应链的遍历。

Motion Events 动作事件

当用户移动、摇晃、倾斜他们设备的时候会产生动作事件,动作事件可以被设备硬件检测到,例如加速计和陀螺仪。

从设备中获取当前方向

如果仅需要获取当前方向而不是方向的确切向量的话可以使用UIDevice这个类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
// 打开加速计硬件 开始接收加速计事件
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
NotificationCenter.default.addObserver(self, selector: #selector(orientationChanged(notification:)), name: Notification.Name.UIDeviceOrientationDidChange, object: nil)
print("current device orientation is \(UIDevice.current.orientation.rawValue)")
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
deinit {
// 不用的时候关闭通知
UIDevice.current.endGeneratingDeviceOrientationNotifications()
NotificationCenter.default.removeObserver(self, name: .UIDeviceOrientationDidChange, object: nil)
}
@objc fileprivate func orientationChanged(notification: Notification) {
// 响应设备发生变化
print("device orientation changed \(UIDevice.current.orientation.rawValue)")
}

摇晃事件

当用户摇晃设备时,iOS将会计算加速计数据。如果这些数据满足某些条件时,iOS推测为摇晃手势并创建一个UIEvent对象呈现它。然后iOS将事件传递给当前正使用的App。App可以同时响应摇晃事件和设备方向变化。

Motion EventTouch Event简单,系统会在一个动作事件开始、结束的时候告诉App,但是任何个人动作事件除外。一个动作事件包含事件类型(UIEventTypeMotion)、事件子类型(UIEventSubtypeMotionShake)和时间戳。

指明第一响应者

想处理这个事件必须的有一个响应对象作为第一响应者。如何设置第一响应者请参考前面响应链。Motion Event也是在通过响应链来传递,如果一直传递到window都没有响应的话,并且applicationSupportsShakeToEdit属性为YES,iOS会显示一个撤销和重做的菜单。

实现动作事件处理方法

动作事件处理方法就3个motionBegan:withEventmotionEnded:withEventmotionCancelled:withEvent:。如果想使用动作事件的话,至少得实现motionBegan:withEventmotionEnded:withEvent中的一个方法。

override func motionEnded(_ motion: UIEventSubtype, with event: UIEvent?) {
    if motion == .motionShake {
        // TODO - 接收到摇晃后逻辑处理
        let alert = UIAlertController(title: "Tip", message: "you shake the device!", preferredStyle: .alert)

        let action = UIAlertAction(title: "OK", style: .default, handler: nil)
        alert.addAction(action)

        self.navigationController?.present(alert, animated: true, completion: nil)
    }
}

// 摇晃时间过长的话就会被取消
override func motionCancelled(_ motion: UIEventSubtype, with event: UIEvent?) {
    print("shake motion cancel")
}

设置和请求硬件能力

info.plist中设置 加速计accelerometer 陀螺仪gyroscope

  1. Required device capabilities对应的数组中添加
  2. 配置UIRequiredDeviceCapabilitieskey

利用Core Motion捕获设备移动

工作原理

Core Motion主要获取加速计和陀螺仪的原始数据并将数据传递给app处理。Core Motion利用独特的运算法则去处理收集到数据,因此可以呈现比较精确的信息。这个处理过程在Core Motion自己拥有的线程中进行。

Core MotionUIKit是截然不同的。它不关联UIEvent对象,也不使用响应链。Core Motion简单直接地将事件分发给需要的app。

Core Motion事件主要由3部分组成,每一个都包含至少一个单位。

  • CMAccelerometerData 捕获每一个空间轴上的加速度
  • CMGyroData 捕获x、y、z轴上旋转度
  • CMDeviceMotion 封装几个不同的单位 包括高度以及对加速度和旋转度来说更容易使用的单位

CMMotionManager类是对Core Motion来说是中心获取点。你可以创建CMMotionManager的单例对象来明确更新间隔,开始更新请求,处理动作事件。多个CMMotionManager对象将会影响接收数据的效率。

Core Motion中所有封装数据的类都是CMLogItem的子类,这个类里面定义了时间戳,所以动作数据可以根据时间来追踪,并且记录到一个文件里面。一个app可以与前一个事件比较时间戳来更准确地设置更新间隔。

Core Motion获取装箱动作数据两种方式

  • Pull app请求开始更新,稍后定时抽查最近动作事件的单位量
  • Push app明确更新间隔 并实现处理数据的block,稍后请求开始更新,并传入Core Motion执行队列和处理block。Core Motion将事件分发给block,在所传的队列中执行。

    对于大部分app来说,尤其是游戏,推荐使用Pull方式。它通常最有效且代码到最少。Push使用收集数据的app。不管采用哪种方式,当app不再需要时记得关闭,这样可以节省电量。

设置更新间隔

当你利用Core Motion请求动作事件数据时,你需要明确一个更新间隔。根据自己的需求设置合理的间隔。间隔越长,收集数据的频率越低,消耗的电量越少,间隔越短则相反。

事件频率 使用场景
10 - 20 检测设备方向矢量
30 - 60 利用加速计实现实时用户输入的app和游戏
70 - 100 检测高频率事件的app 比如:快速点击或摇晃设备

10ms等同于100Hz,自己看需求换算一下啦

处理加速度事件

加速计测量的是x、y、z轴上的变化,每一次运动被捕获到CMAccelerometerData对象里面,封装了类型为CMAcceleration的结构。

Swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// MARK: - 开始更新
fileprivate func startUpdate() {
// 在OC里 可以将CMMotionManager以属性的方式创建在AppDelegate里面
// 使用单例对象 提升效率
let manager = CMMotionManager.sharedManager
if manager.isAccelerometerAvailable {
// setting update interval
manager.accelerometerUpdateInterval = 0.01
// use Pull type trigger start update request
manager.startAccelerometerUpdates()
}
}
// MARK: - 结束更新
fileprivate func stopUpdate() {
let manager = CMMotionManager.sharedManager
if manager.isAccelerometerActive {
manager.stopAccelerometerUpdates()
}
}
func refreshAcceleromate() {
let manager = CMMotionManager.sharedManager
let data = manager.accelerometerData
if data != nil {
let acceleration = data!.acceleration
accelerometerLabel.text = "x:\(acceleration.x) y:\(acceleration.y) z:\(acceleration.z)"
}
}

处理旋转度数据

这里处理跟Accelerometers逻辑一样。

Demo