标签归档:Programming

【利器系列】01-Dash-开发人员的随身好字典

前文

Dash 是我开始工作有了收入之后,购买的第一款跟开发相关的软件,从 2011 年开始,「这本好字典」就一直在我手边,每每遇到不认识的「字」,打开 Dash 便是我下意识的动作。今天,我们就来聊聊 Dash 这本「好字典」。

我是 2009 年 7 月来到北京,加入超图软件开始了为期不到一年的 SuperMap Objects for Java 的 SDK 开发后,于 2010 年 5 月加入了喜讯无限,开始了自己为期 10 年之久的创业之路。从喜讯无限最初的 Android App 开发到如今的 Flutter App 开发,中间还有为期近 7 年的移动游戏开发经历,期间做过大量的 Unity3D 和少量的 Cocos2dx 开发工作。

在 2011 年,我们当时创业的团队喜讯无限从第二个民房办公地(天居园媒体村)搬到了第三个正经办公地(一路之隔安全大厦)。当时我们那个年轻可爱的创业团队中的几个开发同学大多都是同年毕业的伙伴,其中一位同学,我们总是称他为「正经梁」,他是一位非常非常全栈的折腾党,从硬件到软件,从前端到后端,从数据库到运维,基本上只要他感兴趣的领域,他都非常愿意投入巨大的热情和精力去折腾,验证自己所见所闻并形成自己的一套方法和实践,实属同辈中的佼佼者。

与此同时的我们其他几位伙伴,恰好也都是有好奇心的家伙,只是大家感兴趣的领域可能稍有不同,大家也乐于相互分享。例如「张 Tree」同学就非常热衷于体验当时互联网和移动互联网的各种新奇的服务和 App(当时 App 还少得可怜,他们还在用着诺基亚的 E71 呢),在体验之后精选出来他认为不错的服务和 App 分享给我们,对于某些做得有所欠缺或者体验欠佳的友商们,偶尔还会 diss 或心疼一番,「孔雀 67」同学就非常痴迷于游戏开发底层框架技术的钻研和图形学,对算法实现有一定的造诣,时不时地给我们丢一两篇很好的文章好某个实现得很优雅的开源框架或引擎的仓库地址。

在那样的环境下,我们一共是 4 位年轻的开发伙伴,「正经梁」负责后端,当时的我们后端使用 PHP 开发(没错,当年的 Facebook 主站核心开发也是基于 PHP 的),他就是那个戏称自己每天站在鄙视链顶端用着「世界上最好的编程语言」跟我们在一起玩。「张 Tree」「孔雀 67」和「我」,我们三个都是前端程序员,「张 Tree」和「孔雀 67」由于 C/C++ 的基础不错,还在我们创业之初做过一段时间的塞班(Symbian)应用开发,后面也都转到了 Android 和 iOS 应用开发了,「我」因为从一开始在 Java 编程上有些基础,所以我从 2010 年一开始就在做 Android 开发。

当时的我们四个人,开发环境都大不相同:

「正经梁」=> 自己折腾安装的黑苹果 + VIM

「张 Tree」+「孔雀 67」=> Windows + Visual Studio

「我」=> Ubuntu + Eclipse(ADT)

在我们搬到安全大厦后,趁着大家都在重新布置自己的工位和电脑,「正经梁」同学说要升级一下他的黑苹果到最新的系统,我们还一顿 diss 他,说他穷逼买不起白苹果。然后他笑称「黑苹果一样非常的稳定,没事别瞎搞,GPU 和 CPU 的能力显然性价比更高」,然后建议说我们也可以尝试一下。我随口开了一个玩笑,「你要是帮我装上,我就用,而且能不能双系统?」没想到「正经梁」一口答应了,说「刚好拿我的机器来测试一下最新黑苹果的兼容性,反正你还有 Ubuntu 作为备份」。就这样,我开始使用「黑苹果」,隐约记得当时使用的系统应该是「Mac OS X 10.6 Snow Leopard」,当时做 Android 开发的我主要使用 Eclipse + ADT,在「黑苹果」上除了快捷键需要重新适应一下,其他基本上没有明显的区别。

在开始使用 Mac OS X 之后,慢慢地除了开发之外也有一些其他的需求,很多的时候都会向我的「黑苹果」体验前辈「正经梁」请教一二,而作为一个满怀好奇心和浑身折腾欲的二逼青年「正经梁」总是能给出非常令人满意的答案。Dash 和 1Password 就是在那个时候由「正经梁」墙裂推荐给我的(那会儿我们还不咋使用安利和种草这样的词)。

Dash 和 1Password 这两款软件不愧是「正经梁」的镇机之宝,自打我开始安装这两个软件到的我的电脑上之后,就再也没有离开我的手边。这两款软件,我的首次安装确实都是从「正经梁」同学处拷贝的破解版,都在我使用不到半年之后,主动购买了正版授权,并且在往后的 10 年时间里,每每跟着版本升级再次付费或订阅。

