作者归档:贺 利华

关于贺 利华

正在学习编程,享受编程 热爱文学,闲来读读《读库》 有思想,没理想 正在学会专注

如何解决 Unity-Editor 中使用 Gradle 打包出现 Heap-Space 不足的错误

如何解决 Unity Editor 中使用 Gradle 打包出现 Heap Space 不足的错误

最近在自己的开发机上和公司内部用的打包的机器上都出现了通过 Unity Editor 提供的 Gradle 打包流程打包 APK 文件时提示 Java Heap Space Error 的问题。

输出的错误日志内容如下:

Running dex as a separate process.

To run dex in process, the Gradle daemon needs a larger heap.
It currently has 1024 MB.
For faster builds, increase the maximum heap size for the Gradle daemon to at least 2560 MB (based on the dexOptions.javaMaxHeapSize = 2g).
To do this set org.gradle.jvmargs=-Xmx2560M in the project gradle.properties.
For more information see https://docs.gradle.org/current/userguide/build_environment.html

:mergeDebugJniLibFolders UP-TO-DATE
:transformNativeLibsWithMergeJniLibsForDebug
:processDebugJavaRes NO-SOURCE
:transformResourcesWithMergeJavaResForDebug
:validateSigningDebug
:packageDebug
Expiring Daemon because JVM Tenured space is exhausted
Daemon will be stopped at the end of the build after running out of JVM memory
:packageDebug FAILED

BUILD FAILED

Total time: 1 mins 26.532 secs
Expiring Daemon because JVM Tenured space is exhausted
]
exit code: 1

那就按照它的提示把 gradle 的配置修改一下吧,由于我使用的开发机和公司的打包机器都是 macOS 系统,所以只需要在 ~/.gradle 目录下的 gradle.properties 文件(如果该目录下没有这个问题件,可以通过 touch gradle.properties 命令创建)中新增下面的这行配置即可:

org.gradle.jvmargs=-Xmx2048

理解 Git 工作流

理解 Git 工作流

英文原文:Understanding the Git Workflow

如果你没有理解 Git 背后的设计初衷的话,那么你可能处处感受到满满的恶意。因为实在有太多的可能你把 Git 给用歪了,你不受伤谁受伤呢?就好比你拿着一把螺丝刀当锤子使,你确实也能使,但是你不痛苦谁痛苦,螺丝刀还受伤呢。

我们来看看一个普通的 Git 工作流主要分成以下几个部分。

  1. 基于 Master 分支创建一个工作的分支
  2. 在工作分支下工作
  3. 工作完成后将工作分支合并回 Master 分支

大部分时间里,这个工作流程总能如你所愿,因为在你创建分支后 Master 已经发生了改变。然后有一天你将某个功能的分支合并到了 Master 分支,但是 Master 分支却并没有分叉。跟往常每次 Merge 都会创建一个 Merge Commit 不一样的是,Git 直接将 Master 分支的 HEAD 指向了这个功能分支的最后一次提交,也可以称之为 “fast forwards”。如下图解:
Fast forward diagram

很不幸的是,你在该功能分支上开发的时候为了及时备份,你做了多次断点式的提交,而这些断点提交的代码又不太稳定。现在尴尬的是这些功能不稳定的提交没法与 Master 分支上哪些稳定的提交区分开来了。这下如果要将这个功能进行回滚简直就是个噩梦。

好吧,现在你给自己加了一条新的规则:“在每次合并分支之前,一定带上 no-ff 选项来强制 Git 生成一个新的 Commit”。这样确实可以解决上一个问题,然后咱们继续吧。

然后某一天你发现了线上的版本有个很严重的 Bug,你得好好查查究竟是在哪个提交中引入了这个 Bug。你通过 git bisect 命令来定位,最终发现问题就出在某个功能分支的某次断点提交里头。好吧,最终你放弃了 git bisect,开始手动的查找问题所在了。

最终你已经将问题缩小到了某个文件里头了。然后你执行了 git blame 命令来显示最近的 48 小时内这个文件都做过哪些修改。你知道这根本就不可能,但是 git blame 命令确实告诉你这个文件已经有好几周就没有做过任何修改了。好吧,原来 git blame 命令只会显示从这个文件首次提交之后的修改,而并不会显示它被合并时的修改。实际上这个修改是你在几周之前在这个功能分支的某次断点提交中做的,但是你今天才将这个修改合并到 Master 分支中去的。

好吧,这真是按下葫芦起了瓢啊,no-ff 选项开启后,又把 git bisect 整得不好使了,还有这个 git blame 出现的状况,这些都是因为你非得拿着螺丝刀当锤子使给弄的。

重新认识版本控制

版本控制因两个目的而存在。

第一个是它能帮助我们写好代码。你需要和团队中其他的伙伴们同步你的代码,同时也需要时不时地备份一下你的代码。而这些事情没法通过邮件发送文件压缩包来实现。

第二个是它能帮助我们做好配置管理。其中包括管理并行的开发,例如我们经常需要在开发下一个大版本的时候,时不时地对线上出现的 Bug 进行修复。配置管理还能用于搞清楚到底做了哪些修改,这是一个用来定位 Bug 的好工具。

一般来说,这两个目的之间是存在矛盾的。

当我们正在快速地实现某个功能的原型时,我们会很频繁地进行断点式的提交。不过这些提交通常都是没法编译通过的。

在理想的情况下,在你的修改版本历史中的任一修改都应该是简洁明了并且稳定的。不应该出现那种断点式的提交,也不应该有那种包含了上万行代码修改的提交。一个清晰明了的提交历史,将会使得我们想回滚某些改动或者通过 cherry-pick 命令在不同的分支中应用提交变得十分简单和轻松。另外一个清晰明了的提交历史,也非常便于后续的查看和分析。然而维护一个清晰明了的提交历史就意味着你需要在确认某个修改已经彻底 OK 了之后再确认合并。

那么你究竟应该选择哪种方式呢?是继续保持有规律地进行断点式提交呢?还是保持一个清晰明了的提交历史?

如果你在一个只有两个人的初创团队中,清晰明了的提交历史对你来说不会有太大的帮助。你完全可以在 Master 分支上随意进行提交,也可以随时进行部署。

但是随着你的开发团队和用户基数的增长,问题就变得越来越不一样了,你需要一些工具和技术手段来确保事情不会出错。包括自动化测试,代码审查和清晰明了的提交历史。

功能分支乍看上去还蛮不错的。因为它们可以用来解决基本的并行开发的问题。你只需要在进行合并的时候去考虑这些事情,在你进行功能开发的时候,你可以完全不用去考虑这些。

当你的项目大到一定程度的时候,这个简单的 branch/commit/merge 的工作流就没法胜任了。是时候鸟枪换炮了。你需要一个清晰明了的提交历史。

Git 最牛逼的革新之处就是它能同时满足你的两种诉求。在你快速实现原型的时候,你可以经常提交你的修改,但是在你最终完成的时候,又能以一个非常清晰明了的历史记录进行最终的交付。一旦你设定了这样的目标,你就会发现 Git 默认的各种设定简直就是天造地设。

工作流

假设现在有两种分支:公开的和私密的。

公开的分支是整个项目中最权威的,那么这个公开的分支上的所有提交就必须保持简洁和原子化,并且需要确保每个提交都有良好的提交记录,同时尽可能保持该分支的线性,不要打破。公开的分支包含 Master 和 Release 分支。

私有的分支就完全由你自己支配了。你想在里头怎么折腾就怎么折腾吧。

最安全的做法是私有的分支只在自己工作的本地上创建和使用。如果你确实有需要在办公室和家里进行同步的话,事先告知你的伙伴们你推送上去的这个分支是你自己私有的分支,让他们别也在这个上分支上做事情。

你永远都不能使用普通的 merge 命令将一个私有的分支直接合并到公开的分支上去。在进行合并之前,一定要使用类似于 reset,rebase,squash merges 或者 commit amending 这样的工具清理私有的分支的提交历史。

把你自己当作一个作家,把你的每次提交当作一本书中的一个章节。作家从来不会直接发布他们的第一版手稿的。Michael Crichton 说过:“好书不是写出来的——而是改出来的”。

如果你之前有用过其他的版本管理系统,你觉得每次修改的历史记录都应该是铁板钉钉,不能轻易修改提交历史记录的话。那么按照你这个逻辑,我们的文本编辑器就不应该有“撤销”这个功能。

实用主义者只管着不断地改改改,直到改得姥姥都不认识了。而配置管理又只在乎大版本的改动。这样一来,断点式的提交就成为了一个缓冲区了。

