目前主流的图形界面程序结构如下所示
实现一个图形界面程序, 最大的复杂性在于不同操作系统的使用接口完全不同, 差异非常巨大. 这给开发一个跨平台的图形界面程序带来巨大挑战.
不过, 尽管操作系统的使用接口有异, 但基本的大逻辑差不多. 今天我们从同一段视角来看待. 谈谈图形界面程序框架.
事件
无论是什么桌面操作系统, 每个进程都有一个全局的时间队列(Event Queue). 当我们在键盘上按了一个键, 移动或者点击鼠标, 触摸屏幕等等, 都会产生一个事件(Event), 并且由操作系统负责将它人道进程的事件队列. 整个过程大体如下
- 键盘,鼠标,触摸屏等硬件产生了一个硬件终端
- 操作系统的硬件终端处理程序收到对应的时间(Event)
- 确定该事件的目标进程
- 将事件放入目标进程的事件队列(Event Queue)
串口与事件响应
窗口(Window) , 也会有人把它叫视图(View) , 是一个独立可复用的界面元素(UI Element) . 一个窗口响应发送给它的事件(Event) , 修改内部的状态, 然后调用GDI绘制子系统更新界面显示.
响应事件的常见机制有两种
事件处理类(EventHandler, ios中叫 Responder
. 通常我们自定义的窗口类会直接或间接从事件处理类继承. Win平台有些特殊, 为了让窗口类可以复用,且语言无关, 它将事件处理做成了回调函数, 术语叫窗口过程(WindowProc)
. 这只是形式上不同, 没有本质上的差异委托(delegate)
顾名思义, 委托的意思是事件处理不是收到事件的人自己来做, 而是把它委托给了别人. 这只是一种编程的手法. 比如: 在web编程中我们给一个界面元素(UI Element) 实现 onclick 方法, 这可以理解为是一种委托(delegate)
有一种事件比较特殊, 它往往被叫做 onPaint
或onDraw
. 为什么会有这样的事件? 我们想象一下, 当一个窗口在另一个窗口的上面, 并且我们移动其中一个窗口时, 部分被遮挡的窗口内容会显露出来.
这个过程我们可能觉得很自然, 但实际上, 操作系统并不会帮我们保持被遮挡的窗口内容, 而是发送onPaint事件给对应的窗口让他重新绘制
另外,不只是窗口可以响应事件, 应用程序也可以. 因为有一些事件不是发送给窗口的, 而是发送给应用程序的, 比如: 本进程即将被杀死, 手机低电量警告等等.
当然如果我们约定一定存在一个主窗口, 那么把应用程序级别的事件理解为发送给主窗口也是可以的
事件分派
事件是怎么从全局的事件队列(Event Queue)到窗口的呢?
这就是事件分派(Event Dispatch)过程, 它通常由一个事件分派循环(Event Dispatch Loop)来完成. 一些平台把这个过程隐藏起来, 直接提供一个类似 RunLoop 这样的函数. 也有一些平台则让你自己实现
例如 Win 事件叫做消息(Message) , 事件分派循环的代码大致是
func RunLoop() {
for {
msg, ok := winapi.GetMessage() // 从事件队列中取出一个消息
if !ok {
break
}
winapi.TranslateMessage(msg)
winapi.DispatchMessage(msg)
}
}
大体流程, 简单的取消息(GetMessage) 然后对消息进行分派(DispatchMessage)的过程. 其中 TranslateMessage函数负责将键盘按键事件(onKeyDown, onKeyUp) 转化为字符事件(onChar)
窗口有了父子和兄弟关系, 就有了窗口系统. 一旦界面涉及复杂的窗口系统, 交互变得更为复杂. 事件分派过程怎么知道应该由哪个窗口响应事件呢?
这就是 事件处理链(EventHandler Chain)
不同事件的分派过程并不一样
对于鼠标或者触摸屏的触摸事件, 事件的响应方理应是发生处所在的窗口. 但也会有一些例外的场景, 比如拖放. 为了支持拖放, Windows系统引入了鼠标捕获(Mouse Capture)的概念, 一旦鼠标被某个窗口捕获, 哪怕鼠标已经移除该窗口, 事件仍然会继续发往该窗口.
对于键盘事件(onKeyDown/ onKeyUp / onChar) , 则通常焦点窗口先响应, 如果它不感兴趣再逐层上升, 一直到最顶层的窗口
键盘从功能上来说, 有两个不同的能力: 其一是输入文本, 其二是触发命令. 从输入文本的角度上来说, 要有一个输入光标(Caret) 来只是输入的目的窗口. 目的窗口也必然是焦点窗口, 否则就会显得很不自然.
但是从触发命令的角度来说, 命令的响应并不一定是在焦点窗口, 甚至不一定在活跃窗口. 比如windows下就有热键(HotKey) 的概念, 能够让非活跃窗口(Inactive Window) 也获得响应键盘命令机会. 一个常见的例子是截屏软件, 它往往需要一个热键来触发截屏.
到了移动时代, 键盘不在是交互主体, 但是, 键盘作为输入文本的能力很难被替代, 于是它便自然而然的保留了下来
窗口内容绘制
在收到onPaint
或onDraw
消息时, 我们就要绘制我们的窗口内容了, 这时就需要操作系统的GDI子系统
从大分类来说, 我们首先要确定好绘制的内容是2D还是3D的, 对于2D内容, 操作系统GDI子系统往往由较好的支持, 但是不同平台终究还是会有较大的差异. 而对于3D内容来说, OpenGL这样的跨平台方案占据了主流市场, 而Vulkan
号称是NextGL
其潜力不容小觑
从跨平台的难以程度来说, 不同平台的GDI子系统往往概念上大同小异, 相比整个桌面应用程序框架而言, 更容易抽象出跨平台的编程接口.
从另一个角度来说, GDI是操作系统性能要求最高, 最耗电的子系统. 所以GDI优化往往通过硬件加速来完成, 真正的关键角色在硬件厂商这里, 由此观之, 由硬件厂商来推跨平台的GDI硬件加速方案可能会成为趋势
通用控件
有了以上这些内容, 窗口系统本身已经完备, 我们就可以实现一个任意复杂的桌面应用程序了
但是,为了进一步简化开发过程, 操作系统往往还提供了一些通用的界面元素, 通常我们称之为控件(Control). 常用的空间有如下这些:
- 静态文本(Label)
- 按钮(Button)
- 单选框(RadioBox)
- 复选框(CheckBox)
- 输入框(Input, 也叫 EditBox/EditText)
- 进度条(ProgressBar)
- 等待
不同操作系统提供的基础控件大同小异, 不过一些处理细节上的差异往往会成为跨平台开发的坑, 如果你希望一份代码多平台使用, 在这方面就需要谨慎处理.
总结
桌面应用程序通常由用户交互所驱动. 我们身处在由操作系统约定的编程框中, 这是桌面编程的特点
在操作系统的所有子系统中, 交互相关的子系统毫无疑问是差异性最大的子系统, 我们看下面的表格