前面叨叨叨地说了这么多,只是想交代一下我是如何接触和开始使用 Dash 的背景,虽然跟本文核心的内容没有那么大的关系,可是跟我自己有很大的关系,在此回忆和说明一下,也还是蛮高兴的。不是老说人会受原生家庭的影响吗?我这也是受到我们这个小小的原生团队的影响才接触到并开始使用 Dash 的。后面咱们还会聊到很多这种「大家毕业后第一份重要的工作会影响我们整个职业生涯」类似的话题,这就算是一个不是那么明显的稍显微妙的案例。


接下来,正文开始。

Dash 能干嘛?

引用一下 Dash 官网的描述

Dash is an API Documentation Browser and Code Snippet Manager. Dash instantly searches offline documentation sets for 200+ APIs, 100+ cheat sheets and more. You can even generate your own docsets or request docsets to be included.

我们简单翻译一下

Dash 是一个 API 文档浏览器和代码片段管理器。 Dash 可以快速搜索包含 200 多个公开的 API 和 100 多个速查表的离线文档集等。你也可以创建自己的文档集或申请官方新增对某些文档集的支持。

从这个描述上来看,这就是一个本地快查开发参考文档的工具,将大量公开的编程语言和开发框架的官方文档聚合到了一个软件中,并且在本地提供了一个快速搜索的功能。

乍看上去,感觉就这么个功能,为啥还要付钱购买呢?而且我记得当年价格应该也不便宜,至少应该是 19.99 美金这样的价格,而且每次大版本升级都需要付费升级,真的值得吗?我的回答是值,非常值,因为每次我基本都是第一时间付费升级,即便近几年我使用 Dash 的频率越来越低(说明自己基本上停止进步和学习新东西了),但是出于一种习惯和支持优秀开发者的优秀作品,确保这样的产品不会消失,我都会用自己的钱来直接投票。

下面我们来展开讲讲,Dash 到底能干嘛?

Dash 的离线文档聚合能力

在 08 年前后,我们发明了一个新词「科学上网」来应对我们的防火墙长城——GFW 对某些网络资源的封锁。作为 Android 开发者的我很巧的是,我需要经常查看 Android for Developers 这个归属于 Google 的网站上的开发者资源,其中就包含了 Android 官方的开发文档,那么非常不幸的告诉你,大概率上你是无法非常顺畅地直接访问该网站的。对于当时的我来说,初入 Android 开发门道,很多类和方法都不是那么的了然,时常需要查阅官方文档一窥究竟。虽然那个时候的我也有一些「科学上网」的手段,可以参考 2014 年的我写的文章我是怎么科学上网的,这其中简单概述了多年来我为了能自由访问互联网做过的各种尝试,个中辛酸冷暖自知。

要知道,作为开发人员,无法正常访问一门开发语言(Python 语言的官网曾经多年无法正常)和主流开发框架(Android for Developers 直到今天依然无法直接访问)的官方文档,对于一个开发者来说,我认为这种难受是令人发指的,而 Dash 将所有在线文档都打包成了离线文档集,我们可以直接通过 Dash 提供的托管地址下载所需要的文档集资源,例如我就一定会下载 JAVA SE + Android + Python 的文档集。随时流畅访问 Android 和 Python 的开发文档,对于我来说,体验无与伦比,当浮一大白。必须给钱!

Dash 的文档本地快速索引能力

早年间众多开发框架和编程语言的官方文档站点虽说基本能做到结构清晰,文档齐全(主流编程语言和框架),但是其搜索能力相对来说都非常的弱,甚至没有。对于我们在初学某门编程语言或者某个新的开发框架时,实际上很多的地图我们都还没有点亮,我们远不知道这地图上都还有些啥,而很多的时候我们又是带着某个问题或者目的而来求索的,然后我们来到了一个官方文档的页面。我们首先看到的是诸如此类的内容:

Install Guide | Download | Get Started | Documention | References

然而这些目录式的陈列,虽然提供了类似于字典般的检索和引导路径,但是对于一个初到此地的陌生人,坦白讲我们可能连北都找不到,假设我想找一下在 Android 中如何调用接口让手机震动,我能怎么快速找到我想找的内容呢?

通常我们都是打开 Google 直接搜索「android vibration」,当然我发现更多的同学会直接使用百度,搜索内容大致类似「Android 控制手机震动」等等,然后我们可以来看看搜索结果:

百度搜索 Android 控制手机震动的结果页截图
Google 搜索 android vibration 的结果页截图

从上图来看,我们发现使用英文搜索引擎 Google 配合英文技术关键字搜索结果会更为符合我们预期一些。然后我们再来看一下在 Dash 中的检索体验是如何的。

Dash 中搜索 Android Docset 中的 vibrate 的结果页截图

按照我的设定,我只需要输入「and:」这四个关键字先限定我只需要在 Android 的文档集中进行搜索,当我输入「vib」这三个字符后,我就能看到我想要的结果 Vibrator 已经出来了,我一个回车就直接跳转到了 Vibrator 的内容页面了。

虽然这个例子很特殊,但确实不是我刻意挑选的,我就是刚好看到我的手机放在我的手边,收到了一条消息然后手机震动了一下,我就想着是不是可以直接用「手机震动」这个特性来搜索一下。没想到结果也这么的五毛,好像收了钱一样的完全符合我想要表达的。