如果你想让公开的分支上的提交历史干净又漂亮的话,fast-forward 式的合并就不仔只是安全的了,更应该是首选的合并方式了。因为这样会让整个分支的历史保持线性的演进,并且很容易就能看明白。

还有争论说 -no-ff 合并不会有任何提交记录。有些人会使用合并的提交来作为产品环境最终部署的版本。好吧,这是一个反模式。你用 Tag 啊。

指南和示例

针对当前修改的大小,在这个分支上工作的时间,以及这个分支分叉了多远,我有 3 种不同的处理方法来应对。

短期的改动

大部分时间里,我只需要通过 squash merge 清理一下我的提交历史即可。

假设我创建了一个功能分支,然后在一个小时之内做了多次提交:

当我完成了这个功能的开发之后,我不会简单地使用原生的 git merge 进行合并,我会这么做:

然后我会花一分钟时间来好好写一个详细一些的提交记录。

更大的改动

有的时候一个功能可能会连续开发上好几天,功能分支里头也会有很多小的提交。

我认为我做的这些修改就应该分成多个小的修改,这个时候 squash 就有点太过于暴力了。(如我之前所说的一个经验法则,我们可以先问问自己:“这样是不是更便于代码审查?”)

如果我做的这些断点式的提交之间有逻辑顺序的话,我会使用 rebase 的交互模式来进行合并。

rebase 的交互模式很强大。你可以在这个模式下编辑老的提交,将提交拆开,重新排序和压缩提交。

例如在我的功能分支上,执行:

这个时候会打开一个编辑器,然后会显示一个 Commmit 的列表。每一行中都由一个操作指令、Commit 的 SHA1 值和提交记录构成。还有一个图例列出了所有可以执行的指令。

默认情况下,每一个 Commit 的操作指令都是 “pick”,这个并不会对 Commit 做任何修改。

我把第二条 Commit 的指令修改为 “squash” 了,这会将第二条 Commit 给压缩到第一条 Commit 中去。

当我保存并且关闭编辑器后,会再打开一个新的编辑器,提示我为这个组合后的 Commit 写提交记录,写完就好了。

功能分支废了

可能是我的功能分支已经开发了太长时间,这个时候我需要将好几个分支合并到我的功能分支上来,才能让我的功能分支能跟得上最新的进展。这么一合并,提交历史就纠缠在一起了。这个时候可以理解为当前工作的分支已经废了,但是已经做了的工作不能直接丢弃啊,这个时候为了避免出现这种情况最简单的办法就是直接创建一个全新的分支,然后再将功能分支的修改应用到这个全新的分支上去:

这下我的工作空间里头就有了我之前做的所有的修改,同时也不会有在刚才那个功能分支上合并造成提交历史纠缠不清的负担了。接下来我就可以手动的添加和提交我的改动了。

总结

如果你发现你在纠结 Git 的默认设置,先问问为什么。

将公共分支的提交历史视为不可变的,原子的,易于理解的,将私有分支的提交历史当作一次性的可塑的就好了。

理想中的工作流是这样的:

  1. 基于公共的分支创建一个私有的分支
  2. 经常性地将你做好的改动提交到私有分支
  3. 一旦功能开发完毕,清理好私有分支的提交历史
  4. 将清理好的私有分支合并到公共分支中去

译后碎碎念

这篇文章中的核心思想很好,读原文也很容易读懂,很容易就能 Get 到原文作者要表达的意图。但是在翻译的过程中发现,这个哥们的行文风格简直就跟咱们的文言文一般,相当的言简意赅,语法感觉非常的俚语化,对于我这种英文半吊子都不够的人来说,翻译起来确实困难重重。

真的翻译完了之后都担心自己是不是尼玛把意思给表达错了。所以这篇文章可能真的有不少错误之处,大家海涵吧,还望各位看官不吝赐教。

如何写好 Git 提交记录

英文原文:How to Write a Git Commit Message

前言:为什么要写好提交记录

如果你随便挑一个 Git 仓库去查看它的提交日志,你可能会发现这些日志通常或多或少都是混乱的。我们可以来看看早些年间我在 Spring 项目中的提交记录 :

嗯哼,我们再来跟这个仓库中近期的一些提交记录做个对比:

看了这两段提交记录,你更倾向于看到哪个?

前者的记录中,文本的长度和格式都比较随意,而后者的文本长度和格式就都比较统一了。前者的格式纯属自然形成,而后者的格式就不是偶然形成的了。

虽然大部分仓库的日志看起来都更像前者,但 Linux kernelGit 自己 就是两个很好的例外。我们还可以看看 Sprint Boot 项目 或者是由 Tim Pope 管理的任何一个仓库。

👆 上面提到的这些仓库的参与者们都很清楚编写一个良好的 Git 记录是用来与其他开发者(也许是他未来的自己)交流和沟通某次修改的上下文内容的最优方式。一次简单的 diff 操作是能告诉你改动了什么,但是只有提交记录才能准确地告诉你为什么要这么改。 Peter Hutterer 很好地指出了这一点:

重建一段代码的上下文是非常费时间的。我们无法完全避免它,所以我们应该尽可能地减少需要重建代码上下文的可能性。提交记录刚好就能帮我们做到这一点,从一个提交记录完全可以看出这个开发者是否能够很好地跟其他人进行协作。

如果你还没有怎么想过一个良好的 Git 提交记录为什么更好,可能是你还没有在类似于 git log 的这些工具上花太多的时间。这里有个恶性循环:由于提交历史的结构毫无组织并且格式也不一致,所以就没人愿意花时间去利用和管理它。因为这些提交历史从来也没有人会去利用和管理它,所以它的结构就一直毫无组织,格式也就一直这么不一致下去了。

但是一个管理良好的提交日志是一个既漂亮又有用的东西。有了它之后,git blamerevertrebaselogshortlog 和一些其他的子命令就焕发生机了。这样一来 review 别人提交的代码和 pull request 变得顺理成章了,而且还能独立地进行。如此一来,通过提交记录来搞清楚几个月前乃至几年前都发生了什么不只是变成可能的了,而且还更高效了。

一个项目的长期成功取决于(尤其是)它的可维护性,而一个项目的维护者最有力的工具就是项目的提交日志了。所以花时间去学习如何管理好这些提交日志就显得很很值当,也很有必要了。刚刚开始的时候,大家可能或多或少都会对此有所争论和意见,但是一旦形成了习惯之后,这会让整个项目的参与者都倍感自豪和效率倍增的。

在这篇文章中我只注重于保持一个健康的提交历史所需的最基本要素:如何写好一个独立的提交记录。还有很多其他重要的实践技巧,例如 “commit squashing”(压缩提交记录) 等,在这篇文章中我不会展开讨论。也许后续我会单独再写一篇文章来讨论一下。

大多数编程语言中都有一些已经形成的良好约定来保持风格的一致性,例如:命名规则,代码格式等等。当然这种类似的约定有着各种各样不同的版本,但是,我想大部分的开发者都会同意选择其中一种并坚持使用这一种约定,远比大家各自为政搞得混乱不堪要好上千千万万。

一个团队里头大家的代码提交日志的方式方法应该保持一致。为了使得代码库的修改日志变得有用,团队中的所有成员应该就编写提交记录的方式方法达成一个约定,这个约定需要确定这三个要素:

风格, 语法、换行、排版、大小写、标点符号,把这些能确定下来的规则都确定下来,别让大家去猜到底要怎么做,尽可能地把规则简单化确定化。最终的结果将是一个风格非常一致的日志,到时候大家不只是愿意去看这些日志了,甚至会时不时地主动去读这些日志了。

内容, 提交记录的正文中应该写什么内容呢(如果需要的话)?又有哪些提交记录需要正文呢?

元数据, 如何在提交记录中引用 Bug 跟踪号、pull requset 编号等?

幸运的是对于如何编写一个惯用的提交记录已经有了一些既定的约定。事实上,它们中的许多都是可以通过 Git 的命令行功能来达成的。你不需要重新创造任何轮子,只需要遵守以下 7 条规则,你就可以像一个高手一样编写好你的提交记录了。

一条优秀的 Git 提交记录的 7 条规则

记住: 多次 强调过

  1. 使用空行将提交记录的标题和正文分开
  2. 限制标题字符数在 50 以内
  3. 标题首字母大写
  4. 标题行末不使用句号
  5. 在标题中使用祈使句
  6. 限制单行字符长度最大为 72
  7. 在正文中详细解释说明这次提交的改动

举个例子:

1. 使用空行将提交记录的标题和正文分开

git commit 命令的手册中我们可以看到:

