开发 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。比如编程语言。参考知乎问题

Beta 发布 Adaptive Wide Angle

2012/03/29

3 月 21 日 Photoshop CS6 beta 开始公开下载。随之和用户见面的包括 Adobe 中国 Photoshop 团队开发的首个 Photoshop 主要功能 [1] —— Adaptive Wide Angle。这是我们工作一年半的成果。从用户的反应来看结果和前景都令人兴奋。

Adaptive Wide Angle 不仅是 Photoshop CS6 的主要功能,在图像处理方面也是新的突破。Photoshop 一般用来弥补照片的瑕疵或者制作各种超现实效果。Adaptive Wide Angle 面对的是一个较少为人了解的领域 —— 摄影与人主观视觉的差异。

除了人眼的双视立体效果外,普通人对摄影和主观视觉的差异了解不多。Adaptive Wide Angle 处理的问题正是双视立体之外的主观视觉(可以简单视为闭上一只眼之后感受到的视觉效果)和摄影的差异。所以 Adaptive Wide Angle 面临双重挑战:除了提供强大 UI,还需要向用户传达新领域的概念。另一方面,我从一开始就认为传达新概念这个任务并不全属于 UI 设计本身,因为新概念很快会成为被广为接受的普通概念,而 UI 演进要求一定的稳定性。所以,在总结工作心得的同时,这篇 blog 也是对这个新概念的简单介绍。

人的单眼视觉可以达到 100 度左右的视角(和视线中心轴成 50 度夹角的锥形)。这 100 度的视角投射到视网膜上,经过视神经和大脑的处理,最终具备下面近乎完美的特性:

  1. 所有直线仍然是「直线」;
  2. 局部不变形。物体的大小变化只和物体离开人眼的距离有关,在以人为球心的球面上移动物体,观察到的大小不会发生变化。

摄影技术无法完全还原单眼视觉。3D 全息技术固然可以,但在无需双视立体效果的情况下并无必要采用这种复杂昂贵的技术。完全还原单眼视觉的最简技术是把场景投影在以人为球心的球面上(称为视面球,view-sphere)。这种方法的关键在于直线并不真正投影为直线,但是也不像 fisheye 之类的镜头投影为平面上的曲线,而是投影为球面上的「侧地线」[2]。所以既可以保证主观感受的直线不弯曲,也能保证局部不变形。这正是大幅电影采用弧面银幕(部分球面或者部分柱面)的原因。但是这种做法代价仍然很大,以人为球心并且半径不能太小的球面不可能便于携带,也难于在建筑物平面上安置。

画家们在 16 世纪发明了灵活变通技术:对于包含明显直线的大尺度物体,比如背景建筑物,采用保证直线的透视投影 (perspective projection,等价于 pin-hole projection);对于本身不包含明显直线的小尺度物体,采用它们在视面球上的形式(由于球面的小部分可视为近似于平面)。比如这幅油画 [3]。这种技术称为「自由透视」或者「移动视角」[4],是即能避免球面投影又接近主观视觉效果的最佳折衷。但是没有经过透视画法和艺术创意训练的普通人显然和这种技术无缘。美国的图形学家找到了一种可以由计算机实现的数学方法。这篇论文的作者中的 Aseem Agarwala 和 Robert Caroll 和中国团队合作完成了 Photoshop CS6 Adaptive Wide Angle。中国团队从两位图形学家手中得到了质量很高的偏微分方程的线性方程逼近算法代码。

我们的第一个目标是让 Adaptive Wide Angle 和整个 Photoshop 平台风格高度一致。Photoshop 在过去的几十年中不断加入的新功能以及与 Filter 的交互方式,Adaptive Wide Angle 都给予了支持,主要包括:

  • Smart Object(同时也是对 action 的支持);
  • LAB,CMYK 等色彩模式;
  • 每通道 16 位色;
  • 透明通道。

Adaptive Wide Angle 还克隆了一些较新的 Filter 辅助功能,比如 Liquify 按住/放开 X 可以将 Preview 面板的放大系数增加四倍/恢复原状,同时不打断任何正在进行的 on-canvas 元素编辑。