但是我们理智地来分析一下,实际上很多时候我们作为开发者,遇到一些疑难问题想寻找解决方案的时候,是没有这么明确的关键字可以搜索的,例如「Flutter 中如何监听手机软件盘的打开和关闭?」这样的问题才是我们日常遇到的问题,那么通常我们的解题思路会是啥呢?

我想大部分同学会直接拿着这段关键词在百度搜索框中直接搜索,然后我们也能搜索到很多中文的博客文章,通常文章的来源为「博客园」「CSDN」「简书」「掘金」这么几个地方。我自己的习惯是,先把我自己的问题翻译成英文,然后在 Google 搜索框中搜索,也并没有高级到哪儿去,大家都是换汤不换药,然后通常也能搜到好几篇文章,为首的多是「Stack Overflow」「Medium」,不少的独立博客,偶尔还会有 Youtube 视频结果。

然后大家都会点击搜索结果中的链接,跳转到别人发布的解决方案的文章去查看方案,这个时候如果对方给出的方案中提及了一些关键的类或者方法,咱们好奇心重一些的同学,可能会再拓展延伸阅读一下,通常还是再次回到刚刚打开的搜索引擎页面,再次输入新的关键字,然后重复刚刚我们做的步骤,只是这一次很有可能就会搜索和跳转到我们使用的编程语言或者开发框架的官方文档,如果一次递归还没有到这里,那么递归通常最终结束都是到官方文档这里的。

我们想想看,每次这样在浏览器不同的 Tab 页中频繁的新增和切换,最终达成我们找到解决方案,同时溯源到官方文档的这个流程是否有些过于繁琐了。有了 Dash 之后,基本上我在 Stack Overflow 就可以停止我继续搜索的路径了,通常只需要再配合 Dash 打开具体的编程语言或框架的文档,搜索一下具体的关键词,搭配阅读就能搞清楚我的问题究竟是咋来的要咋解决,以及其中的来龙去脉和基础原理通常八九不离十了。再有兴趣深度了解背后的实现逻辑,我们可能就要转入到源代码级别的阅读了,这里我们就暂时先不展开了。

Dash 提供了一种快速联想的能力

大家可能觉得👆 以上咱们谈到的这两个能力确实还不错,对于一个需要快速检索离线 API 文档的开发人员来说,确实是个利器,能给大家提效不少,如果经济实力允许也有购买正版软件的习惯,完全可以买买买。

但是,我更想说的是 Dash 的另一个隐藏的能力才是让我自己最为受益匪浅的,那就是 Dash 的快速检索和反向查找的能力,在事实上提供了一种快速联想的能力,让我能做到举一反三。我还以我刚才随手举的「手机震动」的这个案例来作为切入点,我们可以看看👇 下面的这张截图

在 Dash 中搜索 vibrate 的过程的屏幕动画录制动图

我们可以看到除了正是我们想找的 Vibrator 可以直接控制手机震动之外,我们还能看到跟系统设置相关的,跟音频管理器相关的,跟系统通知栏相关的,跟权限相关的关键字们。实际上他们都是一些相关联的知识点,只是散落在这个庞大而丰富的 Android 开发文档集中的不同模块里头。通过阅读这些关联的文档,我们不但能直接了解到很多关联的功能和特性,我们更探索到了很多不同的模块,拓宽了我们在 Android 开发这个领域中的全面发展的可能性。

这样类似的例子真的数不胜数,但是关键在于我们要是有心人,是有着好奇心的人,而不是一个快速消费者,来了只点一个蛋挞,吃了就走,完全不看菜单,也不想着自己可能还想吃点其他的,或者先看看了解一下,下回点个鸡翅啥的。而 Dash 就非常好地满足了我自己的好奇心,很多的时候,我就会在使用 Dash 查找 A 的同时,会发散阅读和了解 D 和 S 等等可能并没有那么直接关联的知识点,甚至又回再次回到搜索引擎,最终看了一个 Youtube 视频或者读了一篇其他开发者写的 Medium 长文,花了好几个小时。但是这样提供给我的养料和知识是丰富的,是多维的,更让我能从多方面去了解某一个特性或者知识点。这些知识点终将在我的脑子里形成勾连,最终结网,成为我的能力中的一小块。

Dash 的基本功很扎实

👆 以上三点是我自己对 Dash 最为推崇的三点特性,但是其基础功能的靠谱更是前提,下面我们简单挑几个基本功夸一夸啊。

  1. Dash 的作者做了不少的脏活累活,例如把 .NET Framework, Android, Apple API Reference 等文档做成了 Dash 支持的格式,并且提供了非常快速的离线文档下载速度,请简单回忆一下安装完 Visual Studio 后再安装 MSDN 所需要的时间和那个体验;
  2. 搜索结果展示的高亮,搜索结果条目的所属模块的展示和相似结果的提醒,都非常好地提供了更多维度的信息,供开发者快速判断和定位自己想要查找的信息;
  3. Dash 支持的快速关键词匹配特定文档集索引的功能就是一个很不错的创新性的特性;
  4. 支持第三方文档接入,例如支持第三方文档源:Go Docsets, Java Docsets 等等;
  5. 与其他开发工具的集成一直都做得不错,我们常用的开发工具和场景基本都覆盖到了,例如我自己偶尔会用到的:IDEA,Visual Studio Code,Terminal,Alfred,PopClip,除了这些还有不少,至少在开发者效率上,作者还是很下功夫和花心思的,哪怕帮咱们省一秒钟,作者也是努力去做了,👍。