Though not required, it’s a good idea to begin the commit message with a single short (less than 50 character) line summarizing the change, followed by a blank line and then a more thorough description. The text up to the first blank line in a commit message is treated as the commit title, and that title is used throughout Git. For example, Git-format-patch(1) turns a commit into email, and it uses the title on the Subject line and the rest of the commit in the body.

翻译一下:

虽然这不是必须的,但是我们认为在提交记录的最前面使用 50 个字符以内的文本来概括一下本次提交的改动是一个很好的主意,然后紧随其后使用一个空行,再接着写更为详细的正文描述。从第一个字符到第一个空行之间的文本内容会被当作提交记录的标题, Git 在各个模块上都是这么处理的。例如: Git-format-patch(1) 会将一个提交记录转换为一封电子邮件,这个时候提交记录的标题就会被当作邮件的标题,而提交记录中其余的内容会被当作邮件的正文。

当然我们需要先说明一点,不是所有的提交记录都一定需要一个标题和正文。有的时候一句话就够了,特别是当某个提交记录就是非常简单的时候,压根儿就不需要再多写什么。例如:

就这么一句简洁的描述就够了,如果看到这条提交记录的人想知道修改的究竟是哪个拼写错误,他/她只需要通过 git show 或者 git diff 再或者 git log -p 把修改内容显示出来,简单地扫一眼就能知道具体修改了哪个拼写错误了。

如果你在提交的时候只需要编写这种简单的内容的化,你可以直接在命令行中,简单地在 git commit 命令后面加一个 -m 选项,然后跟上需要填写的提交记录内容即可:

不过当一个提交需要解释一下修改的上下文时,你就需要编写提交记录的正文了。例如:

这种带有正文和标题的提交记录就不太好直接在命令行中通过 -m 选项添加了。这个时候你最好是在一个趁手的文本编辑器中编写你的提交记录。如果你还没有设置好 Git 在命令行中调用的文本编辑器的话,可以参考 这篇文章

总之不论怎样,分开提交记录中的标题和正文对于我们日后再次浏览提交日志是大有裨益的。下面是一个查看提交日志全文的输出:

现在我们再用 git log --oneline 输出一下,此时 Git 只会输出提交记录中的标题:

或者我们再来看看 git shortlog,这个命令会将提交记录按照提交者进行分组,为了显示的简洁,Git 也只会输出提交记录的标题。

在 Git 中还有很多其他的应用场景也会区分提交记录的标题和正文,但是如果没有标题和正文之间的那个空行的话,这些应用场景就都白扯了。

2. 限制标题字符数在 50 以内

50 个字符并不是一个硬性的限制,只是一个经验法则罢了。将标题行限制在这个长度首先可以确认它的可读性较好,同时也会强制提交者去主动思考是否可以用更简洁的话来解释究竟发生了什么。

小帖士:如果你发现你很难去概括你的某次提交记录,那么很有可能就是你这次提交了太多的修改了。这个时候你需要尽可能地做到实现 提交原子化 (这是一篇单独讲提交原子化的文章)。

GitHub 的 UI 交互设计就完全遵守了这些约定。如果你提交的记录中标题的文本长度超过 50 个字符,它就会警告你超出了 50 个字符的限制。

标题字符过长的警告

而且它会将标题中长度超过 72 之后的所有字符截断,并用省略号来代替显示。

截断并使用省略号

所以尽可能争取将标题长度控制在 50 以内,实在不行的话也不要超过 72。

3. 标题首字母大写

这一条就是这么简单。确保每个标题的首字母大写就好了。例如,这样:

  • Accelerate to 88 miles per hour

而不是这样:

  • accelerate to 88 miles per hour

4. 标题行末不使用句号

标题行末的句号是不需要的。另外在标题行中尽量谨慎使用空格,当你想要 控制标题长度在 50 以内 的话,一个空格都显得尤为珍贵啊。

我们可以这样:

  • Open the pod bay doors

但是不要这样:

  • Open the pod bay doors.

5. 在标题中使用祈使句

祈使句的意思就是“像发号施令一样地说或写”。例如:

  • 打扫房间
  • 关门
  • 倒垃圾

你现在正在读的这 7 条规则就是用的祈使句(“限制单行字符长度最大为 72”等)。

祈使句听上去感觉有点粗鲁,但这也是我们平时不怎么用到它的原因。但是这正好符合 Git 提交记录标题的需求。其中一个主要的原因就是 Git 自己在我们每次执行一次提交的时候都在使用它。

例如我们使用 git merge 命令进行合并时,自动生成的提交记录是这样的:

还有 git revert 生成的提交记录是这样的:

或者当我们在 GitHub 中点击 “Merge” 按钮之后,生成的提交记录是这样的:

所以当你使用祈使句来编写你的提交记录时,其实你就是在遵循 Git 内建的约定。例如:

  • Refactor subsystem X for readability
  • Update getting started documentation
  • Remove deprecated methods
  • Release version 1.0.0

刚刚开始这么写提交记录的时候是感觉有点诡异。因为我们更习惯于使用陈述句来陈述具体的事实。这也是为什么通常的提交记录长得这样:

  • Fixed bug with Y
  • Changing behavior of X

有的时候提交日志又写得很像是对提交内容的描述:

  • More fixes for broken stuff
  • Sweet new API methods

为了防止大家搞混而不知道到底应该怎么写,这里有一个屡试不爽的公式可以简单地套用。

一个格式良好的 Git 提交记录的标题应该永远可以直接放在这句话的最后面:

  • 如果应用了这个提交,就会 你的提交记录的标题

例如下面这些例子中的标题就是 OK 的:

  • 如果应用了这个提交,就会 重构 X 子系统的可读性 (refactor subsystem X for readability)
  • 如果应用了这个提交,就会 更新新手入门指南文档 (update getting started documentation)
  • 如果应用了这个提交,就会 删除废弃的方法 (remove deprecated methods)
  • 如果应用了这个提交,就会 发布 1.0.0 版本 (release version 1.0.0)
  • 如果应用了这个提交,就会 合并某个用户/分支的#123 号 pull request (merge pull request #123 from user/branch)

而下面这些非祈使句语气的标题就不太好使了:

  • 如果应用了这个提交,就会 使用了 Y 修复了 Bug (fixed bug with Y)
  • 如果应用了这个提交,就会 修改 X 的行为 (changing behavior of X)(好吧,我承认这个我不知道要怎么翻译了。)
  • 如果应用了这个提交,就会 针对错误更深入的修复 (more fixes for broken stuff)
  • 如果应用了这个提交,就会 牛逼的新方法 (sweet new API methods)

记住:我们只是需要在提交记录的标题中使用祈使句。在写提交记录的正文时,就完全可以随意一些了。

6. 限制单行字符长度最大为 72

Git 自己从来都不会主动换行的。当你在写提交日志的时候,你必须注意日志文本的右边距,然后适时地手动换行。

推荐是每行达到 72 个字符就换行,这样的话 Git 在需要控制整行文本内容在 80 字符内的同时,还能有足够的空间来进行格式的缩进。

这个时候我们就需要一个趁手的编辑器。在 Vim 中很容易就能通过配置,让其在我们写 Git 提交记录的时候每到 72 个字符就自动换行。然而通常 IDE 在对提交记录自动换行的支持上都非常的糟糕(虽然 IntelliJ IDEA 在最近的版本中已经对此做了一些的改进)。

7. 在正文中详细解释说明这次提交的改动

这个 比特币官方仓库中的提交记录 就是一个很好的示范,它很好的解释了此次提交修改了什么以及为什么要做出这个修改:

我们可以对照这次提交的 完整 diff 来看一下,想象一下作者在这个提交中把本次提交的修改的上下文环境做了如此清楚的说明后,给项目的其他伙伴们以及后续其他的参与者们节省了多少的时间。如果他没有这么做的话,恐怕大家每次都得在这儿浪费时间了。

大多数情况下,我们可以不需要在提交记录中详细地说明我们是怎么做的修改。因为通常代码自己就能将实现的方法表达清楚了(如果这个代码的逻辑确实复杂到没法通过代码自己解释清楚的话,那么这个时候我们就需要在代码里头写注释了)。所以我们在提交记录的正文中,首先要先将为什么要做这次修改的原因说清楚,说清楚在此之前是怎么实现的(以及那么实现有什么问题),然后说明现在是怎么实现的,以及你为什么选择了现在的这种方法来实现的原因。

相信我,后续的维护者都会感谢你的,当然更有可能的是你会感谢你自己。

小帖士

学会使用命令行,忘掉 IDE 吧

