Archive for the ‘Mac OS X’ Category

恢复(到更高的)生产力

2017/07/24

任何一次生产环境的调整都是对生产力的打击。

六月初 WWDC 宣布 MacBook Pro 升级之后,决定照去年末计划升级用了四年多的 MacBook Pro 2012。回想起来,从第一次升级到 MacBook Pro 已经快八年了。每次升级都要磕磕绊绊一两周时间。所以四年应该是最短的更新周期,Apple Care 也是必须要买的。

当年买第一台的时候还特地等到 Snow Leopard 正式发布后才去 Apple Store,希望得到预装新 OS 的机器。结果欢乐地得到了 —— 预装 Leopard 的机器和 Snow Leopard 的光盘。

和以前相比这次升级的特殊性在于 hobby 开发转向了 GPU rendering 方面。从上古时期开始这就是兼容性的「玄学」领域。加上 Apple 刚刚推出 Metal for macOS 没多久,今年又急急忙忙拿出 Metal 2,这些对生产力的平滑输出都没有什么帮助。在新机器上配置好 Xcode 8.3.3 之后,发现我的 renderer 根本跑不起来。真是一个沉重的打击!接下来发现如果注释掉关于 shadow map 的代码可以勉强运行,这给了我虚假的希望,以为可以简单修改代码绕过问题。折腾了两天发现只要 shader 中 texture sampling 和分支组合到一定程度就出问题,根本没有简单的规避方法。时间在郁闷中虚耗了三天。最后我决定试一试 beta 版 macOS 上的 Metal 2。

之前从不在主力机器上安装 beta 软件,这次是被逼无奈。我甚至考虑如果还没有改观就退掉这台机器。好在装完 High Sierra beta 2 和 Xcode 9 beta 之后 renderer 能跑起来了。看来 Apple 人手吃紧,为了 beta 版的进度已经顾不上修复稳定版的问题了。不知道手握 MBP 2017 的人现在有多少像我一样被迫紧跟 beta 版动态。对于 Apple 来说我大概已被划入少数派用户了,macOS 开发就是小众,Metal 开发也是小众,Metal for macOS 就是小众的小众。

好景不长,反复观察一天发现只要系统休眠后,Metal 2 性能就会狂降。害得我不停 reboot 系统。几天后总算找到了基本「恢复」生产力到方法。在系统中始终运行一个 renderer app,休眠就不太会触发性能问题。似乎 High Sierra 的 GPU 管理在休眠状态判断上有问题,而保持一个运行状态的 Metal 2 app 可以让 GPU 管理绕过这个问题。

不要谈「正确性」

2016/09/29

很多程序员自嘲「数学不好」。反过来看就是在憧憬基于数学理论写就的程序一定完全正确。其实并非如此。比如说,简单的 parser 可以严格基于有限态自动机和 LL(n) 理论,但写过的人都知道调试起来并不简单,即便充分测试也不能达到 100% 正确。

mbe

另一个例子是 3D renderer。无论是物理真实的 ray tracing 还是简单的 phong model,renderer 就是数学公式的代码实现。最近用 Metal by Example 的例子作为起点写程序。一开始还运行得不错。直到加上摄像位置的变换之后,near/far 平面会切掉在 view volume 里的模型。一开始也没在意,给 near/far 参数随便加上个余量凑合用。混了两个星期后偶然想到 Metal by Example 用的 projection 和 OpenGL 的一模一样,而 Metal 的 depth 范围不同于 OpenGL [1] 。在 Twitter 上向作者确认之后果然是这个问题。Metal by Example 经过了 15 个月的开发,出版也有一年左右。但是这个基本问题一直没有被发现。

软件开发的现状略带讽刺 —— 严格依据数学理论的程序反而缺乏有效的工具来验证其正确性。但这里其实也没什么讽刺性,因为数学本身的理论正确性也并非自动获得。一个新证明的正确性必需要经过其他数学家的人工验证,有时需要历时几年,甚至在最后才发现证明中的错误。数学的正确性是依靠数学界的「社交活动」达成的 [2] 。正如测试是软件开发的「社交活动」。

那么,数学理论是不是对软件质量没有帮助呢?也对也不对。数学理论并不能直接保证正确性,无法帮助发现错误。但是一旦发现了错误,数学理论可以帮助更快的修复软件。当我们在一个复杂的 ad hoc if-else 分支群里发现错误行为之后,很难立刻理清修复方案。而面对上文的 projection 错误,立刻就可以修正错误的参数。我把这种特性称为「可修复性」。可修复性和「正确性」不是一个层面的问题。其实软件开发的很多 best pratice 并非追求「正确性」,而是去提高「可修复性」。例如提高代码的可读性,以及我以前讨论的 MVC 模式的局限和突破,都是追求「可修复性」。经常听到很多团队讨论开发流程和实践的时候以「正确性」作为争论的立脚点,讨论怎么做才能节约测试成本,这样就走偏了。

「可修复性」不是「正确性」。也没有办法直接降低测试成本。但是「可修复性」是软件的一个隐形 spec。当错误被发现的时候,「可修复性」将一切争论局限在实现层面,避免了在设计层面的争论,更不会出现 feature vs. bug 的可笑争吵。「可修复性」还避免了修改中无意引入新的 bug。就目前的软件开发现状,我认为没有任何编程行为能有效提高「正确性」,那些能产生正确代码的程序员也无非是把代码丢给测试团队之前,自己先系统测试一番。一切编程本身的实践,都应该围绕「可修复性」来讨论。一切针对「正确性」的讨论,都应该交给测试领域。