第二个目标是在渲染能力上的扩展。Adaptive Wide Angle 既使用显卡加速渲染,又避免渲染的图片大小受到显存和内存大小的限制。

第三个目标也是中国团队投入精力最多的环节是界面的可用性。Aaptive Wide Angle 是目前 Photoshop 中界面最复杂的 Filter。我们尽量让它的界面简洁直观。Adaptive Wide Angle 的界面上先后存在过 30 多个不同控件,在不减少功能和灵活性的前提下减少到现在的十几个。更多的任务通过在 Preview 面板上编辑 on-canvas 元素来完成。

Adaptive Wide Angle 面临的最大的可用性挑战是如何让用户准确的输入照片的焦距 (focal length) 和投影类型 (比如 perspective, fisheye) 。在这方面我们体会到了 Photoshop 平台的强大威力。Adaptive Wide Angle 充分利用了 Photoshop 的资源:

  • 对于 Photoshop 支持的镜头/机身组合,Adaptive Wide Angle 自动根据 Lens Profile 数据计算焦距(同时弥补镜头的光学畸变);
  • 对于 meta-data 中同时提供 focal length 和 crop factor 的照片直接采用这些数据;
  • 通过增强 Auto-Align Layer 功能,把 Photomerge 生成的全景图的虚拟焦距存入 PSD 文档(或者其它任何支持 Adobe XMP 标准的文件);
  • 对于没有任何 meta-data 的 fisheye 镜头,中国团队发明了在 Preview 面板上一次成功的估测方法。

经过这些努力,大多数用户根本不必了解焦距和投影类型这两个概念。克服这个可用性障碍是中国团队的努力和 Photoshop 平台的巨大资源积累的合力。

最后,我们如愿以偿地看到很多用户通过 UI 本身和简单介绍能理解这个工具所实现的新概念。甚至于有些用户评价,由于这个工具直观的 UI 和结果的可预测性,一些理论上能够由 non-adaptive 工具 [5] 完成的任务,也可以通过 Adaptive Wide Angle 来完成。

脚注:

  1. Photoshop 中国团队之前开发过 Adobe Lens Profile Creator 和 Adobe Lens Profile Downloader。都是配合 Photoshop 使用的独立工具应用。
  2. Great circle。球面上连接两点最短的弧。可以看作两点和球心所在的平面与球面的部分交线。
  3. 注意此画两侧和中央的人物大小相同,而桌子的透视灭点又说明观察者距离场景并不远(不是长焦效果),这在相同视角的普通照片上是不可能的。
  4. 这个功能中的 adaptive 就是表示根据图像的内容自适应的进行自由透视投影。
  5. Non-adaptive 表示所进行的图像变形是有解析形式的统一数学变换,和 adaptive 算法进行的自由透视方法相对(后者是由用户输入和图像性质共同决定的偏微分方程逼近结果,没有解析形式)。Lens Correction 和 Perspective Transform 是常用的 non-adaptive 工具。

软件质量之路

2012/03/20

上个月有段时间修正 bug 忙得要死,最后缓口气的时候统计了一下,发现这段时间修正了近 20 个 bug。这个数字大大出乎我的意料。

这些修正 bug 的工作并非是目前我开发的产品,而是一个两年前维护过的现在已经交给其它 team 维护的系统。即便两年前,对此系统的诸多细节也不甚了解。如今已两年没有接触它的 code base,只是对其大体构架还有印象。最近维护该产品的人手紧张,调我临时支援一下,主要处理一些罕见的 random crash。这些 random crash 很难在测试环境下复现。产品发布给数千的 pre-release 用户后才会在不断的日常使用中偶尔产生。遭遇 crash 的用户可以通过自动工具把 crash report 提交给服务器,report 中的信息不是很多,最主要的是所有 thread 在 crash 时刻的 call-stack,后端系统会根据导致 crash 的 thread call-stack 自动进行分类。这就是这些 bug 的背景:一个不熟悉的产品,没有复现 bug 的情景,只有少量线索。因为还要保证目前负责的产品的进度,每天只能花一半工作时间研究这些 crash report,如此历经三周。修正近 20 个 bug 的成绩令人颇感意外。

这件事情给我的启示是多方面的。Eric Steven Raymond 在《The Cathedral and The Bazaar》中针对 open source 模式总结出「Linus Law」,后来又在《The Art of UNIX Programming》中详细阐述:

Given enough eyeballs, all bugs are shallow.

有人质疑「Linus Law」(比如这篇),开放 source code 是否等同于有更多的人发现并修正 bug。因为不管一个 open source 软件多么流行,真正积极参与开发,能深入了解 code 的总是少部分人。这段时间修正 bug 的经历却证明修正软件稳定性方面的 bug 无需深入了解特定产品的 source code,特别需要的是与产品无关的通用软件开发知识,比如 C/C++ 的语言和 runtime,操作系统,二进制接口,静态和动态链接,汇编,多线程多核等等。影响和推动某个 open source 软件产品的通常是小团队,但能够参与提高产品稳定性质量的可以是很大的开发者群体。因为所需技能并非与某个甚至某类产品相关,一个高水平的程序员甚至可以在业余时间帮助提高多个 open source 产品的质量,即使有些产品的类型超出他熟悉的范围。

第二方面的启示是关于在软件中寻找问题。这些 crash 的代码乍看起来是非常「无辜」的。甚至于我每次都会惊呼一定是 C runtime 的 malloc() 有 bug 或者宇宙射线导致的内存错误。而且没有任何动态分析(比如使用 debugger,输出 log)的手段。不过只要定下神,告诉自己确信这个地方一定会导致 crash,经过几个小时的静态分析和冥思苦想之后几乎总是能找出一些问题。根据这些模模糊糊的推测完成的修复没法即时验证,只能先 submit 再说,称为 blind-fix。同样出乎意料同时也颇有成就感的是 crash 统计系统中相当多的 crash 数量在相关的 blind-fix 之后陡然下降为零。

在软件系统日益复杂的时候,程序员却失去了一些 postmotern debugging 的信息(例如,large sparse 进程空间让 full core dump 在用户生产系统上无法接受),但是这些真实的 blind-fix 实例说明:第一,call-stack 是最重要的调试信息;第二,修正问题最重要的一步是能 100% 的确信一定存在问题。这两点的重要程度之高远远超乎我之前的预料。关于第一点,我曾经很多次提到过,对待破坏 call-stack 可见性(比如,由于增加动态语言的虚拟机而导致两层 call-stack)和完整性(比如 message-posting,provider-consumer,Reactor 模式)的技术要非常慎重。

第三方面是关于工具。一个简单的自动提交 crash report 的工具和一个 crash 分类数据库,不算什么高深的技术。但是,如果没有这套系统,发现和研究 random crash 与验证 blind-fix 就是不可能的。在软件工业里,再不起眼的小工具都是加速整个行业进程的一部分。一些老程序员有个共同的经历:开始编程是因为能直接操控一台复杂的机器快感;经过一段时间之后,发现这机器比预想的要难驾驭,产生很强的挫败感;经过一段迷茫之后又发现,自己每一点努力的成果,并不是限于直接操控机器,而是增加整个群体的驾驭系统的能力,同时自己的能力也在这个体系上加速提高;如此重新找到编程的热情。

十几年前,「软件危机 (software crisis) 」还算个值得一提的名词,而且《人月神话》里「no silver bullet」的预言至今也没有被打破。不过,虽然没有数量级的生产力提高,软件开发还是在工具与协作模式上持续地稳步提升生产力,甚至还有些许的加速度。我们已经安然度过了「软件危机」。知乎上有一个问题 —— 是否能用十年前的技术造出今天的软件,我说的是,这十年软件业并没有空过,和十年前相比,我们毕竟拥有了很多无法或缺的东西。

用户是最大的漏洞

2012/02/15

从安全方面来说,用户是系统中最薄弱的环节。不过「聪明的」用户总是会找出各种理由把责任推卸给「技术」。甚至研究者有时也会落入这种陷阱。

不久前在知乎上有一个关于 MD5 的讨论。不得不说 MD5 是一种非常弱的 hash 算法。考虑到使用 SHA-1 等更强算法的额外负担完全可以忽略,我建议无条件的避免使用 MD5。但是从另一个方面来说,到底多少安全问题真正的责任在于 hash 算法的脆弱性?我在知乎上的总结是这样的(下面有些描述是符号化的,但在知乎上用的是非符号化语言。如果二者造成理解上的差异我为知乎上的不准确抱歉):

