Zhihao's Studio.

让iMac与MacBook高效协同工作——mouseSync开发心得

Word count: 2,961 / Reading time: 11 min
2017/09/23 Share

开发mouseSync的初衷

或许是不想让买显示器送主机的笑话成真,Apple将iMac 2015之后的版本关闭了纯显示器模式。Apple store里LG的显示器挺贵的,27寸5K版本售价超过了1万元,衬托出了iMac 27寸13000+的定价之良心。考虑到两者颜值的差别,在看脸的时代,iMac的销量还是很不错的。iMac的用户很可能也是MacBook的用户,因此让iMac和MacBook协同工作是很有意义的一件事。当然,也同样适用于两台MacBook之间的协同工作。

为了让两台Mac协同工作,我发现最痛苦的事情莫过于需要两套触摸板(鼠标)和键盘。在谷歌里搜了半天,发现了很多通过蓝牙将一台mac的键盘作为另一台mac键盘的软件,但是将一台mac的鼠标作为另一台mac的鼠标的软件却很少。比较知名的软件是Synergy,但是它的普通版售价19刀、pro版售价29刀,作为一个工程师的我不愿意掏这钱,于是mouseSync出现了。和以前一样,它是开源的,托管在github上,地址是:https://github.com/zhihaozhang/mouseSync

mouseSync开发步骤

开发任何东西,我首先问自己的事情便是:大象放进冰箱一共分几步?要想让一台mac的鼠标事件同步给另一台mac,首先需要将两台电脑连接起来,建立一条稳定的专用通道。然后每当宿主机发生相应时间的时候,发通知给另一台Mac,让它的鼠标也跟着移动。

为什么选用蓝牙

考虑到协同工作肯定两台电脑靠的很近,在蓝牙的接收范围内。再考虑到蓝牙4.0标准的耗电量很低,而且即使在没有wifi的情况下依然可以工作,因此我选用了蓝牙技术来建立专用通道。蓝牙框架中值得注意的概念大约有5个。我想举一个我生活中的例子来介绍下面的概念:我每天会用一个体重计量一下体重,然后体重计会通过蓝牙传到我的手机App里,这样我就可以知道我每天的体重以及近期体重的趋势。

  1. Central(数据中心) 用来展示信息的设备,对应上面例子中的手机。
  2. Peripheral(外围设置) 原始数据采集,对应上面例子中的体重计。
  3. Service(服务) 指外围设置提供的各种能力,对应上面例子中体重计称重的能力。
  4. Characteristic(特征值) 服务中的数据成为特征值,对应上面例子中的体重数据、体质数据等其他数据。
  5. UUID 服务或特征值的唯一标志。每个服务或特征值均有一个唯一标识。比如我的体重计不仅能称重,还能测体脂、水分等特征值,手机App端能区分体重数据和水分数据,就靠UUID。当然,UUID的值需要数据中心和外围设置通讯前就约定好。

蓝牙通讯的流程图清楚的解释了蓝牙通讯的整个过程,这个过程类似TCP/IP的三次握手,不过没有那么严格。简单的来说,这个过程就是外设将自己的特征值通过服务的形式打包发布,通过建立起来的蓝牙通道广播给数据中心。首先,数据中心扫描蓝牙信号,发现外设后,与外设建立起链接,请求服务携带的数据,一次最基本的读取数据交换就算是完成了。当然,数据中心也可以发指令给外设。对于经常变化的特征值,数据中心可以订阅它,每当它发生变化时,可以实时通知数据中心,以便数据中心做出相应变化。具体到Cocoa框架,蓝牙的相关类见下图。

Peripheral相关类

CBPeripheralManager是管理外设的,提供了发布服务,广播方法,CBPeripheralManagerDelegate是它的代理方法,考虑到了外设和数据中心之间数据交换是异步的,它定义了一组协议方法,根据不同的状态出发回调代理方法完成双方数据交换。

CBPeripheral类是建立蓝牙连接后,给CBCentral调用peripheral外设提供的对象。CBPeripheralDelegate是它的代理协议,提供辅助CBPeripheral完成服务发现、数据获取、订阅数据后的回调方法。

Central相关类

CBCentral是数据中心类,完成蓝牙设备发现,建立连接,请求服务数据功能。CBCentralDelegate是它的代理协议,辅助它central提供Peripheral发现,建立连接后的回调。