脚注:

  1. Metal 的 canonical view volume 是 2x2x1,它的 z-buffer 范围不超过 [0, 1] 。OpenGL 为 2x2x2 ,z-buffer 范围为 [-1, 1] 。
  2. 当然,类似 Coq 这样的形式验证工具说明有可能改变这样的现状,但是距离实现仍然有一定距离。

Program by Debug

2016/09/10

老手都知道「debugging」是书本很少涉及但是对生产力影响最大的编程手段。但在不断提高自身修养的过程中也听过大师告诫不要「program by debug」,令人时常前思后想不敢动手 coding 。反复调试时充满负罪感。

如果因为一句「不要 program by debug」就坚持敲代码之前要深思熟虑,那就是和自己过不去了。因为人并不擅长在不同层次进行通盘思考。花费很多精力思考的大计划在细节上必然充满逻辑漏洞。所以编程就是先让粗糙的代码在简单输入下勉强运行起来,然后这里紧一下「螺栓」,那里调一下「杠杆」,最终获得一个稳定的系统。所以恰恰就是要「program by debug」。如果重新思考这个问题,重点不在于是否让 debugger 成为 coding 的线索,而在于如何使用从 debugger 得到的信息,不应该用「头疼医头」的方式去修补问题,而是要从 debugger 暴漏出的现象扩展出一般化的问题,寻找一般化的解决方案。

大师的观点一定程度上和工具的发展程度有关。Debugging 是高度依赖工具的手段。工具永远有覆盖不到的地方,所以脱离 debuging 的慎重思考也总有不可替代的地位。不过工具发展之后,要让我们的头脑从工具成熟的领域中解放出来。

五六年前尝试学过 rendering ,还写过几篇《 OpenGL 随想 》,现在看来十分惭愧。除了熟悉基本概念之外,实际的练习都是浅尝辄止。那个时候我的印象是 GPU pipeline 的编程并不像大多数工程可以大量依靠 debugging 。今年公司给了去 SIGGRAPH 2016 的福利。为了不太辜负这次见闻,回来之后打算再进阶一次自己的 rendering 知识。总结一下前几次进阶夭折的原因,有一点在于只用下面这样的简单过程生成的模型。因为无聊而失去动力。所以我想这次要先花些力气让代码直接用上从网上下载的大量模型。

bqhwpgrcyaeokfr-png-large

具体的目标定为 Wavefront OBJ 格式的 viewer。本着 program by debug 的精神,从网上找了几个 OBJ loader 库,然后给它们分别写些简单的测试代码来 debug ,看它们产生的结果是否好理解。然后选中 tinyobjloader 加到 Metal by Example 的例子里直接开始编码。每次 debugging 暴漏的问题的时候,不能只是把当前出错的这一个 OBJ 文件的情况糊弄过去,而是要考虑类似情况如何反映在所有的 OBJ 文件里。用点合理猜测,偶尔去查相关的 OBJ 标准,程序就一步一步的稳定起来。最后把例子一步步的 refactor 成现在的 Nuo Model Viewer

Parsing model 的构建诠释了 program by debug 的作用,但是这毕竟不算 rendering 本身。和几年前相比,GPU debugging 的工具也丰富多了。例如 rendering order 就不用费力猜了。

2016-0908-modelviewer

Core Animation 初探

2012/08/17

尽管在产品中要实现不少动画效果,却还没用过 OS X 的 Core Animation。一方面是因为经常要考虑用一套 code base 兼顾 OS X 和 Windows 两个操作系统。另一方面是习惯了基于 MVC 的手工实现方法 —— 在 model 中加入描述过渡帧的变量,用 timer 定期修改变量并通知 view,view 根据变量绘制过渡帧。不过偶尔模糊地见闻一些对 Core Animation 的褒扬 —— 充分的利用 GPU,释放主线程的压力,iOS 的 graphics 完全转向 Core Animation 等等。所以最近终于抽空看了些 Core Animation 的资料 [1]。

真正阅读资料之前对简介 Core Animation 的优点一直有些疑问。我无法设想与手工 MVC 动画相比 Core Animation 如何进一步压榨 GPU 的效能。在手工 MVC 动画中,虽然控制中间过程的变量需要 CPU 计算,但是这种变量会被设计为最简形式。比如,实现旋转一个矩形的动画时 timer 修改的不会是矩形的四个顶点,而是在 model 中存储旋转角度的变量。在 view 绘制中间帧时,才会根据这个角度计算四个顶点。这时的计算并非主要由 CPU 承担,而是把旋转角度变为旋转矩阵,交给 GPU 担负各个顶点的计算。