鉴于 Git 有太多的子命令可用,我觉得拥抱命令行是明智之选。Git 简直牛逼到炸,当然 IDE 们也很牛逼,只不过它们各自牛逼的地方不太一样。我每天都在使用 IDE(IntelliJ IDEA),而且也曾广泛地使用过其他的 IDE(Eclipse),但是我还从来没有见过哪个 IDE 能将 Git 的功能集成到牛逼如其原生的命令行(一旦你掌握了)。

当然有些 Git 的方法被 IDE 们集成得很棒,例如在删除文件的时候主动调用 git rm 命令,在我们重命名某个文件的时候,会调用一系列的 Git 命令来实现文件在 Git 仓库中的重命名。但是当你开始使用 IDE 进行 commit, merge, rebase 或者是做些复杂的提交历史分析的时候,IDE 就不行了。

如果你想发挥 Git 的全部潜能,那么命令行是首选。

记住,无论你是在使用 Bash、Zsh 或是 Powershell,都可以通过 Tab 键 自动补全 脚本 来帮助我们更好地记住各种子命令和开关。

好好读读《Pro Git》这本书

《Pro Git》 这本书非常棒,而且可以在线免费阅读,好好利用吧,亲!

如何优雅地应用 Google Play Obb 机制

先说点闲话

近期我们的游戏 SailCraft 准备上线一个新的版本,在该版本中我们采用了 APK + Obb 文件的方式来实现资源的分发,尽可能减少玩家直接通过 CDN 下载游戏资源文件的概率。这是基于前一段时间我们游戏在 Google Play 中获得了一周的新游推荐之后,从玩家注册转化率数据上发现了在部分地区的玩家转化率明显不正常,跟踪分析之后发现是由于我们客户端的一个错误实现 + 玩家所在地区网络带宽太小这两个原因,致使玩家无法顺畅地进入游戏的事实得出来的判断。由于我们在海外 Android 的分发完全依赖 Google Play,这样就意味着我们所有的用户都能顺畅地从 Google Play 下载安装游戏,依托 Google Play 在全球的网络优化,我们完全可以不用考虑安装包分发的问题。由于时间的原因和我个人在 Obb 上的一些不太愉快的经历,之前确定资源文件分发方案的时候,我们过于理想化地认为只要我们选择最好的 CDN 厂商,应该就能解决我们的问题,所以最终我们在上线时并未考虑使用 Google Play 提供的 Obb 文件机制。

最终事实告诉我们 Google Play 在全球的网络优化和分发能力远不是我们这种小团队可以想象的,而且使用 Obb 文件还有一个额外的好处就是可以大量减少我们使用的 CDN 服务的流量费用。经历了这次 Google Play 全球新游推荐,让我们更多地接触到了平时可能触及不到的一些地区的玩家,也帮我们收集了更多数据更是直接反映出来了很多问题,其中之一就是使用 Obb 文件的必要性。既然箭已在弦上,那就不得不搞了。我们选择的做法是,直接将未打包在 APK 里头的所有资源打包成 Zip 包,作为 Obb 文件上传到 Google Play 后台,下载成功后首次启动游戏时,将 Zip 中文件解压存放到游戏专属的 SD 卡的 data 子目录中,后续的资源更新也是直接将更新资源下载到该 data 子目录中。

确定方案

那么我们先来梳理一下 Google Play 是如何处理 Obb 文件的,先来看看我们为什么要用 Obb 文件:

  1. Google Play 目前对于 API 等级 9 以上的 APK 支持最大文件大小为 100M,对于 API 等级为 8 及以下的 APK 文件大小限制为 50M(那种设备对于我们游戏开发厂商来讲毫无价值,我们也不会支持)。
  2. 目前绝大部分游戏厂商的产品最终的文件大小都是超过 100M 的,我们目前线上的版本完整包的大小时 130M,所以我们的做法是将 APK 大小控制在 95M 左右,将其他的 40M 大小的资源文件打包成 Zip 包作为 Obb 文件进行上传。

接下来我们得确定我们的 Obb 文件究竟是以什么样的形式呈现,以及我们游戏又该如何跟 Obb 文件进行交互。

由于 Android 系统自身实际上对于 Obb 文件并没有什么实质上的规范和要求,甚至我们可以理解为这只是 Google 在 Google Play 这个服务的基础上提供的一个解决方案而已,并非 Android 系统的一个基础设施,本质上 Android 系统只是将 Obb 文件以及其存放的目录当作普通的文件和目录来处理罢了,所以我们选择直接将所有的资源文件打包成 Zip 包进行上传作为 Obb 文件。Google Play 支持一个 APK 最多可以上传两个 Obb 文件,一个 Main Obb,一个 Patch Obb,每个 Obb 文件的大小上限为 2G,通常 Main Obb 不经常修改建议可以大一些,更新最好以 Patch Obb 文件的形式上传,这个文件可以小一些,在后续版本更新中可以持续更新这个文件。

目前我们暂时不考虑使用双 Obb 文件的方法了,毕竟当我们需要更新版本的时候,通常都需要更新 APK 包,而 APK 包的大小已经接近了 100M,玩家通常也会选择在有 Wi-Fi 网络的情况下进行安装包的更新,所以如果我们真的有必要更新 Obb 文件时,选择跟版本更新同时更新 Main Obb 文件问题应该不大。不过我们确实可以将 Patch Obb 用来做资源的更新,只是这样对整个资源打包流程的改动较大,特别是制作 Patch Obb 的流程会变得很复杂,对于代码的处理逻辑来说也相对来说会更复杂一些,还需要考虑到线上资源更新与 Patch Obb 资源更新之间如何取舍的问题,在第一个版本中可以暂时不考虑,后续可以再完善。

既然这样的话,那么当我们从 Google Play 中下载安装游戏时,Google Play 会先在 /sdcard/Android/obb 目录下创建一个以游戏包名 (例如:com.seabattle.uq) 命名的目录,然后将我们上传到后台的 Obb 文件下载到这个目录下。首次启动游戏时,我们可以直接将该 Obb 文件当作 Zip 包进行解压,将解压的资源文件保存到 /sdcard/Android/data/com.seabattle.uq/ 目录下,游戏内读取资源只从 APK 包内部和该目录进行读取即可。如果后续有某个资源文件需要进行更新,可以直接从 CDN 上下载,将更新的资源文件也保存到该目录即可。

开始编码

确认好了具体的方案之后,我们就可以开始编码了。看上去我们需要做的调整并不多,目前看来只需要做几件事情,我们就可以享受 Obb 机制带来的巨大利好了,对伐?

  1. 把原来需要从 CDN 下载的资源文件整理好压缩成一个 Zip 包,在上传 APK 到 Google Play 后台时,将其作为 Obb 文件同时上传;
  2. 在游戏首次启动的时候判断一下是否成功解压过 Obb 文件,如果没有解压过 Obb 文件,就直接从 /sdcard/Android/obb/com.seabattle.uq/ 目录下找到我们需要解压的 Obb 文件,直接使用相应的 Zip 库将文件解压到 /sdcard/Android/data/com.seabattle.uq/ 目录下,然后直接走正常加载资源进入游戏的流程就好了。

当然上面的两步是一个基础,也是一定要做的,但是只做这两步的话还是远远不够的,我们来看看会有哪些问题。

由于 Android 系统相对开放,大家随时都可能通过各种手段访问和操作 SD 卡,而 Obb 文件就是存在在 SD 卡上的,也就是说在游戏 App 依然安装在设备上的同时,Obb 文件是否可用是没有绝对保证的,主要有以下几种可能导致 Obb 文件不存在:

  1. Google Play 在下载安装 APK 的时候,未能成功地将 Obb 文件下载下来,这个 Google 官方的说法是这样的:Expansion files are hosted at no additional cost. When possible, Google Play downloads expansion files when apps are installed or updated. In some cases, your app will need to download its expansion files.
  2. 玩家不小心错误地将游戏对应的 obb 目录下的 Obb 文件给删除了(注意这里只考虑文件被删除的情况,因为目录被删除了的话就是另外一种情况了,而且目录被删除的话会更麻烦)。

那这个时候玩家的设备上都没有 Obb 文件,我们怎么办,解压个鬼啊?别担心,Google 自己也是考虑到了这些情况的,所以 Google 官方是有提供一个完整的 Obb 文件下载解决方案的,就是为了让大家可以快速地集成一个手动从 Google Play 下载 Obb 文件的服务到我们已有的项目里头来的,代码就在 [ANDROID_SDK_PATH]/extras/google/ 目录下,分别是以下三个目录:

  1. market_apk_expansion/downloader_library
  2. market_apk_expansion/zip_file
  3. market_licensing