给定一个 hash 算法 f(x),对 f(x) 的「破解」有下面几种方法(注意「破解」不能等同于攻击。破解是非蛮力攻击。所以以下方式均有「比穷举算法效率更高」这个限制):

  1. 使用比穷举算法效率更高的方法得到 x1 和 x2,满足 f(x1) = f(x2);(可称为随机盲目碰撞)
  2. 给定 x1,使用比穷举算法效率更高的方法得到 x2,满足 f(x1) = f(x2);
  3. 给定 x1,使用比穷举算法效率更高的方法得到满足约束 A 的 x2,满足 f(x1) = f(x2)。约束 A 越严格,达成实质攻击的可能越大。比如,若 A 是描述自然的英语或者汉语,则任意的签名欺诈可行矣。

目前在理论界只有破解方式 1 得到了一些针对 MD5 级别的弱 hash 算法的突破。方式 2 和 3 在理论上的可能性也还未证实,甚至于对最弱的 hash 算法即使用蛮力也无法得到方式 3 中真正可以施行欺诈的 x2。我认为方式 1 很难达成实质的攻击。但是有研究者不同意这个观点本地存档)。这个链接给出了一个方法,通过方式 1 的随机盲目碰撞也能达成实质的数字签名欺诈。果真如此吗?

错!

(以下描述需要理解刚才给出的链接内容)

Caesar 的错误不在于使用了弱 hash 算法,而是他轻易相信了 Alice 给他的 PostScript 格式的文件。他没有审查这个文件的实质代码(尽管 PostScript 文件的源代码实际上是可读的),而是轻信了一个 PostScript viewer 一时 render 出来的外观就匆匆地数字签名了。如果把 PostScript 文件变成可执行文件,把 PostScript viewer 变成操作系统,Caesar 变成某家水果公司。那么以上关于 Caesar 和 Alice 的情景无异于:一个开发者给水果公司一个 app,水果公司运行了一下发现没问题,然后水果公司就用公司证书给这个 app 签名并将签名后的 app 发回给开发者了。那么水果公司因此背上任何黑锅都是咎由自取。事实是:

  • 水果公司不会用自己的证书,而是用自己的证书认证开发者证书,然后要求开发者用自己的开发者证书签名;
  • 水果公司要保留追溯开发者的机制,在后期发现 app 违规时可以追加惩罚。

所以,数字签名技术提供的信任是有约束的。对简单的可审查文件(如纯文本)才能任意签署。对复杂的不可审查文件(如可执行文件,复杂结构化文档,特别是带有动态内容的文档 —— 其实质等同于可执行文件,有些甚至达到了 Tuning-complete 级别)必须坚持「谁产生谁签署」的原则,而且必须有后续的法律追溯机制。更近一步说,对复杂的不可审查文件的轻信是任何技术都无法挽救的安全溃败。

其实上文提到的这个研究者在最后一页中提到了采用其它文件格式作为解决方法。但是他明显认为和采用更强的 hash 算法相比这是一个次要因素,而且他提出的居然是 Word 文档,一种内在格式和外观差异与 PostScript 不相上下的格式,而非「所见即所存」的简单文本格式。我曾经参与过几次安全培训,那些很有经验的讲师一般也会在几天的培训里不经意举出一两个他们认为的技术上的安全漏洞,而实为用户「盲目轻信」导致的问题。从信噪比的角度看,这些疏忽对那些安全专家的知识价值可说瑕不掩瑜,但也说明在人类心理和行为模式方面的安全才是最脆弱的。

新增功课

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 质量本身而非效果甚微的底层方案才是正确的。

为什么 Mac OS X 先进 (续) ?

2012/01/19

几个月前写的《为什么 Mac OS X 先进?》有人认同也有人反感。想给它写个续篇缘于最近发生的两件事情。一件是为了买《 The Design and Implementation of the FreeBSD Operating System 》在 amazon.com 上闲逛(后来在 china-pub.com 上买到了影印版),惊喜地看到《 DTrace: Dynamic Tracing on Oracle Solaris, Mac OS X, and FreeBSD 》已经出版了。五年前在杭州西湖边的酒店首次听 Sun Microsystems 工程师介绍 DTrace,还只是 Solaris 上和我不太相关的东西。半年前得知 DTrace 已经被移植到 OS X 上,在 amazon.com 上看到这本书处于未出版的预定状态。今天终于能翻着这本书在 OS X 上把玩 DTrace,心情有些许激动。