结语

拉拉杂杂写了也不少,一个人遇到一款软件,有很多的机缘巧合。我是因为加入了一个非常年轻且好奇心浓厚的一个团队,遇到了一些很可爱的伙伴,然后尝试了这些优秀的软件,对自己造成了一些影响,也许是改变,也许只是更加深了对自我本真的塑造,然后我成为了今天我,不算太好,也不算太坏。

作为一个普通的开发者,我有着比较清楚的自知之明,我也清楚地知道,在真实的互联网开发团队中,很多的开发伙伴们还依然在使用土法制炸弹,虽然做出来的炸弹也能响,也能以此卖得三二两酒钱,但是我想大体上大家还是希望有一些更为不错的工具和方法能让自己做的不那么狼狈,做得不那么辛苦,做得稍微体面一些吧。

希望能提供一丁点帮助,带来多一丢丢启发或思考,大家一起进步,加油。

【利器系列】00-写在前面

最近跟好友「孔雀 67」聊了多次关于「一个到了要被大厂优化的 35 岁的高龄程序员,接下来要干些啥?」这个话题,由于我俩的革命友谊建立已有 14 年之久,更有长达 8 年之久居同一屋檐下,除去一同求学的 4 年同窗同宿之谊,更有 2 年共同创业背靠背作战的经历,所以我俩之间的通话基本上能做到毫不保留,也非常地简单纯粹。

谈话中,好友谈及自己近期开启了一个新的 Side Project —— 花十年乃至更长的时间,做一个优秀的体素游戏引擎,从他的嘴里说出来这样的话,让我听着感觉非常的信服和幸福。

信服来自于好友近 5 年在体素游戏领域的深耕,自己从零开始,带领一个原本是做移动互联网 App 的团队成功转型,从一个开源的小引擎入手,持续迭代 3 年,从技术到产品,一点点创造和打磨出来一个非常优秀的体素游戏,在商业上获得了不错的成绩。基于这 5 年前的判断,这 5 年间的投入和产出,以及这些年他在该领域解决的各种难题,积累下来的经验和解决方案,我相信他的这个 Side Project 非常的真实可触及,我要先祝福他。

幸福来自于我自己对好友的羡慕,羡慕他能有如此明确的想做的事情,而且听着又是那么切实际和可执行的项目,同时还是一个那么契合自己对长期主义比较推崇的这个心理,在我看来,「花十年乃至更长的时间,做一个优秀的体素游戏引擎」这是一个长期来看都很有意义的事情,未来价值不会消解甚至可能会增长的 事情。

反观自己,从 10 年开始移动互联网创业,从移动社区到手游,再从手游回到互动娱乐直播,从 Android 开发到 Unity3D 开发,再回到 Flutter 开发,感觉自己做过的事情不老少,但是留下的东西少之又少轻于鸿毛,在技术领域也没有什么深厚的积累,在商业上更是未获得什么可以称得上成绩的结果。

所以当 2022 年开始,我就尝试不断地问自己,「除了做一个互联网公司的工具人,我还能做些啥?」,直到最近,一直都没有一个接近好友「花十年乃至更长的时间,做一个优秀的体素游戏引擎」这样的一个可执行的想法或事情作为答案出现。虽说当年大学毕业求职的时候,最终选择了来北京加入一个软件公司,成为了一名软件开发人员,但是自己对于通过代码创造一些东西这个事情,一直没有那种在别人的文本中渲染的为代码痴迷的热情。只是出于养家糊口需要有一技之长,和出于为事情负责任的心态,持续在做着写代码这个事情,在写的过程中只是觉得自己应该干得漂亮一些,不能做得太糟糕,不想丢人或者辜负别人的期望,才一步步走到了现在这个样子。

写代码这件事情并非我所爱(也许可能自己也不知道自己真正热爱的是啥,更有可能是自己做啥就烦啥吧,可能就是因为没有做出来啥成绩罢了)这个事实,慢慢地我学会了接受它,我也接受了我并不讨厌写代码这个事实,同时也接受了我写代码还不太烂的这个事实,终于,我发现了自己只是一个非常非常普通的程序员,只是在写代码赚钱,并尽量让这个过程不那么狼狈和难看。

接受了自己没有这么强烈的创造欲和探索欲之后,内心反而坦然了一些,那么问问自己喜欢做点啥,哪些事情做得还不错,做哪些事情能让自己内心有一些满足感,那么我就做这个吧。