service相关类

CBMutableService是可变类型的服务对象类,UUID和特征值可以修改。与它对应的是CBService,属性不可修改的服务对象类。

characteristic相关类

CBMutableCharacteristic是可变类型的特征值对象类,UUID、properties属性可以修改。与它对应的是CBCharacteristic类,上述属性不能修改。

回到mouseSync,两台Mac中,负责采集触摸板事件的那台Mac是外围设置,另一台Mac是数据中心。建立起蓝牙物理通道之后,接下来要做的事是监听外围设置的触摸板事件,然后通知数据中心的触摸板跟着该事件进行响应。

在外围设备端监听鼠标事件

鼠标事件的监听分为全局监听和应用内监听,显然全局监听更符合我们的要求。对键盘鼠标事件的监听,主要调用的是NSEvnet里的addGlobalMonitorForEvents方法。鼠标事件有很多种,这里我选取了比较重要的几种,分别是移动(.mouseMoved)、单击(.leftMouseDown/up)、双击(特殊处理)、右击(.rightMouseDonw/up)、触摸板的上下左右滑动(.scrollWheel)和鼠标的上下滑动。

移动事件

移动比较好处理,这里我关心的是当前位置相比于上一个位置x和y方向的坐标变动情况,因此需要一个变量记录上一个位置,然后将x和y方向的变动通过蓝牙发送给数据中心,让数据中心的鼠标也进行相应的变动。值得注意的是,Mac的坐标(0,0)点在左下方,因此移动鼠标的时候需要进行相应的转换。

单击事件

单击事件分成两个步骤,第一个步骤是左键按下,第二个步骤是左键弹起来。只有两个事件都发生,我们才认为整个单击的过程完成了。单击事件并不需要传外围设置的坐标给数据中心,直接人数据中心在当前的鼠标位置执行单击事件即可。

双击事件

双击事件和单击事件很像,监听事件传入的event对象有一个clickCount属性,如果该属性的值大于等于2,那么就认为它是双击事件。后面的步骤跟单击事件一样。

右击事件

右击事件跟单击事件就更像了,只是将名称由left改成了right。

滑动事件

滑动事件在x和y方向的变化值分别对应的是event对象里的deltaX和deltaY,将这两个值传给数据中心,并有数据中心跟着这两个值进行相应的滑动即可。通常情况下,用户只会在一个方向上进行滑动(鼠标只能上下滑动),因此我将鼠标左右和上下的滑动分成了两个部分进行处理,传递的时候判断外围设置在哪个方向上更显著,让数据中心进行相应的滑动。

在数据中心端模拟鼠标事件

单击、右击事件的模拟

前面提到过了,单击和右击事件非常像,因此举同一个例子就可以说明问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func mouseMoveAndClick(onPoint point: CGPoint) {
guard let moveEvent = CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: point, mouseButton: .left) else {
return
}
guard let downEvent = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left) else {
return
}
guard let upEvent = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left) else {
return
}
moveEvent.post(tap: CGEventTapLocation.cghidEventTap)
downEvent.post(tap: CGEventTapLocation.cghidEventTap)
upEvent.post(tap: CGEventTapLocation.cghidEventTap)
}~~

思路是通过CGEvent创建一个事件,然后调用该事件的post方法告诉系统,执行该事件。

鼠标移动事件的模拟

移动事件的模拟跟上面思路一样,要注意的是OS X屏幕的坐标系统,x和y方向的变化是加还是减。

双击事件的模拟