Core Animation 文档再次印证了我的一个感觉。当你感觉一个东西表面上被宣称的优点不成立,那么这种感觉很可能是对的。如果这个东西仍然很流行,那么在宣传中肯定遗漏了这个东西的真正优点 [2],并且伴随着一些隐晦的妥协 [3]。Core Animation 的妥协在于,它并不能完全替代手工 MVC 方式的灵活性。手工 MVC 动画中 model 里描述过渡状态的变量和 view 根据这些变量计算显示过渡帧的过程可以自由定义,在 MVC 这一套原则下可以实现任何动画。Core Animation 仅仅实现了有限的预定义动画,如 layer 的旋转,透明度变化,颜色,位移。尽管 Core Animiation 预定义的动画属性相当多,而且有数种组合方式达到无限的组合结果,但是仍然不能覆盖理论上的任何动画。所以它的动画效果是「罐装 (canned)」的。

但「罐装」效果对灵活性的牺牲带来了在表面宣传中未提及的好处 —— transactional。即使是初学者也能很快的实现一个简单的手工 MVC 动画,但是一旦加入复杂的 transaction 要求,即使老手也很为难:如何处理在一个动画播放的过程中用户 cancel 或者 undo 一个操作,或者更加复杂,在动画过程中用户执行引发另一个动画的操作。这要求动画在任意阶段的回退,或者从一个动画效果无缝切换到另一个效果,并保证 view 最终总能正确呈现 model 的稳定状态。通过把动画效果限制在有限的「罐装」组合,Core Animation 在程序员无需干预的情况下自动实现 transaction [4]。

更重要的是,虽然理论上听起来「罐装」效果对灵活性的牺牲很可怕,实际上 99% 的应用只需要「罐装」效果,因此 Core Animation 作出的牺牲其实是几乎无代价的。当然,不排除由于 Core Animation 的流行,一些原本应该更有创意的动画的地方被用平庸的「罐装」效果来应付。但我认为这是很罕见的情形。而且,动画效果已经从早期的 eye candy  变成提供 UI 行为的 clueness 的普遍要素(比如,当前的 view 变化是因为图像旋转还是图像发生了变化 —— 前者显示为图像旋转而后者则是新旧图片的平移切换)。从少数 fancy 操作扩展到几乎所有 view 的变化。动画的一致性 (consistency) 和 least suprise 变得比创意更为重要。

如果抛开 Core Animation 出现的真实历史 (historical accuracy) 来设想一下一个类似 Core Animation 的库是如何在 Apple 这样的公司出现的。它应该不会是自上而下的需求,不会是为了 OS X 的市场而设计的 feature。相反,像 Apple 这样的公司会对内部产品的界面动画有一套详尽的关注细节的指导原则,最初必然是由程序员用手工 MVC 实现,只需要极少创意和极复杂的 transaction 处理。于是一段时间之后程序员们终于把动画效果「罐装」起来,此后原来的指导原则也几乎不再有提及的必要,因为几行代码就能自然地实现,反而是没有动画效果的操作显得不够自然了。这必然是从真实的痛苦中诞生的技术。

脚注:

  1. 看的是 OS X 版本,所以对 iOS 平台的描述仍然不尽明了。
  2. 比如很多场合人们宣称 GPU 的优势在于大规模并行。这当然会引来一些思维敏捷的初学者的疑问 —— 为什么 Intel 们不把 CPU 做得一样并行化。而 GPU 的威力真正来源是它把要处理的数据结构局限在异常简单而且可以完美对齐的方式,并且配以高速的内存总线。最直接的证据就是无论 OpenGL 还是 OpenCL,在可预见的未来都不会支持指针。
  3. 如上 GPU 对数据结构的妥协。
  4. 底层实现上,Core Animation 通过三套 layer tree 实现自己的 MVC 结构来实现 transaction。这同样是由于把问题局限于「罐装」效果才可能做到。Core Animation 自己的 mini-MVC 让 app 的 MVC 架构脱离了动画 transactional 的负担。

开发 Batch Crop

2012/07/16

从 6 月 8 日到今天有一个多月没更新 blog,以前从未拖过这么久。因为这段时间在开发我的第一个准备在 Mac App Store 上发布的应用 —— Batch Crop。前几天终于把 1.0 版提交给 Apple iTunes connect 等待审查(就我的习惯来说,这个应用应该叫 0.6 版,但是 Apple 不允许 beta 版进入 App Store)。目前页面中的截图是正在开发的新版本。

以前作为 one man team 开发软件的经历不是很多。这次经历感受匪浅,想写的东西也不少。无奈不断改进 Batch Crop 的工作完全占用了 day-job 和家庭之外的所有时间。这也让我开始非常敬佩那些既开发自己的产品又撰写 blog 的牛人们。

Text Field 与 Field Editor

2012/06/08

Cocoa 提供了两种文本编辑控件 [1]:NSTextViewNSTextField。从表面上看,前者比后者功能丰富,前者一般用作复杂的文字编辑,后者一般接受简单的数据输入。二者处理 Enter 和 Tab 键的行为不同。NSTextView 的方式和通常的编辑器相同:给编辑内容添加换行或者 tab 字符。
NSTextField 的方式则类似于其它非文本编辑的 Cocoa 控件:Enter 键触发 target action(缺省为终止编辑),Tab 键令焦点移到相邻的下一控件。

有瑕疵的世界观

如果根据表面现象粗浅地猜测,有这么几种可能:

  • 二者是实现完全不同的类,运行时没有协作;
  • NSTextView 是 NSTextField 的子类;
  • NSTextField 是 NSTextView 的封装,对外隐藏后者的高级功能。