持续创业了 10 年之多,虽然没有进过什么大厂见世面,但是 10 年的在一线创业团队中的摸爬滚打,让我在解决问题上的能力得到了非常充分的锻炼,我也可以摸着自己的心口说一句,在这点上我并不怵,而且我自己也比较享受跟人分享我是如何锻炼自己解决问题的能力的过程,而且自认为这件事情做得还不算太糟。解决问题的能力听起来很虚,但实际上是可以具象化的,而且是可以分模块拆解的。在我自己的逻辑里头,解决问题的能力可以拆解为以下几点:

  1. 理解问题的能力,这是解决问题的第一步,也是很多人不太重视或者较容易忽视的一点,很多的时候我们能否正确地通过对现象(很多时候我们都是遇到了某个具体的事情或者观察到了某个现象)的分析,尝试确定问题对于我们最终找到解决方案至关重要,如果第一步就走错了,后续很有可能要绕很多弯路才能抵达正确的地方;
  2. 分析和定位问题的能力,通常当我们理解了问题之后,基本上我们已经知道问题的方向和可能出现问题的地方了,然后顺着在上一个步骤中发现的一些蛛丝马迹抽丝剥茧地找到问题的源头,抓到这个虫子🐛,然后把它给治了,通常这个步骤是大家日常花费时间和精力最多的,也是大家八仙过海各显神通的环节,每个人抓虫🐛 的方法各有千秋,但实际上是有技巧优劣之分和效率高低之别的(别跟我说黑猫白猫抓到老鼠就是好猫这种没有用的话,大家都在一个商业社会里,大家也都是有血有肉的人,大家实际一些,考虑一下投入产出比和抓虫人的个体感受,好不好);
  3. 归因和总结的能力,在解决了问题之后,能否回过头来思考一下,为何在自己的代码中或者项目中出现这样的问题,并且得出结论最终形成自己思考,为自己或团队后续的工作提供一些参考或者指导,并且能够形成自己或团队的一些积淀,就更是善莫大焉了。

👆 以上三点,第一和第三点,看着很虚,但是实际上是对人要求更高的软能力,第二点是非常实际的硬技能也是每一个开发人必须具备的恰饭技能。

由于第二点中涉及的更多的是我们日常开发和排查问题过程中需要使用到的硬技能,也是更好展开来聊聊的问题,毕竟这也是难度可低可高,深度可深可浅的一个话题,咱们可以由浅入深,慢慢展开。所以我们就先从分析和定位问题的能力开始,而分析和定位问题中,我们会使用到诸多的工具来帮助我们,正所谓「工欲善其事,必先利其器」,而且我在工作了 10 多年之后,观察了一些伙伴们在分析和定位问题中使用的方法和工具,觉得大家还是有些苦于没有更好地利用到我们可以利用到的更优秀的一些工具和方法,导致自己排查问题的过程并没有那么有效率,体感也相对较差,总是略显狼狈或者磕磕绊绊的。

那么我就先开这个坑——「利器系列」,在这个系列中,我会跟大家分享一下,这些年里头我自己常用的一些工具和方法,以及如何利用这些工具和方法来达成我们「善其事」的目的,希望能给感兴趣的伙伴们提供一些参考或思路上的开拓。关于如何提升自己的软能力的话题,我们留着后面再来一起探讨,咱们不急。

【翻译】关于 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 的内容如下:

<html>
<head>
<meta http-equiv=“Content-Type” content=“text/html; charset=utf-8”>

我们需要注意的是这个 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 的所有内容,但是我依然希望当你读完这篇文章,再回去编程的时候,能不再盲信什么水蛭或咒语可以治病,而是学会如何正确地使用抗生素,这是是我想留给你的一个小任务。

Android 中解决图像解码导致的 OOM 问题

在上一篇博文 Android Bitmap 内存限制中我们详细的了解并分析了 Android 为什么会在 Decode Bitmap 的时候出现 OOM 错误,简单的讲就是 Android 在解码图片的时候使用了本地代码来完成解码的操作,但是使用的内存是堆里面的内存,而堆内存的大小是收 VM 实例可用内存大小的限制的,所以当应用程序可用内存已经无法再满足解码的需要时,Android 将抛出 OOM 错误。

这里讲一个题外话,也就是为何 Android 要限制每个应用程序的可用内存大小呢?其实这个问题可能有多方面的解答,目前我自己考虑到的有两点:

  1. 使得内存的使用更为合理,限制每个应用的可用内存上限,可以防止某些应用程序恶意或者无意使用过多的内存,而导致其他应用无法正常运行,我们众所周知的 Android 是有多进程的,如果一个进程 (也就是一个应用) 耗费过多的内存,其他的应用还搞毛呢?当然在这里其实是有一个例外,那就是如果你的应用使用了很多本地代码,在本地代码中创建对象解码图像是不会被计算到的,这是因为你使用本地方法创建的对象或者解码的图像使用的是本地堆的内存,跟系统是平级的,而我们通过 Framework 调用 BitmapFactory.decodeFile() 方法解码时,系统虽然也是调用本地代码来进行解码的,但是 Android Framework 在实现的时候,刻意地将这部分解码使用的内存从堆里面分配了而不是从本地堆里分配的内存,所以才会出现 OOM,当然并不是说从本地堆里分配就不会出现 OOM,本地堆分配内存超过系统可用内存限制的话,通常都是直接崩溃,什么错误可能都看不到,也许会有一些崩溃的错误字节码之类的。
  2. 省电的考虑,呃…,原因我好像也不能很明白地说出来。