将这三个工程作为 Library Project 导入到 Android 工程中就可以直接使用了,具体如何调用这个服务可以参考 market_apk_expansion/downloader_sample 中的实现。

不过在集成这几个 Library 到项目里时有几个地方需要注意一下:

  1. Google 提供的这个解决方案实现的时代已经很是久远了,而目前已经疏于维护了,所以这个解决方案中的三个 Library 工程的编译 SDK 等级都只能设置为 15,过高的话可能会出现某些工程中引用的 API 已经在高版本的 SDK 中被移除了导致无法编译的问题;
  2. 这个解决方案中使用的 Notification 相关的代码实在太老了,在运行时会直接输出错误级别的日志,最好时引入一个 Support 库,然后通过 NotificationCompat 的方式来替换那些古老的实现;

所以我们集成这个 Obb 下载服务到咱们游戏内的目的就是为了解决玩家首次启动游戏时,设备 SD 卡上游戏专属的 obb 目录下的 obb 文件不存在或者未下载成功的问题,虽然可能用到的概率很小,但是为了玩家,上吧。当我们把 Obb 文件丢失或者下载不成功的骨头啃完了以后,是不是就可以歇歇了呢?

意料之外的“惊喜”(坑)

我想你也看到了我在上文提到了一个 /sdcard/Android/obb/com.seabattle.uq/ 目录都被删除的情况了对吧,这个情况就更加复杂和麻烦了,为了要应对这种情况,如果我们的游戏没有申请读写 SD 卡的权限的话,我们会想到以下的一些可能的解决方法:

  1. 提示玩家,“对不起,你 SD 卡上的这个 obb 目录不见了,我们啥也干不了,请卸载我们的游戏,然后重新从 Google Play 里头下载安装一遍吧”,你觉得玩家会怎么说?“草泥马,卸载”。
  2. 是否可以尝试调用 Google 提供的这个 Obb 下载的服务去重新下载 Obb 文件呢?对不起,不可以,因为咱们没有申请读写 SD 卡的权限,所以目录被删除了之后,Obb 下载服务自己也是没有创建目录的权限的,因为这个服务就是集成在游戏内的,游戏申请了哪些权限决定了游戏内所有的接口调用的权限,所以死了这条心吧,在 obb 目录被删除之后,重试调用 ObbDownloadService 去下载 Obb 文件会直接失败的。
  3. 那么我们总不能真的回到方法 1 吧,也不能不让玩家玩游戏啊。那么此时我们就只能选择直接从 CDN 处下载资源文件了,下载成功后就可以进入游戏了。

那我们吭哧吭哧地把代码写好了,包也打出来了,测试一下吧。你等着,还有更多惊喜在等着你哦。

在我们将打包的 APK 包和 Obb 文件上传到 Google Play 后台,并且发布到 Beta 版本后,我们使用测试帐号进行了下载安装测试,发现了一个比较诡异的问题,那就是在某些手机上(Samsung S7, S7 Edge,红米 Note,乐视 Max 等),首次启动的时候,游戏并没有主动去解压已经成功下载到 /sdcard/Android/obb/com.seabattle.uq/ 目录下的 main.25.com.seabattle.uq.obb 文件,而是选择了直接从 CDN 处下载资源文件。但是在某些手机上(Google Nexus 6)一切正常,卸载重试安装多次,都是正常的。

继续研究

最终通过查看日志,发现在出现问题的这些设备上,游戏首次启动的时候,对 /sdcard/Android/obb/com.seabattle.uq/ 目录就没有读取的权限,所以客户端的逻辑判断认为 Obb 文件不存在,然后就启动了 Obb 下载服务去下载 Obb 文件了,但是也不知道为啥这个 Obb 下载服务竟然能判断出来 main.25.com.seabattle.uq.obb 文件已经下载成功了,不需要再进行下载了,然后就直接回调了下载 Obb 文件成功的逻辑,然而实际上它压根儿就没有权限访问 main.25.com.seabattle.uq.obb 这个文件。

针对这个 Obb 下载服务能正确判断 Obb 文件存在,但是我们却无法访问的问题,今天细细探究了一番,最终发现 Android 中对 File.exists 方法的实现跟 JDK 中的实现是有区别的,在 Android 中的代码是这样的:

从上面的代码段中我们可以看出这货不会像 JDK 中抛出 SecurityException 这样的运行时错误,我们可以看看 JDK 中的访问文档是怎么样的:

public boolean exists()

Tests whether the file or directory denoted by this abstract pathname exists.

Returns:

true if and only if the file or directory denoted by this abstract pathname exists; false otherwise

Throws:

SecurityException – If a security manager exists and its SecurityManager.checkRead(java.lang.String) method denies read access to the file or directory

那么这个 F_OK 是个什么鬼啊,这就要去看 Linux 中 access(2) 的文档了,因为 Android 实际上最终调用的就是这个方法。

access() checks whether the calling process can access the file
pathname. If pathname is a symbolic link, it is dereferenced.

The mode specifies the accessibility check(s) to be performed, and is
either the value F_OK, or a mask consisting of the bitwise OR of one
or more of R_OK, W_OK, and X_OK. F_OK tests for the existence of the
file. R_OK, W_OK, and X_OK test whether the file exists and grants
read, write, and execute permissions, respectively.

由此我们可以得出结论了,由于 Android 中 File.exists() 方法判断一个文件是否存在并不需要调用者有对该文件的任何访问权限,这个方法只是判断文件是否真实存在,由于该文件确实是存在的,所以即便调用者对于该文件没有任何访问权限都会返回 true,致使 Obb 下载服务确实可以正确的判断 Obb 文件成功,而我们的代码逻辑选择了直接信任 Obb 下载服务返回的结果,认为只要返回下载成功就可以直接访问该 Obb 文件了,但是后续我们尝试通过其他的接口来列举 obb 目录下的文件清单和单独访问该文件都会出现权限异常的问题。

客户端在下载 Obb 文件成功的回调中,会主动调用解压 Zip 包文件的逻辑,但是由于最终无法访问 /sdcard/Android/obb/com.seabattle.uq/ 目录和 /sdcard/Android/obb/com.seabattle.uq/main.25.com.seabattle.uq.obb 文件,导致解压 Obb 文件的流程失败了,然后就只能走 CDN 下载的流程了。

我们可以看看这个截图,这是从 Google Play 下载安装成功游戏后,游戏专属的 obb 目录的权限的截图:

安装游戏成功后obb目录权限截图

这个问题看上去这么诡异,我们放狗搜一下吧,看看有没有人也遇到了类似的问题,不搜不知道,一搜吓一跳。原来在 Android 官方的 Issue Tracker 中已经有了两个跟我们一模一样的问题,而且别人还找到了怎么让这个问题自动修复好的办法,那就是重启手机。这两个问题的链接在这里:
https://issuetracker.google.com/issues/37544273https://issuetracker.google.com/issues/37075181

那么重启一下手机之后,游戏专属的 obb 目录的权限会变成什么样呢:

重启手机后正确的obb目录权限截图

至此我们已经可以很确定地说,在大部分的 Android 设备上会存在这个 obb 目录权限出错的问题,这会导致游戏在不申请读写 SD 卡权限的情况下,在这些设备上无法正常地读取已经从 Google Play 上下载成功的 Obb 文件。由于我们游戏目前对于 SD 卡的读写权限是这么设置的:

所以在 Android 5.0 以上的设备上,实际上我们游戏是只能读写 SD 卡上专属的两个目录,/sdcard/Android/obb/com.seabattle.uq/ 和 /sdcard/Android/data/com.seabattle.uq/,但是由于 Android 或者说是 Google Play 的这个未能正确设置 obb 目录权限的问题,我们就无法在 SDK 等级为 18 以上的设备上正确地读取并解压 Obb 文件。这下蛋疼了,尼玛做了这么多,你告诉我,这都白干了?

这个时候我们就只能根据实际情况来做判断了,鉴于 Android 设备的各种奇怪设定我们已经见怪不怪了,而且我们也看到了在 Android 的 Issue Tracker 中其他开发者提到的受影响的设备和 Android 版本的情况,所以我们可以初步判断这个问题可能影响到的设备数量级较大,而且目前并没有什么更好的解决方案,如果我们想利用 Google Play 提供的 Obb 文件带来的益处,就只能考虑申请 SD 卡读写权限了。

最终的选择和方案