实际上这三个猜测都是错误的。查看文档可以排除第二种。另外两种的真伪则要花些功夫来辩明。当然,很多应用界面仅需要 NSTextView 提供的缺省 rich-text 编辑功能以及把 NSTextField 作为简短数据输入方式,所以我们大可以采用第一种假设来开发 90% 的应用。但若需要精细调整文本编辑行为,采用有瑕疵的猜想像是用牛顿力学和以太的概念指导宇宙航行。

以太概念的抛弃

要了解两个类的关系,它们的命名可以作为切入点——其中的「field」是什么意思?在数据库记录、表格或文件格式中一段相对独立的数据经常被称为「field」,所以自然的猜想是 NSTextField 作为简单的数据输入方式其名称中的「field」源于此意。但是 field 还有「现场」、「场所」的意思。

其实在 Cocoa 中提供文本编辑功能的类只有 NSTextView
NSTextField 不是 NSTextView 的封装,它的作用是为实际承担编辑工作的 NSTextView 提供操作「场所」。其名称中「field」的意义不是表格或文件格式意义上的 field。当一个 NSTextField 控件不拥有焦点的时候,它只显示自己存储的文本值 [2],并不和 NSTextView 有任何关系。当它获得焦点时,其所在的窗口会把一个 NSTextView 控件置于其上,并将原来的 NSTextField 对象设置为该 NSTextView 对象的 delegate,真正获取焦点并且成为 first responder 的控件是 NSTextView 对象。在同一窗口中,置于所有 NSTextField 之上的是同一个 NSTextView 对象实例。因为只有一个控件能获得焦点,所以共享单一的 NSTextView 实例没有问题。这个唯一的实例称为「field editor」,即放置在 text field 上的 editor。

Field editor 由窗口负责创建和管理。开发者如果希望实现自己的 field editor,可以重写 (overried 或者 implement) 下面的函数之一:

  • NSWindowfieldEditor:forObject:
  • 窗口的 delegate 的 windowWillReturnFieldEditor:toObject:

在说明 field editor 机制如何导致对 Enter/Tab 的不同处理行为之前,先简单说明一下 Cocoa 对键盘事件的总体处理机制。下图截自《Cocoa Event Handling Guide》,Figure 1-5。

最后一步「Insert as character in view」对于 NSTextView 来说相当于接收到 keyDown: 消息。Enter/Tab 作为 key action 被路径中更早的模块截取 [3],即图中的「Send action message to first responder」。所以 Enter/Tab 事件不会向 field editor 发送 keyDown: 消息,而是分别发送 insertNewLine:insertTab: 消息。

现在回到 NSTextViewNSTextField 对 Enter/Tab 的不同处理。严格的说是非 field editor 的 NSTextView 对象和作为 field editor 的 NSTextView 对象的不同行为。 NSTextView 的 isFieldEditor 属性表示当前对象是否为 field editor。一切行为差异的秘密就在于 insertNewLine: 和 insertTab: 会根据 isFieldEditor 的返回值来决定控件的行为。

问题的解决

有了正确的世界观,就可以自由地对文本编辑行为作出调整。比如,如何让控件在接收到 Enter/Tab 事件的时候始终插入相应的字符而非终止编辑或者切换焦点?可以有以下方案:

  • 始终用 NSTextView,并且保证 isFieldEditor 属性返回 NO
  • 重写窗口 delegate 的
    windowWillReturnFieldEditor:toObject: ,返回 custom field editor。此方案需要创建两个新类:窗口 delegate 和 NSTextView 的子类。后者的 insertNewLine: 和 insertTab: 需要被改写。
  • 让处理 key action 的模块发送 insertNewLineIgnoringFieldEditor: 和
    insertTabIgnoringFieldEditor: 消息给 field editor,保证始终插入换行或 tab 字符。

下面详细说一下如何实现最后一个方案。处理 key action 的模块首先检查拥有焦点的 NSTextField 是否有 delegate。如果有的话会向其发送 control:textView:doCommandBySelector: 消息。重写此函数可以改变发送到 field editor 的消息 [4] [5]。

- (BOOL)control:(NSControl*)control textView:(NSTextView*)fieldEditor
                         doCommandBySelector:(SEL)commandSelector
{
    if (commandSelector == @selector(insertNewline:)) {
        [fieldEditor insertNewlineIgnoringFieldEditor:self];
        return YES;
    } else if (commandSelector == @selector(insertTab:)) {
        [fieldEditor insertTabIgnoringFieldEditor:self];
        return YES;
    }
    return NO;
}

脚注:

  1. 本文混用「控件」和「类」来表示 NSView 的子类。在强调该类的用户界面交互行为的时候偏向于使用「控件」。
  2. 实际上 Cocoa 中的静态 label 也是由 NSTextField 实现,只不过这时它没有获取焦点的能力,不作为 NSTextView 的「field」。
  3.  这个「更早的模块」是 Cocoa 的 key-binding manager。可以参见《Cocoa Event Handling Guide》的 Key Bindings 章节等。
  4. 用 debugger 在其中设置断点查看 call-stack 可以发现更多信息,比如关于 key-binding manager 的信息。
  5. 更多细节可以阅读《Editing Programming Guide》的 Working With Field Editor,以及《Technical Q&A 1454》。

空间化界面

2012/05/14