另一件事是同事向我推荐 Blackhat 的 Dionysus Blazakis 的《 The Apple Sandbox 》。在《 Sandbox 初探 》中我提到过,Sandbox 是在 DAC 和 MAC 两种安全模型都不适合个人计算的情况下另辟的第三条路。读了 Blazakis 的 paper 发现 Apple 没我描绘的那么特立独行 [注]。OS X 自 Leopard 起就已经集成了通用 MAC 架构 TrustedBSD,只是一直未有具体策略施加其上(TrustedBSD 只是执行 MAC 策略的机制而不是策略本身)。Lion 中新出现的 Sandbox 是在该架构上编写的 policy module。如《 Sandbox 初探 》所述,Apple 的创新在于和 UI framework 集成的动态安全策略。在执行安全策略的底层架构上,Apple 并不是自己发明车轮,而是直接采用了 BSD 社区的方案。另一方面,如 Blazakis 所说,Sandbox 也为 TrustedBSD 增加了高价值的功能,没有这类 closed 系统的无缝封装,无法想像 TrustedBSD 能在普通大众的计算机上发挥作用。

这两件事情让我回忆起前段时间看到的关于「开放模式」的讨论。Mac OS X 常被称为 closed 系统,相对而言有人甚至称 Windows 为 open 系统。Closed 略带贬义,因为有人盲目崇信 open 必胜。Steve Jobs 和 Bill Gates 都更愿意用含蓄一些的词,前者称 Mac 为 integrated 系统,后者称 Windows 为 compatible 系统。我认为做比较还是该用 open/closed 这样的直白词汇,但是要在各个层次分别描述:

  • Closed open-core 系统:Mac OS X
  • Open closed-core 系统:Windows
  • Open 系统:Linux,BSD

这些年 Mac 似乎远比 Windows 成功。Linux 和 BSD 这类 open 系统的成功领域是嵌入式设备、移动系统、服务器和超级计算等定制硬件系统,从软硬件的整体角度来说也构成 closed open-core 系统。看来 close open-core 是这阵子的黄金模式。传记《 Steve Jobs 》反复表达 closed 系统在提供优秀用户体验方面的 open 系统不可比拟的优势。但是人们经常忽视 Apple 产品在 open-core 方面的优势。

上面两个实例充分体现了 open-core 模式的优势。Jobs 公布 OS X 的第一个预览版时强调它是 Unix 并非吸引观众的权宜之计,目前为止七个版本的发展中,OS X 和Unix-like 系统社区一直保持密切的交流(还没有提这些年 Apple 向 Unix-like 反哺的代码,比如 Grand Central Dispatch)。这几年 Mac 上很多创新都来自公共领域经过考验的杰作。Closed-core 的 Windows 尽管能用于更广范围的廉价硬件(以牺牲用户体验为代价),但是集成这些创新明显力不从心。一是和 Unix-like 不兼容的内核必然导致移植成本增加,二是长久隔离于 Unix 圈之外的开发团队对公共领域的创新缺乏敏感,以至于只有 Apple 等其它厂商早有所行动之后才能缓慢跟进。

关于底层和上层的 open/close 模式问题,曾经在《 Open Source 的界限 》中从 open source 项目角度讨论过。Open-core 无疑是总结这个模式的好名字。Open source 的界限,就是 open 的界限,也是 open 的生命力所在。

注:虽然《 Sandbox 初探 》在 Sandbox 和 MAC 之间的关系上不太准确,但是这不妨碍它是 Blazakis 的 paper 的有益补充。后者虽然有无可比拟的 reverse engineering 实证作为基础,却没有涵盖 Sandbox 最重要部分 —— 和 UI 集成的通过 Powerbox 动态扩展 sandbox 范围的机制。关于这部分机制我认为《 Sandbox 初探 》的解释是正确的。