基于这个问题,我们也请教了腾讯游戏的开发者,沟通的过程中得知腾讯所有发海外的游戏通常都会主动申请 SD 卡读写权限,他们貌似都没有遇到过我们这个问题。好吧,既然这样,那我们多看看其他的厂商是如何处理的吧。

  1. 《炉石传说》,未使用 Obb 文件,游戏启动之后直接从 CDN 下载资源文件,但是下载速度惊人的快且稳定;
  2. 《游戏王》,未使用 Obb 文件,游戏启动之后直接从 CDN 下载资源文件,下载文件之多,简直惊人,速度也较快且稳定;
  3. 《剑与家园》,使用 Obb 文件,游戏启动后主动申请 SD 卡读写权限,获得权限后解压 Obb 资源;

这么看来一线大厂们基于自己多年分发游戏的积累,已经形成了一套非常稳定可靠的资源更新下载的机制,可以不依赖单个分发平台提供的便利机制,而使用自有的资源分发机制,这样可以降低项目的复杂性和不同平台上维护的难度,不失为一种可行的方案。但是对于中小厂商,由于在全球发行上并未积累太多的经验,很大的程度上选择依托 Google Play 这样成熟的平台会更有优势,所以此时可能只能做退一步的选择了,那就是为了尽可能利用 Google Play 提供的 Obb 文件分发机制减少使用 CDN 可能带来的下载问题和流量费用,但是鉴于目前可能存在的 obb 目录读取权限的问题,在目录出现访问权限的问题时主动申请权限,如果在某些设备上刚好运气不错可以直接访问 obb 目录下的内容的话,就可以直接进行解压了,不需要动态申请该权限了。至于这会影响到多少玩家因为游戏主动申请 SD 卡权限而选择放弃这款游戏或者去 Google Play 中给一个差评,这就很难讲了。作为技术执行者,我们能做到的这已经是极致了。

最终整个方案的处理流程图如下:

Android Google Play Obb 机制流程图

【翻译】关于 Unicode 和字符集,每个程序员都必须掌握的基本内容(别找借口!)

关于 Unicode 和字符集,每个程序员都必须掌握的基本内容(别找借口!)

原文链接在这里:The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!)

你是否曾好奇过 ”Content-Type“ 这个 tag 究竟是用来干嘛的?就是那个你在编写 HTML 的时候必须设置的 tag,你是不是一直压根都不知道这货是用来干嘛的呢?

你是否也曾收到过你那儿来自保加利亚的朋友发给你的邮件,但是杯具的是邮件标题显示成 “???? ?????? ??? ???” 这样的乱码了呢?

令我失望的远不止于此,我发现非常多的软件开发人员(程序员)对于字符集、编码和 Unicode 这一类的东西毫无了解。几年前,一个 FogBUGZ 的 beta 测试用户曾经问过我们 FogBUGZ 是否可以支持接收日文的邮件。啥,日文?你们还用日文写邮件啊?好意外啊,我怎么知道能不能支持。然后当我开始认真地去研究我们用来解析邮件消息的 MIME 的商业版 ActiveX 控件时,我们发现这货在处理字符集的这件事上彻头彻尾的错了,所以我们必须自己编写代码来避免它做出错误的转换,并且确保解析正确。然后,我又研究了一下另一个商业的类库,好吧,这货的字符处理模块也彻头彻尾的错了。我联系了这个类库的开发者,并跟他沟通了一下这个问题,你猜人家肿么说的,“对此我们无能为力”。就像大部分程序员一般,估计他希望这个问题能随风远去吧。

然而并不会,有木有。当我发现世界上最好的编程语言 PHP 竟然无视字符集的存在,非常牛逼地直接使用 1 个字节(8 位)来保存字符,这使得 PHP 几乎不可能直接用来开发良好的国际化 Web 应用程序(这不是我说的,是 Joel 说的哦)。好吧,也许够用就好了。

所以,在此我要声明一下:如果你是一个 2003 年的程序员,然后你告诉我你对于字符、字符集、编码和 Unicode,那么别让我逮着你,否则我会把你关到核潜艇上去剥 6 个月的洋葱的(尼玛捡肥皂是不是更好)。相信我,我是认真的。

另外,我想说的是:

这并不难。

在这篇文章里,我会尽量将每个程序员在实际工作中都需要掌握的基本内容讲清楚。尼玛那些 “plain text = ascii = characters are 8 bits” 这种鬼画符的东西不止是错了,简直错到姥姥家去了,如果你继续这么写代码的话,那你就跟一个不相信细菌存在的医生差不多了。请你在读完这篇文章之前,别再写任何代码了。

在我开始之前,我需要先说明一点,如果你对于国际化已经很熟悉了话,你会发现整篇文章讨论的内容相对来说会有点过于简单了。我确实只是尝试着设定一个最低标准来进行讨论,以便每个人都能理解到底发生了什么,并且能学会如何编写可以支持除英语之外的语言显示的代码。另外,我需要说明的一点是,字符处理只是软件国际化工作中的很小的一部分,不过今天在这篇文章里头我一次也就只能讲清楚这一个问题。

历史回顾

理解这些东西最简单直接的方法就是按时间顺序来回顾一遍。

你也许觉得我要开始讲一些类似于 EBCDIC 之类的远古时代的字符集了。然而,我就不。EBCDIC 跟你已经没有半毛钱关系了。我们没必要再回到那么远古的时代了。

那么我们回到中古时代吧,当 Unix 刚刚发明出来时,K&R(Brian Kernighan 和 Dennis Ritchie)还在写《The C Programming Language》这本书的时候,一切都是那么的简单。EBCDIC 就是那个时候出现的。在 EBCDIC 中只考虑到了那些不带音调的英文字母,我们称其为 ASCII,并且使用 32 到 127 之间的整数来代表所有的字符。空格是 32,字母 “A” 是 65,以此类推。这些字母使用 7 位就可以很容易地保存了。不过那个时代的大部分电脑都使用的是 8 位架构,所以不止可以很容易地保存所有的 ASCII 字符,我们还有 1 位富余出来可以用来存别的东西,如果你比较邪恶的话,你完全可以拿这 1 位来偷偷地干坏事:WordStar 这款软件就曾经使用 8 位中的最高位来标记字母是否是一个单词中的最后一个,当然 WordStar 也就只能支持英文文本了。小于 32 的字符被称为不可打印字符,主要用于符咒(开个玩笑罢了)。这些字符是用作控制字符,例如 7 对应的这个字符就是让你的电脑哔哔哔地响,12 这个字符就是让打印机吐出当前正在打印的纸张,接着吸入下一张新的纸张。

ASCII

这一切看上去都还是挺美好的,如果你是一个只说英语的人。

由于这一个字节共 8 位,可以表达的数字是 0 ~ 255,那么就还有可利用的空间,很多人就开始想“对啊,我们可以利用 128 ~ 255 来达成我们自己的一些想法”。可问题的关键是,想到这个点子的人实在是太多了,而且就这 128 ~ 255 之间的数字,他们的想法还各不相同。IBM-PC 的开发人员把这部分设计成了一个提供了部分带有音调适合欧洲语言的字符和一组划线符号的 OEM 字符集,这组划线符号有横杆、竖杠、右边带有虚点的横杠等等,有了这些划线符号,你可以在屏幕上画出非常整齐的方形图案,你现在依然可以在你家的干洗机自带的 8088 微机上看到这些图案呢。事实上一旦当美国之外的人们开始购买 PC,那么各种不同语言的 OEM 字符集纷纷涌现出来,这些字符集们都各自使用这 128 个高位字符来达成自己的设计目的。例如,有些 PC 机上字符编码为 130 的字符是 é,但是在那些以色列出售的机器上,它是希伯来语中的第三个字符 (ג),所以当美国人给以色列人发送 résumés 时,在以色列人的电脑上收到之后会显示为 rגsumגs。还有很多类似的例子,例如俄罗斯人,他们对于如何使用这 128 个高位字符编码有着太多太多不同的想法了,所以你压根就没法可靠地通过某种既定的规则来转换俄语的文档。

OEM

最终这场 OEM 字符集的混战由 ANSI 标准终结了。在 ANSI 标准中,低于 128 编码的字符得到了统一,跟 ASCII 码完全一致,但是对于高于 128 编码的的字符,就有很多种不同的方法来处理了,这主要取决于你住在哪儿。这些不同的高位字符处理系统被称为码点页。例如,以色列 DOS 系统使用了一个名为 862 的码点页,希腊人用的是 737 码点页。这两个系统中,字符编码低于 128 的字符都是一样的,但是高于 128 编码的字符就不同了,所有其他有趣的字母就都放在这儿了。全国的 MS-DOS 版本得有几十个这样的码点页,用来处理从英语到冰岛语,甚至还有部分多语言的码点页用来处理世界语和加利西亚语在同一台电脑上的显示。哇!但是希伯来语和希腊语还是无法在一台电脑上同时显示,除非你自己编写一个程序将所有的字符都按照位图来显示,因为处理希伯来语和希腊语的高位编码的字符所需要的码点页完全不同。