在论坛社区里有个问题每过段时间就会被提一次:是否有可能出现一种图形化编程方式能完成「传统」方式的所有任务,甚至取代后者的主流地位。知乎的 Rio 在回答中提出像 Lisp 这样语法超级简洁的语言也许可以通过图形化语法树的形式完成图形化编程。从更广的角度来说,自 Steve Jobs 访问 Xerox PARC 之后(甚至更早),软件设计者们一直在挑战一个难题:软件界面应该图形化到何种程度。Apple 在传统 Macintosh 中做到了图形化的极端,甚至去掉了键盘的方向键;在后期的系统以及 OS X 中则向相反方向回退:AppleScript 鼓励普通用户编写脚本,命令行的 Terminal 重新发挥作用。合适的边界在哪里,而编程又在边界的哪边?

问题维度

小学时学习实数用到数轴,中学时学习一元函数用到二维笛卡儿坐标的图形化。足够细心的人能用绘图法能够解决很多选择题。如果配合坐标纸,绘图甚至可以独立解答一部分平面解析几何的题目。更进一步,空间想象力和绘图功力不错的人可以用正交投影绘图来研究二元函数和立体几何。但投影绘图方式只能作为提供思路的辅助工具,无法起到一元函数和平面解析几何的绘图法那种独立解题的作用。作为研究问题的工具,图形化所能胜任的问题维度受限于工具介质的维度。而且这里的图形化严格说来应该称之为「空间化」。由于高维度的问题无法空间化,研究者必须直接操作逻辑符号。

计算机的显示器是二维的,即使它能用各种投影法来显示 3D 内容,指点设备 (pointing device,比如鼠标) 操作界面的方式仍是二维的。所以软件的界面受限于二维。当然,实际要更复杂一些。借助窗口,tab,分割线,甚至于更复杂的 dockable  palette 等等,软件界面是具有层次关系的一组二维空间。但是具有层次关系的二维空间组仍然不是三维空间。

适合图形化界面的问题,是在经过层次划分之后每个层次可以被自然地二维空间化的问题。比如文件管理,文字处理和排版。在 《The Mythical Man-Finger》 中,作者循序渐进的说明了简单的管理音乐文件的问题如何一步步复杂化到远远超越二维空间化的程度,最终必须由命令行来实现。比如「把 iTunes 中随机/最后播放/最常播放的 n 首歌曲同步到其它 app/device」。把这个问题拆借成单独的小问题,很容易分别图形化:

  • 按照随机/最后播放/最常播放排序 —— 排序的空间化;
  • 找到任意数量的歌曲 —— 数量的空间化;
  • 同步到 iPhone —— 位移的空间化。

由于每个问题单独空间化的意义不同,它们的界面往往是分开的。组合到一起成为复合需求之后则难以图形化。这也是为什么一些图形界面应用总体设计的不错,一到了 Import/Export 界面却变成很凌乱的对话框,反而不如像 AppleScript 这样的脚本化功能来的自然。

空间化作为呈现问题的一种方式,是将问题本身空间化,而非空间化问题的另一种表现方式。不能因为一个文字排版软件优美地编排了一个阐述四维空间问题的论文,就说这个软件成功的空间化了这个四维空间问题。同样, 把所有的 Import/Export 选项用下拉列表和 TextEdit 控件并列出来,固然也算可用的界面,但并不优于命令行方式。Rio 关于图形化 Lisp 语法树的设想,以及很多软件通过图形化流程图来尝试图形化编程的方式,都属于这种不成功的空间化。数轴不是把实数的十进制表示空间化,而是把实数本身空间化。二维坐标系不是把一维函数的公式空间化,而是把函数本身空间化。

目前用计算机处理多维度问题的方式主要要是定义一个多维的非空间化表示方式,然后再尽可能的用图形化界面来呈现这种方式。此时的图形化是为了呈现表现方式的符号而不是问题本身。今天我们有支持多 tab 和彩色字符的 Terminal;有支持语法高亮,括号匹配和 block 折叠的代码编辑器,它们是符号化图形界面而不是空间化界面。

空间化与符号化

回到最初的问题。既然早已存在高度图形化的代码编辑器,为什么「图形化编程」的问题会一再被提出?因为提问者和回答者都在潜意识里把「图形化」等同于「空间化」,直觉上对符号化的图形界面并不满意。但是缺乏「空间化/符号化」的分类思维又无法认识到空间化的局限。

为什么「空间化」如此深入人心?我只能猜想,在几百万年的进化中,人类的空间化记忆和认知对生存至关重要,这是人类最熟练的技能。

即使不考虑键盘和指点设备的输入带宽差异(幻想有某种神奇的指点设备能用键盘输入字符的速度操作空间化元素),把逻辑符号生硬的空间化也未必能提高效率。比如说,语法树这个逻辑概念中,每个节点的空间坐标其实是没有意义的。当然,在具体的教学例子和某位程序员的思考过程中,把某个语法树空间化是有用的。但是这更多的是特定例子(对初学者)的呈现和短时间自省的工具,而非一般的交流和存储方式。试想如果把成千上万的语法树空间化表示,光是调整节点的空间布局就需要无谓地耗费多少精力。在长期的知识表示和交流中,语法树的空间信息是正熵而非负熵。