开始以为双击事件的模拟无非就是模拟两次单击事件,让他们之间的时间间隔非常小。但经过尝试,该方式行不通。在网上搜了一些solution,最后居然是在我最喜欢的炉石传说的一个插件里发现的答案。 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
` func doubleClick(at location: NSPoint) {
let source = CGEventSource(stateID: .privateState)
var click = CGEvent(mouseEventSource: source, mouseType: .leftMouseDown,
mouseCursorPosition: location, mouseButton: .left)
click?.setIntegerValueField(.mouseEventClickState, value: 1)
click?.post(tap: .cghidEventTap)
var release = CGEvent(mouseEventSource: source, mouseType: .leftMouseUp,
mouseCursorPosition: location, mouseButton: .left)
release?.setIntegerValueField(.mouseEventClickState, value: 1)
release?.post(tap: .cghidEventTap)
click = CGEvent(mouseEventSource: source, mouseType: .leftMouseDown,
mouseCursorPosition: location, mouseButton: .left)
click?.setIntegerValueField(.mouseEventClickState, value: 2)
click?.post(tap: .cghidEventTap)
release = CGEvent(mouseEventSource: source, mouseType: .leftMouseUp,
mouseCursorPosition: location, mouseButton: .left)
release?.setIntegerValueField(.mouseEventClickState, value: 2)
release?.post(tap: .cghidEventTap)
}

滑动事件的模拟

滑动事件的模拟,swift目前支持的不是很好,我在Stack Overflow上找到了一个比较tricky的方法。需要swift和C混编,借助C来完成任务。关于swift和C/OC的混编内容比较多,这里不展开讲了,感兴趣的可以研究我的开源代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
` void createScrollWheelEventY(float x) {
CGEventRef upEvent = CGEventCreateScrollWheelEvent(
NULL,
kCGScrollEventUnitPixel, 2, x*5, 0 );
CGEventPost(kCGHIDEventTap, upEvent);
CFRelease(upEvent);
}
void createScrollWheelEventX(int y) {
CGEventRef upEvent = CGEventCreateScrollWheelEvent(
NULL,
kCGScrollEventUnitPixel, 2, 0, y*5);
CGEventPost(kCGHIDEventTap, upEvent);
CFRelease(upEvent);
}

在这里遇到了一个诡异的问题,上下滑动可以很好的工作,但是左右滑动死活不能和预期的一样,单步跟踪后发现传入函数的确实是float类型的正确值,但是创建upEvent的时候,该变量的值突变了。后面将float类型改成了int类型,又可以正常的工作了。如果知道为什么的同学可以留言告诉我。

demo



后记

开发mouseSync的过程中,让我更深刻的理解了Cocoa的蓝牙框架、键盘鼠标事件。纸上得来终觉浅,绝知此事要躬行。

另外,mouseSync的开发还没有完成,目前存在的问题是外围设备这边的鼠标容易误操作,因此我准备将外围设备的鼠标控制在一个window内,一旦移除这个window,就强行将光标移动回window中心。通过快捷键控制软件的开启和关闭。还有一个比较重要的功能正在开发中,那就是我非常喜爱的三指拖移功能,敬请期待!

你对mouseSync的功能还有哪些期待或建议呢?不妨留言告诉我吧!

开发和写本文过程中参考了以下资料,在这表示感谢:
【1】 macOS应用开发,赵剑 张帆 合著
【2】HSTracker的github开源代码 https://github.com/HearthSim/HSTracker/blob/master/HSTracker/Utility/Automation.swift
【3】Stack Overflow: https://stackoverflow.com/questions/42813264/cocoa-application-scroll-programmatically
【4】 ios蓝牙技术:http://www.jianshu.com/p/c1154179da8a

我会在微信公众号【骨灰级果粉】中不定期分享我个人的macOS/ios开发心得和开发笔记,也会在里面发表对于苹果产品/框架/趋势的拙见,希望爱好科技产品或者苹果生态圈的开发者关注。相信本公众号一定能给您带来收获和启发。

~~如果我的博客内容帮助到了您,您可以使用支付宝/微信扫码请我喝一听可乐;如果您对我文章的内容有疑惑,欢迎留言或发email至zhihaozhang@me.com与我进一步交流~~

CATALOG
  1. 1. 开发mouseSync的初衷
  2. 2. mouseSync开发步骤
    1. 2.1. 为什么选用蓝牙
      1. 2.1.1. Peripheral相关类
      2. 2.1.2. Central相关类
      3. 2.1.3. service相关类
      4. 2.1.4. characteristic相关类
  3. 3. 在外围设备端监听鼠标事件
    1. 3.1. 移动事件
    2. 3.2. 单击事件
    3. 3.3. 双击事件
    4. 3.4. 右击事件
    5. 3.5. 滑动事件
  4. 4. 在数据中心端模拟鼠标事件
    1. 4.1. 单击、右击事件的模拟
    2. 4.2. 鼠标移动事件的模拟
    3. 4.3. 双击事件的模拟
    4. 4.4. 滑动事件的模拟
  5. 5. demo
  6. 6. 后记