同时在亚洲,这件事情显得尤为严重,因为亚洲语言文字有成千上万的单字,8 位根本无法表达这么多的单字。这个问题通过一个叫做 DBCS(double byte character set)的系统解决了,在这个系统里头有些单字使用 1 个字节来表示和存储,有些单字使用 2 个字节。这个系统有个问题就是,它可以很容易地实现字符串中往前查找单字,但是几乎没法往后查找。程序员们不应该 s++ 和 s– 这样的操作来往后和往前查找单字,而应该调用类似于 Windows 系统中提供的 AnsiNext 和 AnsiPrev 函数来完成相应的操作,这些函数会自行搞定那些乱七八糟的事情。

但是就是还有那么多的人,他们依然假装 1 个字节就是一个字符,一个字符就是 8 位,而且你也从来不会把一个字符串从这台电脑发送到另一台电脑上,也只会一种语言,这样的话,貌似是没什么问题的。当然,事实上是互联网出现了,字符串在不同电脑之间的传输变得既平常又频繁,这下这些乱七八糟的字符处理系统终于扛不住了。幸运的是我们有了 Unicode。

Unicode

Unicode 很成功地将目前地球上所有的文字书写方式都整合到了一个字符集里头,甚至一些虚构的文字都可以囊括进来,例如克林贡语等等。有些人可能简单地误以为 Unicode 就是一个简单的 16 位的编码,每个字符都是用 16 位来进行存储和表示,那么它就最多能表示 65536 个字符了。实际上这样理解是不对的。这是对于 Unicode 最常见的一种误解,你这么想并不孤独,别在意就好了。

实际上 Unicode 对于字符的管理有一套完全不同的思维方式,你必须理解 Unicode 的思维方式才行。

现在,让我们假设需要将一个字母转换为二进制数据存储到磁盘或内存中:

A -> 0100 0001

在 Unicode 中,一个字母映射一个码点,这个码点是一个理论上的常量值。至于这个码点如何存储到内存或磁盘就完全是另外一回事了。

在 Unicode 中,字母 A 是一个纯抽象的概念,它并不指向任何特定的字符表示形式。抽象的 A 和 B 不是同一个字符,A 和 a 也不是同一个字符,但是 A 和 A 还有 A 指的却是同一个字符。也就是说在 Times New Roman 字体中的 A 和 在 Helvetica 字体中的 A 是同一个字符,但是与同一个字体中的小写的 “a” 却是不同的字符,看上去好像也没啥毛病对吧,但是在有些语言里头这就行不通。例如德语中的 ß 字母究竟是一个独立的字母呢,还是 “ss” 的一个花俏的写法?如果一个字母出现在单词的末尾,它的形状就要发生变形,那么它们要算是不同的字符吗?希伯来语就是这样的,但是阿拉伯语又不是这样的。不过不管怎样,Unicode 组织中那些牛逼的大大们在过去的十年里已经帮我们把这些问题都解决了,天知道他们都经历过了什么样的争辩与妥协(这已经是政治问题了,亲),所以你不需要再去担心这些问题了。他们已经把这些问题都搞定了。

每一个字母表中的抽象的字母都会被 Unicode 组织分配到一个长这样的魔数:U+0639。这个魔数被称为码点。“U+” 代表这个字符采用的是 Unicode 编码并且采用十六进制来表示编码的值。U+0639 对应的是阿拉伯字符中的 ع 。而英文字母的 A 对应的 Unicode 码点是 U+0041。你可以通过 Windows 2000/XP 中自带的 charmap 工具或者访问 Unicode 的官网 来查找不同字符的码点或者通过码点进行反向查找。

Unicode 编码所能定义的字符数量是不存在上限的,实际上 Unicode 字符集中定义的字符数量早已经超出 65536 了,所以并非所有的 Unicode 字符能被压缩为两个字节进行存储,但是这听上去有点诡异对吧。

好吧,那么我们来看个字符串:

Hello

在 Unicode 编码中,对应该字符串的 5 个字符的码点值为:

U+0048 U+0065 U+006C U+006C U+006F

至此我们看到的都只是一串码点值,看上去实际上都只是数字而已。我们还没有讨论过如何将这些字符如何存储到内存或者在 Email 中如何显示它们。

编码

想解释上面的两个问题,就需要涉及到编码了。

最早的 Unicode 编码试图将所有的字符都编码为两个字节来存储,那么好的,我们只需要将这些数字编码为两个字节,然后按顺序排好。那么 Hello 就成这样了:

00 48 00 65 00 6C 00 6C 00 6F

就这样吗?等等,我们看看这样行吗?

48 00 65 00 6C 00 6C 00 6F 00

从技术上来说,这两种编码方式都是可以的,事实上早期的 Unicode 编码实现者们就希望能以大端字节序或者小端字节序的方式来存储他们的 Unicode 码点,无论他们的电脑的 CPU 是快还是慢,现在是白天还是晚上,反正现在就是有了两种存储 Unicode 码点的方式了。所以人们就必须在处理 Unicode 字符串之前,遵守一个奇怪的约定,那就是在每一个 Unicode 字符串的最前面加上 FE FF 这两个字节(这被称为 Unicode 字节顺序标记,也被戏称为 BOM,炸弹的谐音)用来标记这个 Unicode 字符串的编码字节序是大端字节序,如果使用小端字节序进行编码存储的话,就在 Unicode 字符串编码的最前面加上 FF FE 两个字节,这样一来解析该 Unicode 字符串的人,在读取整个字符串编码的最前面两个字节就能判断当前的 Unicode 字符串究竟是采用大端字节序进行编码存储还是使用的小端字节序。但是,你懂的,真实的情况是,并非所有的 Unicode 字符串都会在其最头部加入所谓的 BOM 噢。

这看上去都挺好的,可是过了没多久,就有一帮程序猿开始有意见了。他们说:“靠,你们看看这些 0 们,你们有什么想法没有!”。因为他们是美国人,而且他们看到的也大都是英文,所以他们几乎从来都不回用到高于 U+00FF 的码点。另外他们还是加州的自由主义嬉皮士,他们很想节约一点存储空间(呵呵)。如果他们是德州人,他们就压根不会在意这多出来一倍字节数。但是加州的这帮弱鸡们就是无法忍受要把存储字符串的的空间給增大一倍,并且他们已经有了那么多的该死的文档已经是使用 ANSI 和 DBCS 字符集进行编码和存储的,尼玛谁愿意再一一地去給这些文档做格式转换啊?光是出于这个原因,很多人在很长的一段时间里头都选择无视 Unicode 的存在,与此同时,事情变得越来越糟了。

于是机智的 UTF-8 登场了。UTF-8 是另一个用来编码存储 Unicode 字符码点的编码方式,使用 8 个比特来存储码点魔数种的每一个数字。在 UTF-8 中,码点从 0 到 127 的字符都是使用一个字节来进行存储。只有码点值为 128 以及更高的字符才会使用 2 个或者 3 个字节进行存储,最多的需要使用 6 个字节。

UTF-8

这个方案有个巧妙的附带效果,那就是对于英文来说,在 UTF-8 和 ASCII 中的编码几乎是一模一样的,所以美国人根本都意识不到有任何问题。只有世界上其他地区的人才需要去跳过这些坑。还以这个 Hello 为例,它的 Unicode 码点是 U+0048 U+0065 U+006C U+006C U+006F,在 UTF-8 编码中将会以 48 65 6C 6C 6F 的形式来存储。看,这就跟使用 ASCII 和 ANSI 编码进行存储一毛一样了对伐。好了,现在如果你执意要使用带有音调的字母或者是希腊字母和克林贡字母的话,那么你就需要使用多个字节来保存一个字母的码点了,但是美国人压根儿都意识不到。(UTF-8 还有一个优良的特性,就是那些无知的使用一个值为 0 的字节来作为字符串终止符的老旧字符串处理代码不会错误地把字符串給截断了)

目前为止,我已经告诉了你有三种 Unicode 字符的编码方式。最传统的做法是将所有的字符编码保存到两个字节中,这种编码方法被称为 UCS-2(因为存储在两个字节里头)或者 UTF-16(因为使用了 16 个比特),而且你需要根据字符串的 BOM 来判断其存储采用的是大端字节序还是小端字节序。更为普遍被采用的就是新的 UTF-8 编码标准了,使用 UTF-8 编码有个好处,就是对于那种无脑的只能处理 ASCII 字符陈年老程序或者碰巧你只需要处理英文文本的话,你几乎不用做任何调整就可以正常使用了。