逻辑符号标志着人类中的一部分超越了可能是自然进化赋予的空间化记忆和认知能力,数学(特别是超越三维的问题)和编程语言体现了直接在高维度思考的能力。每个人掌握这种能力的程度不一,有些程序员可以通过默想写出程序,而另一些则需要绘制大量用于自省的草图,。但是无论如何,空间化工具已经不足以解决高维度问题的过程中充当主要角色。人类和工具在相互影响,一方面工具尤其是软件在不断适应人类几百万年的空间化认知习惯,另一方面,能够驾驭高维逻辑工具的人在整个人类中的比重也应该越来越多。

模态和对话框

2012/04/19

最近这两年,我对软件 UI 设计经常反复强调的一个意见是尽量避免使用模态对话框 (modal dialog box) 。这个曾经广泛应用的 UI 元素逐渐式微。不严肃的说,模态对话框的多寡似乎成了区分「企业开发」的垃圾 UI 和消费者/专业级软件 UI 的一个新指标。

有些设计源自妥协,一旦妥协的前提不复存在,建立于其上的设计也随之衰败。比如说,一度公认的观念是应用程序必须「正常」退出,强行中止程序或者 crash 可能导致严重的数据丢失。但是近年来 iOS app 和 OS X 的 sudden termination 概念改变了旧观念。因为硬件性能的提高,原来把重要数据只保留在内存中直到用户明确表示存盘或者退出的做法,逐渐被更安全的方式替代 —— 重要数据一旦被创建或者修改立即写入磁盘,称为 crash-only 方式。「正常」退出成为不必要的 performance hack。

模态对话框也源自类似的妥协,我称之为「系统范化 hack 」。将本该由软件设计者承担的责任过多地由系统和 UI framework 代劳,根源在于对非功能性需求的轻视。OS X 和 iOS 将用户体验提升为最重要的竞争指标,「系统范化 hack 」也失去了立足的基础。

模态操作、消息循环和对话框

基于 model-view-controller 模式的 app 的用户体验由一系列修改 model 数据的「模态 (modal) 」过程组成。在一个模态过程中,某些 UI 元素接受用户输入并修改 model,其它 UI 元素接受用户输入的能力被暂时禁止或限制,以此避免过多不确定顺序的修改操作破坏 model 的数据一致性 (integratiy) 。「模态」的名称就是表示 UI 时刻处在不同的状态 (mode) 中,每个状态中只有特定的元素可以接受用户输入。「模态」过程由下面的概念构成:

  • 过程的开始和结束,即本次 mode 的时间范围;
  • 过程的边界,即在本次 mode 的时间范围内可以接受用户输入的 UI 元素和禁止接受用户输入的 UI 元素的界限;
  • 过程的结果:根据用户的输入修改 model (submit),或者在不修改 model 的情况下中止 mode (cancel)。

最基本的模态过程实现是把完成该过程的所有代码都放在单次 event-handler 调用中,在过程结束之前,消息循环无法获得控制权,以此禁止所有 UI 元素接受用户输入。这种方式有两个局限:

  • 执行的操作耗时不可过长,否则会导致界面假死;
  • 对用户输入简单地完全禁止,而非有选择的限制。在过程开始之后无法追加修改输入。适合诸如「键盘输入一个字符」或者「按下一个按钮」这类无追加输入的操作。而像文件存盘过程中涉及文件系统的 naviagation,起名等操作,就不能采用阻塞消息循环的方式。

根据「模态」过程的一般定义可以显而易见的分析模态对话框的行为。开始和结束对应对话框的打开和关闭,边界即对话框的边界,submit 和 cancel 由对话框的不同关闭操作完成。

从实现角度看,模态对话框是 UI framework 对「第二消息循环」的粗糙封装。内层循环可以阻塞主消息循环,而且消息循环具备过滤 event 的能力,不但可禁止 UI 元素接受用户输入,还可以有选则的允许用户追加修改输入 [1],所以「第二消息循环」很适合实现模态过程的边界和时间范围,自然而然的成为模态过程的主要实现方式。但是消息循环本身是一个比较难理解的概念,MFC 和 Java Swing 甚至企图完全隐藏它 [2]。所以,早期的 app 开发者和 UI framework 串通好采用模态对话框这个粗劣封装。本该由开发者决定的丰富多彩的「模态」过程被系统僵化同质的罐装行为替代。

模态对话框这个 one-size-fit-all 的封装面临一个两难问题:模态过程和整体界面的融合度高不高?如果融合度很高,那么弹出对话框的行为就会撕裂用户体验。如果融合度不高,那么在模态过程中,对话框下的主窗口就是干扰用户注意力的视觉污染。所以 UI 设计应该尽可能抛弃模态对话框,根据模态过程和整体 UI 的融合度来决定时间范围和边界行为。

鼠标拖放

鼠标拖放 (drag and drop) 第一代 Macintosh 和 Windows 3.x 时代就进入了用户体验。但是相对复杂的编程接口让程序员总是希望逃避它,所以一直以来只有 Finder、Windows Explorer 以及 Text Field 之类的系统控件支持有限的缺省拖放行为。很多开发者没有意识到鼠标拖放是一种能被高度定制的模态过程。这是非常可惜的。鼠标拖放巧妙的利用了「冲压机原理」[3] 来限制边界。通过把模态过程的开始和结束分别定义为鼠标键的按下和抬起来占据鼠标设备,避免边界之外的 UI 元素接受用户输入 [4]。