回到正题来,我们在应用的设计和开发中可能会经常碰到需要在一个界面上显示数十张图片乃至上百张,当然限于手机屏幕的大小我们通常在设计中会使用类似于列表或者网格的控件来展示,也就是说通常一次需要显示出来图片数还是一个相对确定的数字,通常也不会太大。如果数目比较大的画,通常显示的控件自身尺寸就会比较小,这个时候可以采用缩略图策略。下面我们来看看如果避免出现 OOM 的错误,这个解决方案参考了 Android 示范程序 XML Adapters 中的 ImageDownloader.java 中的实现,主要是使用了一个二级缓存类似的机制, 就是有一个数据结构中直接持有解码成功的 Bitmap 对象引用,同时使用一个二级缓存数据结构持有解码成功的 Bitmap 对象的 SoftReference 对象,由于 SoftReference 对象的特殊性,系统会在需要内存的时候首先将 SoftReference 对象持有的对象释放掉,也就是说当 VM 发现可用内存比较少了需要触发 GC 的时候,就会优先将二级缓存中的 Bitmap 回收,而保有一级缓存中的 Bitmap 对象用于显示。

其实这个解决方案最为关键的一点是使用了一个比较合适的数据结构,那就是 LinkedHashMap 类型来进行一级缓存 Bitmap 的容器,由于 LinkedHashMap 的特殊性,我们可以控制其内部存储对象的个数并且将不再使用的对象从容器中移除,这就给二级缓存提供了可能性,我们可以在一级缓存中一直保存最近被访问到的 Bitmap 对象,而已经被访问过的图片在 LinkedHashMap 的容量超过我们预设值时将会把容器中存在时间最长的对象移除,这个时候我们可以将被移除出 LinkedHashMap 中的对象存放至二级缓存容器中,而二级缓存中对象的管理就交给系统来做了,当系统需要 GC 时就会首先回收二级缓存容器中的 Bitmap 对象了。在获取对象的时候先从一级缓存容器中查找,如果有对应对象并可用直接返回,如果没有的话从二级缓存中查找对应的 SoftReference 对象,判断 SoftReference 对象持有的 Bitmap 是否可用,可用直接返回,否则返回空。

主要的代码段如下:

private static final int HARD_CACHE_CAPACITY = 16;

// Hard cache, with a fixed maximum capacity and a life duration
private static final HashMap<String, Bitmap> sHardBitmapCache = new LinkedHashMap<String, Bitmap>(HARD_CACHE_CAPACITY / 2, 0.75f, true) {
    private static final long serialVersionUID = -57738079457331894L;

    @Override
    protected boolean removeEldestEntry(LinkedHashMap.Entry<String, Bitmap> eldest) {
        if (size() > HARD_CACHE_CAPACITY) {
            // Entries push-out of hard reference cache are transferred to soft reference cache
            sSoftBitmapCache.put(eldest.getKey(), new SoftReference<Bitmap>(eldest.getValue()));
            return true;
        } else
            return false;
    }
};

// Soft cache for bitmap kicked out of hard cache
private final static ConcurrentHashMap<String, SoftReference<Bitmap>> sSoftBitmapCache = new ConcurrentHashMap<String, SoftReference<Bitmap>>(HARD_CACHE_CAPACITY / 2);

/**
* @param id
*            The ID of the image that will be retrieved from the cache.
* @return The cached bitmap or null if it was not found.
*/
public Bitmap getBitmap(String id) {
    // First try the hard reference cache
    synchronized (sHardBitmapCache) {
        final Bitmap bitmap = sHardBitmapCache.get(id);
        if (bitmap != null) {
            // Bitmap found in hard cache
            // Move element to first position, so that it is removed last
            sHardBitmapCache.remove(id);
            sHardBitmapCache.put(id, bitmap);
            return bitmap;
        }
    }

    // Then try the soft reference cache
    SoftReference<Bitmap> bitmapReference = sSoftBitmapCache.get(id);
    if (bitmapReference != null) {
        final Bitmap bitmap = bitmapReference.get();
        if (bitmap != null) {
            // Bitmap found in soft cache
            return bitmap;
        } else {
            // Soft reference has been Garbage Collected
            sSoftBitmapCache.remove(id);
        }
    }

    return null;
}

public void putBitmap(String id, Bitmap bitmap) {
    synchronized (sHardBitmapCache) {
        if (sHardBitmapCache != null) {
            sHardBitmapCache.put(id, bitmap);
        }
    }
}

上面这段代码中使用了 id 来标识一个 Bitmap 对象,这个可能大家在实际的应用中可以选择不同的方式来索引 Bitmap 对象,图像的解码在这里就不做赘述了。这里主要讨论的就是如何管理 Bitmap 对象,使得在实际应用中不要轻易出现 OOM 错误,其实在这个解决方案中,HARD_CACHE_CAPACITY 的值就是一个经验值,而且这个跟每个应用中需要解码的图片的实际大小直接相关,如果图片偏大的话可能这个值还得调小,如果图片本身比较小的话可以适当的调大一些。本解决方案主要讨论的是一种双缓存结合使用 SoftReference 的机制,通过使用二级缓存和系统对 SoftReference 对象的回收特性,让系统自动回收不再敏感的图片 Bitmap 对象,而保有一级缓存也就是敏感的图片 Bitmap 对象。