当然实际上还有很多种不同的 Unicode 字符编码方式。例如 UTF-7 就是一种跟 UTF-8 非常相似的编码方式,但是它会确保一个字节中的 8 个比特中最高位的值永远为 0,所以如果你必须通过某种认为只需要使用 7 个比特来进行字符编码就够了的政府-警察的电子邮件系统来发送 Unicode 字符的话,UTF-7 就能将 Unicode 字符压缩到 7 位并进行编码和存储,而不至于丢失字符串中的任何内容。还有 UCS-4,它将每个 Unicode 字符的码点编码为 4 个字节进行存储,它的优势是所有的字符都是使用相同的字节数进行存储,不过,这样的话,即便是德州人估计都不太乐意浪费这么多的内存空间了。

现在你在想的应该是,理论上来讲,所有这些通过 Unicode 码点来表示的字符,应该也能通过原来的老的编码方式来进行编码才对啊。例如,你可以讲 Hello(U+0048 U+0065 U+006C U+006C U+006F)的 Unicode 字符串通过 ASCII 编码的方式进行编码,也可以通过老的 OEM Greek,甚至是 Hebrew ANSI 编码方式,乃至前面我们提到的几百种编码方式。但是使用这些老的编码方式对 Unicode 字符进行编码都会有同一个问题:那就是有些字符是无法正常显示的。如果在这个编码方式中找不到一个跟 Unicode 字符的码点对应的字符来显示,你通常都只能看到一个问号 ?,或者更好一些的就是一个带着黑色的块中有个问号 �。

有好几百种这种过时的编码系统,只能正确地存储一小部分的 Unicode 字符码点,然后将其他的字符都存储的存储为问号 ?了。例如一些非常常用的英文文本编码方式 Windows-1252(Windows 9x 中内置的西欧语言的标准编码方式)和 ISO-8859-1 也叫做 Latin-1(也适用于所有西欧语言),都没法正确地存储俄文字符或者希伯来语字符,而只会把这些字符变成一串串的问号 ?。而 UTF 7,8,16 和 32 就都能很好地将任何码点的字符进行正确的编码和存储。

关于编码的一个最重要的事实

如果你已经完全忘掉了我刚刚吧啦吧啦讲了的一大堆东西,那么就记住一个非常重要的事情,那就是 “如果不知道一个字符串使用的是什么编码,那么你就完全不知道它会是个什么鬼”。你再也不能像鸵鸟一般把头埋到沙子里头,然后假装所有的纯文本都是使用 ASCII 编码的。

根本就没有什么纯文本这样的东西。

如果你有一个字符串,不论它是在内存里,在文件中,还是在一封电子邮件里头,你都需要知道它的编码方式,否则你压根就没法正确地解析它,然后再正确地显示给你的用户。

几乎所有类似“我的网站看起来尼玛彻底乱码了啊”以及“她没法查看我发的带有音调字母的电子邮件”这种傻逼问题,通常都是因为某个无知(naive)的程序猿压根儿都没理解一个很简单的道理,那就是如果你不告诉我你的字符串使用的是 UTF-8 或者 ASCII 还是 ISO-8859-1(Latin 1),又或是 Windows 1252(西欧语言)编码,你压根儿就没法正常地显示这些字符,甚至都没法判断字符串在那儿结束的。高于 127 的码点字符的编码方式有好几百种,这就彻底歇菜了。

那么我们如何将字符串使用的编码信息保存起来呢?当然,这也都有标准的方法来实现。例如在电子邮件中,你就需要在邮件内容的头部加入一段这种格式的文本来说明这封电子邮件正文使用的编码方式:

Content-Type: text/plain; charset=”UTF-8″

对于网页来讲,最开始的想法是这样的,Web 服务器在每次返回网页内容的同时会返回一个类似的 Content-Type 的 HTTP 的头信息,注意不是在网页 HTML 文件中,而是在服务器返回 HTML 文件之前通过一个 HTTP 的响应头返回的。

不过这样就带了一些问题。假设你有一个庞大的 Web 服务器,这个服务器上部署了很多的网站,而且这些网站的网页是由很多使用不同语言的人创建的,而他们都直接使用微软的 FrontPage 编写网页,然后直接将 FrontPage 生成的网页文件拷贝到服务器上。服务器没法真正地确定每个网页文件实际上使用的编码方式的,所以它就没法正确地发送 Content-Type 的 HTTP 头了。

如果你能使用某种特殊的 tag 将 HTML 文件的 Content-Type 在网页文件内进行正确地声明,那么也是挺方便的。但是这就让那些纯粹主义者们就疯了,在你知道一个 HTML 文件的编码之前,你要怎么读取这个 HTML 文件来解析它内部的内容呢?(听起来有点鸡生蛋,蛋生鸡的意思噢)不过幸运的是,几乎所有常用的编码对于码点值 32 到 127 之间的字符处理都是一样的,所以你总是可以直接解析 HTML 文件中的关于编码的 tag 的内容,而不需要关注可能出现的乱码的字符,通常 HTML 文件中关于编码的 tag 的内容如下:

我们需要注意的是这个 meta tag 必须是 <head> 标签中的第一个子节点,因为浏览器在读取到这个 meta tag 之后就会马上停止继续解析当前的网页内容了,而是使用 meta tag 中声明的编码重新开始解析当前网页的内容。

那么如果浏览器没有从 HTML 文件和 Web 服务器返回的 HTTP 头中读取到任何指定当前网页的 Content-Type 的信息,浏览器会怎么解析当前网页呢? IE 浏览器实际上会做一些很有意思的事情:它会基于不同的字节在不同语言中的常用编码和出现频率,尝试着去猜测当前网页使用的语言和编码方式。由于不同的老的 8 位码点页编码系统总是尝试着将其国家或地区语言中特殊字符映射到 128 到 255 之间的不同点位,而每种语言中的字符实际上是有其统计上的特征的,所以 IE 浏览器的这个自动判断当前网页使用的语言和编码方式偶尔还是能奏效的。乍看上去非常诡异,但是由于总是有些无知的网页开发者压根儿就不知道需要在他们编写的网页的头部声明网页的 Content-Type 以便浏览器能正确地展示他编写的网页。直到有一天,他们编写了一个不符合他们母语字符分布规律的网页,而 IE 浏览器最终会把这个网页当成韩文来进行渲染展示,坦率地讲我认为波斯特尔法则(Postel’s Law)—— “严于律己,宽于待人”(哈哈,这个翻译有点诡异,实际上的意思是,对于自己创建和发送的内容要尽量严格要求,减少出错的可能,而对于我们接收的内容要尽可能的宽容,并考虑容错)并不是一个好的工程准则。不管怎么说,这个网站可怜的读者们面对这原本应该显示为保加利亚语的网页被显示为韩语(还不确定是北朝鲜语或者是南朝鲜语),他又能做什么呢?他可以使用 IE 浏览器中菜单栏里头的视图(View)|编码(Encoding)选项来尝试使用不同的编码方式(大概有个几十种东欧的语言吧)来显示当前网页,直到他最终试出了当前网页使用的语言和编码。当然前提是他得知道这么操作才行,然而实际上绝大部分人根本就知道怎么调整浏览器渲染和显示网页时使用的编码。

在我们公司发布的最新版本的网站管理软件 CityDesk 中,我们决定在软件内部实现中都采用 UCS-2 编码,这也是 Visual Basic、COM 和 Windows NT/2000/XP 底层使用的字符串编码方式。在 C++ 中,我们将所有的字符串都声明为 wchar_t(宽字符)类型,而不再使用 char 类型,使用 wcs 方法,而不再使用 str 方法(同理我们会使用 wcscat 和 wcslen 替换 strcat 和 strlen 方法)。在 C 语言中,你只需要在字符串的前面加上一个大写的 L,例如 L“Hello” 就可以创建一个字面上的 UCS-2 字符串了。

在 CityDesk 发布网页时,它会将所有的网页都转化为 UTF-8 编码,多年以前各大浏览器对 UTF-8 编码的支持就已经非常好了。这也是为什么 Joel on Software 这个网站拥有 29 种语言的版本,但是我从来没有听说过任何一位读者反馈说他没法正常地浏览这个网站上的内容。

这边文章写得实在是太长了,但是我依然无法在这一篇文章里涵盖字符编码和 Unicode 的所有内容,但是我依然希望当你读完这篇文章,再回去编程的时候,能不再盲信什么水蛭或咒语可以治病,而是学会如何正确地使用抗生素,这是是我想留给你的一个小任务。