鼠标拖放过程的边界由三种 UI 元素构成:拖动的元素,开始拖动的源,drop 的目的元素(有多个可能的目的元素,最终在其中之一上进行 drop 而结束过程)。如果对 model 的某种修改能够反映为 UI 元素的空间位置变化,就可以采用鼠标拖放。比如常见的图片编辑器中对图片的平移、旋转、缩放。常见的文件移动操作因为 spatial file manager 的概念把文件操作对应为空间操作,也采用了鼠标拖放。

鼠标拖放由「第二消息循环」来实现 [5]。由于 MFC 和 Swing 等 framework 试图隐藏消息循环概念,它们支持鼠标拖放的 API 相当蹩脚,文档晦涩不明,是阻碍拖放的流行的一个因素。在 Cocoa 和 Qt 这类 framework 中鼠标拖放的编程模型回归到基于消息循环的概念 [6]。由鼠标键的按下开始拖放过程。在合法目的元素上,鼠标键的抬起引发 submit,在非法目的上,引发 cancel 操作。键盘的 ESC 键也可以进行 cancel 操作。鼠标拖放可以配合键盘修饰键(比如文件拖放中 Shift 的按下与否表示拷贝还是移动),磁力粘合,拖动手势,不同的目的元素接受 drop 的行为等追加输入完成复杂的操作。还可以配合 UI 元素上不同的 handler 完成旋转、缩放等操作。和整体 UI 的融合度很高的模态过程几乎都可以采用鼠标拖放来完成。设计良好的图形化 UI 的重要特征是把 model 的表示空间化 [7]。

进度条

使用进度条,是避免 UI 过度复杂的前提下,在耗时较长的计算过程中禁止用户的一切输入,同时避免 UI 呈现假死。用户可以中止该过程,以放弃部分完成的计算结果为代价换取立即执行其它操作的机会。在老式 UI 中这类过程通常由带有一个进度条 (progress bar) 和一个 cancel 按钮的模态对话框实现。

这种模态过程的 submit 不由用户控制,用户或者等待过程结束自动 submit,或者主动 cancel。如果换一个思路,此类模态过程无需禁止用户输入 —— 对 UI 元素操作视为 cancel 即可。因为没有复杂的边界定义(也就无需 event 过滤),这种模态过程可以采用「第二消息循环」以外的方式实现(比如 timer)。可以直接在主窗口上显示进度条。过程完成后隐藏进度条,在操作过程中如果用户进行其它操就 cancel 过程。

进度条对话框还有一个略带病态的变种 —— 提示对话框 (alert box) ,仅仅向用户提供一条信息。这种操作应该由主窗口底部的状态条来代替。

整体状态切换

如果模态过程和整体 UI 的融合度非常低,应该考虑让主窗口整体进入模态。Photoshop 的 Filter plug-in 是采用模态对话框实现的。但是近年来随着新出现的 Filter UI 越来越复杂,新的 Filter 如 CS6 版本的 Lighting Effect 和 Field Blur 均采用了主窗口整体模态的方式,在 Filter 工作时不再弹出模态对话框,而是将整个 Photoshop 主窗口切换为 Filter Workspace。

在这种模态过程中,边界之外的 UI 元素被隐藏,甚至完全销毁,所以不存在 event 过滤问题。实现的难度在于模态过程之前的 UI 状态保存和之后的状态恢复。需要应用程序开发者实现的细节比较繁琐,UI framework 提供的编程接口则相对简单。

附着型系统控件

上面谈的例子宗旨是让 app 开发者多做一些,让 UI framework 的责任少一些,接口灵活一些。但是在快速开发中,app 开发者还是期望有一些简单的方案。现代的 UI framwork 也提供了一些新的罐装机制 —— 虽然不如手工打造的模态过程精美,也比老旧的模态对话框好得多。

除了提过的融合度难题,老式模态对话框还有两大弱点:第一,它阻止用户访问同一 app 的所有其它窗口;第二,弹出模态对话框的窗口 (owner) 和对话框本身没有任何视觉联系。从逻辑上来说,每个模态对话框都有自己的 owner,而且在多窗口 app 中,每个窗口代表一个独立的 document,实在没有必要让一个模态对话框阻断用户对所有窗口操作。新的罐装模态过程机制虽然不能解决融合度问题,但是针对这两个次要弱点做了不小的改进。

OS X Cocoa 提供了 pull-down sheet,解决与 owner 缺乏视觉联系和禁止所有窗口操作的问题。在多窗口的 document-based app 中,pull-down sheet 可以在不增加代码复杂度的情况下完全替代模态对话框。Lion 又提供了 Pop Over  控件,将模态对话框的 owner 关系进一步突显到控件级别。