Android Bitmap 内存限制

在编写 Android 程序的时候,我们总是难免会碰到 OOM 的错误,那么这个错误究竟是怎么来的呢?我们先来看一下这段异常信息:

08-14 05:15:04.764: ERROR/dalvikvm-heap(264): 3528000-byte external allocation too large for this process.
08-14 05:15:04.764: ERROR/(264): VM won’t let us allocate 3528000 bytes
08-14 05:15:04.764: DEBUG/skia(264): — decoder->decode returned false
08-14 05:15:04.774: DEBUG/AndroidRuntime(264): Shutting down VM
08-14 05:15:04.774: WARN/dalvikvm(264): threadid=3: thread exiting with uncaught exception (group=0x4001b188)
08-14 05:15:04.774: ERROR/AndroidRuntime(264): Uncaught handler: thread main exiting due to uncaught exception
08-14 05:15:04.794: ERROR/AndroidRuntime(264): java.lang.OutOfMemoryError: bitmap size exceeds VM budget
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.graphics.BitmapFactory.nativeDecodeAsset(Native Method)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.graphics.BitmapFactory.decodeStream(BitmapFactory.java:447)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.graphics.BitmapFactory.decodeResourceStream(BitmapFactory.java:323)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.graphics.BitmapFactory.decodeResource(BitmapFactory.java:346)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.graphics.BitmapFactory.decodeResource(BitmapFactory.java:372)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at com.xixun.test.HelloListView.onCreate(HelloListView.java:33)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1047)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2459)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2512)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.app.ActivityThread.access$2200(ActivityThread.java:119)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1863)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.os.Handler.dispatchMessage(Handler.java:99)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.os.Looper.loop(Looper.java:123)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at android.app.ActivityThread.main(ActivityThread.java:4363)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at java.lang.reflect.Method.invokeNative(Native Method)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at java.lang.reflect.Method.invoke(Method.java:521)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:860)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:618)
08-14 05:15:04.794: ERROR/AndroidRuntime(264):     at dalvik.system.NativeStart.main(Native Method)

从上面这段异常信息中,我们看到了一个 OOM(OutOfMemory) 错误,我称其为 (OMG 错误)。出现这个错误的原因是什么呢?为什么解码图像会出现这样的问题呢?关于这个问题,我纠结了一段时间,在网上查询了很多资料,甚至查看了 Android Issues,确实看到了相关的问题例如 Issue 3405Issue 8488,尤其 Issue 8488 下面一楼的回复,让我觉得很雷人啊:

Comment 1 by romain…@android.com, May 23, 2010

Your app needs to use less memory.

当然我们承认不好的程序总是程序员自己错误的写法导致的 ,不过我们倒是非常想知道如何来规避这个问题,那么接下来就是解答这个问题的关键。

我们从上面的异常堆栈信息中,可以看出是在 BitmapFactory.nativeDecodeAsset(),对应该方法的 native 方法是在 BitmapFactory.cpp 中的 doDecode() 方法,在该方法中申请 JavaPixelAllocator 对象时,会调用到 Graphics.cpp 中的 setJavaPixelRef() 方法,在 setJavaPixelRef() 中会对解码需要申请的内存空间进行一个判断,代码如下:

bool r = env->CallBooleanMethod(gVMRuntime_singleton,

                                   gVMRuntime_trackExternalAllocationMethodID,

                                   jsize);

而 JNI 方法 ID — gVMRuntime_trackExternalAllocationMethodID 对应的方法实际上是 dalvik_system_VMRuntime.c 中的 Dalvik_dalvik_system_VMRuntime_trackExternalAllocation(),而在该方法中又会调用大 HeapSource.c 中的 dvmTrackExternalAllocation() 方法,继而调用到 externalAllocPossible() 方法,在该方法中这句代码是最关键的

heap = hs2heap(hs);

   currentHeapSize = mspace_max_allowed_footprint(heap->msp);

   if (currentHeapSize + hs->externalBytesAllocated + n <=

           heap->absoluteMaxSize)

   {

       return true;

   }

这段代码的意思应该就是当前堆已使用的大小 (由 currentHeapSize 和 hs->externalBytesAllocated 构成) 加上我们需要再次分配的内存大小不能超过堆的最大内存值。那么一个堆的最大内存值究竟是多大呢。通过下面这张图,我们也许可以看到一些线索 (自己画的,比较粗糙)

image

最终的决定权其实是在 Init.c 中,因为 Android 在启动系统的时候会去优先执行这个里面的函数,通过调用 dvmStartup() 方法来初始化虚拟机,最终调用到会调用到 HeapSource.c 中的 dvmHeapSourceStartup() 方法,而在 Init.c 中有这么两句代码:

gDvm.heapSizeStart = 2 * 1024 * 1024;   // Spec says 16MB; too big for us.

gDvm.heapSizeMax = 16 * 1024 * 1024;    // Spec says 75% physical mem

在另外一个地方也有类似的代码,那就是 AndroidRuntime.cpp 中的 startVM() 方法中:

strcpy(heapsizeOptsBuf, "-Xmx");

property_get("dalvik.vm.heapsize", heapsizeOptsBuf+4, "16m");

//LOGI("Heap size: %s", heapsizeOptsBuf);

opt.optionString = heapsizeOptsBuf;

同样也是默认值为 16M,虽然目前我看到了两个可以启动 VM 的方法,具体 Android 何时会调用这两个初始化 VM 的方法,还不是很清楚。不过可以肯定的一点就是,如果启动 DVM 时未指定参数,那么其初始化堆最大大小应该就是 16M,那么我们在网上查到了诸多关于解码图像超过 8M 就会出错的论断是如何得出来的呢?

我们来看看 HeapSource.c 中的这个方法的注释

/*

* External allocation tracking

*

* In some situations, memory outside of the heap is tied to the

* lifetime of objects in the heap.  Since that memory is kept alive

* by heap objects, it should provide memory pressure that can influence

* GCs.

*/

static bool

externalAllocPossible(const HeapSource *hs, size_t n)

{

    const Heap *heap;

    size_t currentHeapSize;

   /* Make sure that this allocation is even possible.

     * Don’t let the external size plus the actual heap size

     * go over the absolute max.  This essentially treats

     * external allocations as part of the active heap.

     *

     * Note that this will fail "mysteriously" if there’s

     * a small softLimit but a large heap footprint.

     */

    heap = hs2heap(hs);

    currentHeapSize = mspace_max_allowed_footprint(heap->msp);

    if (currentHeapSize + hs->externalBytesAllocated + n <=

            heap->absoluteMaxSize)

    {

        return true;

    }

    HSTRACE("externalAllocPossible(): "

            "footprint %zu + extAlloc %zu + n %zu >= max %zu (space for %zu)\n",

            currentHeapSize, hs->externalBytesAllocated, n,

            heap->absoluteMaxSize,

            heap->absoluteMaxSize –

                    (currentHeapSize + hs->externalBytesAllocated));

    return false;

}

标为红色的注释的意思应该是说,为了确保我们外部分配内存成功,我们应该保证当前已分配的内存加上当前需要分配的内存值,大小不能超过当前堆的最大内存值,而且内存管理上将外部内存完全当成了当前堆的一部分。也许我们可以这样理解,Bitmap 对象通过栈上的引用来指向堆上的 Bitmap 对象,而 Bitmap 对象又对应了一个使用了外部存储的 native 图像,实际上使用的是 byte[] 来存储的内存空间,如下图:

image

我想到现在大家应该已经对于 Bitmap 内存大小限制有了一个比较清楚的认识了。至于前几天从 Android123 上看到 “Android 的 Btimap 处理大图片解决方法”一文中提到的使用 BitmapFactory.Options 来设置 inTempStorage 大小,我当时看完之后就尝试了一下,这个设置并不能解决问题,而且很有可能会给你带来不必要的问题。从 BitmapFactory.cpp 中的代码来看,如果 option 不为 null 的话,那么会优先处理 option 中设置的各个参数,假设当前你设置 option 的 inTempStorage 为 1024*1024*4(4M) 大小的话,而且每次解码图像时均使用该 option 对象作为参数,那么你的程序极有可能会提前失败,在我的测试中,我使用了一张大小为 1.03M 的图片来进行解码,如果不使用 option 参数来解码,可以正常解码四次,也就是分配了四次内存,而如果我使用 option 的话,就会出现 OOM 错误,只能正常解码两次不出现 OOM 错误。那么这又是为什么呢?我想是因为这样的,Options 类似与一个预处理参数,当你传入 options 时,并且指定临时使用内存大小的话,Android 将默认先申请你所指定的内存大小,如果申请失败,就抛出 OOM 错误。而如果不指定内存大小,系统将会自动计算,如果当前还剩 3M 空间大小,而我解码只需要 2M 大小,那么在缺省情况下将能解码成功,而在设置 inTempStorage 大小为 4M 的情况下就将出现 OOM 错误。所以,我个人认为通过设置 Options 的 inTempStorage 大小根本不能作为解决大图像解码的方法,而且可能带来不必要的问题,因为 OOM 错误在某些情况是必然出现的,也就是上面我解释的那么多关于堆内存最大值的问题,只要解码需要的内存超过系统可分配的最大内存值,那么 OOM 错误必然会出现。当然对于 Android 开发网为何发布了这么一篇文章,个人觉得很奇怪,我想作为一个技术人员发布一篇文章,至少应该自己尝试着去测试一下自己的程序吧,如果只是翻翻 SDK 文档,然后就出来一两篇文章声称是解决某问题的方案,恐怕并不是一种负责任的行为吧。

=================================

还是点到为止吧,希望大家都自己去测试一下,验证一下,毕竟自己做过验证的才能算是放心的。