脚注:

  1. 这也是「对话 (dialog) 」一词的由来。
  2. MFC、Swing 等 UI framework 试图隐藏消息循环的做法给开发者的学习制造了不小的障碍。我在 MFC 和 Swing 中滚打多年,对这个概念的理解也一直比较模糊。多年以后,在 Cocoa 的文档中看到开篇明示消息循环的概念,才有醍醐灌顶的感觉(见注释 5)。
  3. 为了防止意外伤害工人的手,冲压机有两个冲压按钮,在远离冲压孔的两个方向,只有两个钮同时按下冲压机才会执行冲压操作。所以只有两只手的生物绝对没有机会在冲压操作中把手放到冲压孔内。
  4. 至少避免了指点设备 (pointing device) 的输入。键盘成为拖放操作中追加后续修改操作的手段。
  5. 这点可以参考 Cocoa 的 Event Handling Guide,Handling Mouse Dragging Operations
  6. 同注释 3。特别是其中的 The Mouse Tracking-Loop Approach。注意,鼠标拖放 API 的设计模型基于第二消息循环,并不意味着 API 本身必须完全暴露第二消息循环。
  7. 从另一方面说,本质上高于三维的 model 已经不适合图形化 UI。比如编程语言。参考知乎问题

新增功课

2012/02/10

在 Amazon.com 上买的《Mac OS X Internals: A System Approach》经过十天到货了。虽然有电子版,但还是买个纸质的收藏一下。

技术与文化

2012/02/07

我写了两篇《为什么 Mac OS X 先进》(  ) 。主要讨论什么样的文化经历了什么样的历史如何沉淀到技术中去。不过,文化有时候就保持为文化,不是所有的文化都有机会或者有必要沉淀为某种具体的技术。

前不久有一阵关于 Android 和 iOS 用户体验差异的讨论,始于《Why is Adnroid laggy, while iOS, Windows Phone 7, QNX, and WebOS are fluid》。这篇文章的作者认为原因在于两个操作系统调度线程 (thread scheduling) 的方式不同,在 iOS 上 UI 由一个有 realtime 优先级的专用线程处理,而在 Android 上只由普通线程处理。但是马上有人指出其实 iOS 和 Android 线程调度方式的差异并无文中猜测的那么巨大。

文章的作者承认自己的结论属于「猜测」。他说明自己并无特权接触iOS 底层(如果真有「特权」接触,也就不太可能被允许讨论),只是根据观察两个操作系统的外部行为大胆推测。他的观察是准确的,iOS 上大多数 app 界面渲染的优先级高于其它任务的优先级的程度,要比 Android 上类似的差异大。不过我推测这种最终处理效果的优先级差异并非源自操作系统的线程调度机制。

第一,虽然大多数 app 需要对用户的操作作出及时反应,但并不要求这种反应很快地提供最终计算结果。所以很多计算并不要求在后台和 UI 渲染同步进行(或者说,在单核系统上,和 UI 渲染以 preemptive 的方式分时进行),相反,只是要求 UI 渲染能比较快地打断计算即可。这种「打断」一般是利用 timer 等协作式多任务机制来完成。因为相对操作系统本身的 preemptive 多任务,协作式多任务对数据结构一致性的处理更为简单。此类多任务并不受操作系统的调度,而是 timer 本身的实现和 timer 的调度库(比如 Cocoa,Cocoa Touch)负责的。其中又以前者的影响为大。

第二,现代操作系统的线程调度一般基于动态反馈优先级 (dynamic feedback priority) 。在这种策略中,如果一个线程总是用尽分配给自己的时间 (time slice) 而必须由操作系统强制收回 CPU,它的优先级就会降低。而一个线程如果总是由于等待鼠标、键盘、或者磁盘主动让出 CPU,它的优先级就会提升。这种策略只考虑收回 CPU 的方式,而不考虑线程本身运行和休眠 (sleep) 的历史情况。在此基础上,有些操作系统,如 FreeBSD 和 Linux,会统计线程运行和休眠状况的历史,并且根据二者的关系推测该线程是否为 interactive 类型,即上文的 UI 线程。不过这种基于统计的推测做不到 100% 准确。Interactive 线程从休眠状态恢复到可运行状态时,会放入运行队列直接等待 CPU 调度。而 non-interactive 线程从休眠状态恢复到可运行状态时,会放到比运行队列低一级的「next 队列」。CPU 调度线程时不会考虑 next 队列,只有当运行队列清空之后(其中线程都已经调度过,并且其间没有 interactive 线程被重新唤醒),next 队列和运行队列的角色才会对调。值得注意的是 OS X 的 XNU kernel 中的 Mach scheduler 并没有采用这种「运行/next」队列的概念,也没有 interactive/non-interactive 类型,它只为每个 CPU core 创建和维护唯一的基于动态反馈优先级的运行队列。我猜测同样基于 Mach 的 iOS 和基于 Linux kernel 的 Android,其 scheduler 也与各自的源头基本相同。所以,现代操作系统中为提高用户体验的最重大的努力之一并没有应用到 OS X / iOS 中,而结果是似乎后者的用户体验并未受到什么伤害。

在 time-sharing 系统中,虽然 scheduler 是一个关键角色,但是因为它没有上层应用的 knowledge,scheduler 根据统计 (statistic) 和启发式 (heuristic) 算法所作的优化并不如上层应用根据自身逻辑进行的优化效果更明显。OS X 和 iOS 上 app 顺畅的操作感来自于 app 开发者本身对高质量界面文化的认同,而不是操作系统提供的「免费午餐」。OS X 没有采纳「运行/next」队列是不完美的,我希望这点最终能改变,不过目前看来其它操作系统和 OS X / iOS 在质量文化方面的差距差距抵消了后者技术上的不完美,亦或是,把有限的资源用在提高 app 质量本身而非效果甚微的底层方案才是正确的。