1. 记一个 Base64 有关的 Bug

    本文原计划写两部分内容,第一是记录最近遇到的与 Base64 有关的 Bug,第二是 Base64 编码的原理详解。结果写了一半发现,诶?不复杂的一个事儿怎么也要讲这么长?不利于阅读和理解啊(其实是今天有点懒想去休闲娱乐会儿),所以 Base64 编码的原理详解的部分将在下一篇带来,敬请关注。 0x01 遇到的现象 A 向 B 提供了一个接口,约定接口参数 Base64 编码后传递。 但 A 对 B 传递的参数进行 Base64 解码时报错了: Illegal base64 character a 0x02 原因分析 搜索后发现这是一个好多网友们都踩过的坑,简而言之就一句话:Base64 编/解码器有不同实现,有的不相互兼容。 比如我上面遇到的现象,可以使用下面这段代码完整模拟复现: package org.mazhuang.base64test; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.util.Base64Utils; import sun.misc.BASE64Encoder; @SpringBootApplication public class Base64testApplication implements CommandLineRunner { @Override public void run(String... args) throws Exception { byte[] content = "It takes a strong man to save himself, and a great man to save another.".getBytes(); String encrypted = new BASE64Encoder().encode(content); byte[] decrypted = Base64Utils.decodeFromString(encrypted); System.out.println(new String(decrypted)); } public static void main(String[] args) { SpringApplication.run(Base64testApplication.class, args); } } 以上代码执行会报异常: Caused by: java.lang.IllegalArgumentException: Illegal base64 character a at java.util.Base64$Decoder.decode0(Base64.java:714) ~[na:1.8.0_202-release] at java.util.Base64$Decoder.decode(Base64.java:526) ~[na:1.8.0_202-release] 注: 测试代码里的那个字符串如果很短,比如「Hello, World」这种,可以正常解码。 也就是说,用 sun.misc.BASE64Encoder 编码,用 org.springframework.util.Base64Utils 进行解码,是有问题的,我们可以用它俩分别对以上符串进行编码,然后输出看看差异。测试代码: byte[] content = "It takes a strong man to save himself, and a great man to save another.".getBytes(); System.out.println(new BASE64Encoder().encode(content)); System.out.println("--- 华丽的分隔线 ---"); System.out.println(Base64Utils.encodeToString(content)); 输出: SXQgdGFrZXMgYSBzdHJvbmcgbWFuIHRvIHNhdmUgaGltc2VsZiwgYW5kIGEgZ3JlYXQgbWFuIHRv IHNhdmUgYW5vdGhlci4= --- 华丽的分隔线 --- SXQgdGFrZXMgYSBzdHJvbmcgbWFuIHRvIHNhdmUgaGltc2VsZiwgYW5kIGEgZ3JlYXQgbWFuIHRvIHNhdmUgYW5vdGhlci4= 可以看到 sun.misc.BASE64Encoder 编码后的内容换行了,而换行符的 ASCII 编码正好是 0x0a,如此貌似解释得通了。让我们进一步跟踪一下,找一下出现这种差异的源头。 0x03 更进一步 在 IDEA 里按住 CTRL 或 COMMAND 键点击方法名,可以跳转到它们的实现。 3.1 sun.misc.BASE64Encoder.encode 这种写法主要涉及到两个类,sun.misc 包下的 BASE64Encoder 和 CharacterEncoder,其中后者是前者的父类。 它实际工作的 encode 方法是在 CharacterEncoder 文件里,带注释版如下: public void encode(InputStream inStream, OutputStream outStream) throws IOException { int j; int numBytes; // bytesPerLine 在 BASE64Encoder 里实现,返回 57 byte tmpbuffer[] = new byte[bytesPerLine()]; // 用 outStream 构造一个 PrintStream encodeBufferPrefix(outStream); while (true) { // 读取最多 57 个 bytes numBytes = readFully(inStream, tmpbuffer); if (numBytes == 0) { break; } // 啥也没干 encodeLinePrefix(outStream, numBytes); // 每次处理 3 bytes,编码成 4 bytes,不足位的补 0 位和 '=' for (j = 0; j < numBytes; j += bytesPerAtom()) { // ... } if (numBytes < bytesPerLine()) { break; } else { // 换行 encodeLineSuffix(outStream); } } // 啥也没干 encodeBufferSuffix(outStream); } 然后在 CharacterEncoder 类的注释里我们可以看到编码后的格式: [Buffer Prefix] [Line Prefix][encoded data atoms][Line Suffix] [Buffer Suffix] 而结合 BASE64Encoder 这个实现类来看,Buffer Prefix、Buffer Suffix 和 Line Prefix 都为空,Line Suffix 为 \n。 至此,我们已经找到实现中换行的部分——这个编码器实现里,读取 57 个 byte 作为一行进行编码(编码完成后是 76 个 byte)。 3.2 org.springframework.util.Base64Utils.encodeToString 这种写法主要涉及到 org.springframework.util.Base64Utils 和 java.util.Base64 两个类,可以看到前者主要是后者的封装。 Base64Utils.encodeToString 这种写法最终用到的是 Base64.Encoder.RFC4648 这种编码器: // isURL = false,newline = null,linemax = -1,doPadding = true static final Encoder RFC4648 = new Encoder(false, null, -1, true); 留意 newline 和 linemax 的值。 然后看实际的编码实现所在的 Base64.encode0 方法: private int encode0(byte[] src, int off, int end, byte[] dst) { // ... while (sp < sl) { // ... // 这个条件不会满足,不会加换行 if (dlen == linemax && sp < end) { for (byte b : newline){ dst[dp++] = b; } } } // ... return dp; } 所以……这个实现里没有换行。 0x04 小结 经过以上的分析,真相已经大白了,就是两个编码器的实现不一样,我们在开发过程中注意使用匹配的编码解码器就 OK 了,就是用哪个 Java 包下面的编码器编码,就用相同包下的对应解码器解码。 至于为啥会出现不一样的实现,它们之间有过什么来龙去脉、恩怨情仇,Base64 的详细原理等等,就厚着老脸,邀请大家且听下回分解吧!:-P

    2020/03/01 Java

  2. GitHub 用户专属福利,实际到账 3K+,Namebase Airdrop

    我经常提醒自己的防骗第一准则:天上不会掉馅饼。 冒着被人当骗子的风险,写这样一篇文章,是因为这次是真的领到了馅饼。不过这个馅饼不是随机掉落,是限定了条件定向投放的,满足条件的可以一试,不满足的就不用浪费时间了,可以推荐给身边的 GitHub 用户来碰碰运气。 我的馅饼到账图: 从开始操作到入账历时约一天,花了一两个小时在了解和操作上面。 条件 在 2019-02-04 那一周,你的 GitHub 账号有 15 个以上 followers; 保留有当时的 SSH / PGP 私钥; 背景简介 详情可查看 https://www.namebase.io/airdrop,大意就是说 Handshake Orgnization 从 A16Z 和红杉融资以后,向 GNU、Mozilla 和其它互联网基金会捐赠了 10.2 亿美元,现在他们向 GitHub 上符合条件的开发者赠送约 4662 Handshake 币。 而这些币可以提取到 Namebase 账户,并可以兑换成比特币或美元,最终换成人民币提现。 Handshake Orgnization 可以提供 CA 的分布式替代方案,去中心化的 DNS 以提升互联网安全性,详见 https://www.namebase.io/blog/meet-handshake-decentralizing-dns-to-improve-the-security-of-the-internet。建议有兴趣的人除了收馅饼,也关注一下项目,项目方放 Airdrop 是希望更多人能参与并支持他们的项目建设。 取馅饼步骤 好了闲话少述,我知道大家最关心的还是怎么领到钱,步骤可以根据 https://www.namebase.io/airdrop 讲的来,以下我也简单做个描述,供懒得看英文的朋友参考: Step 1. 验证并领取 HNS 打开 https://www.namebase.io,点击右上角 Log In,使用 GitHub 账号登录; 打开 https://www.namebase.io/airdrop,按它的步骤来,第一步,下载 hs-airdrop 工具: git clone https://github.com/handshake-org/hs-airdrop.git 第二步,安装 hs-airdrop 工具: cd hs-airdrop && npm install 我在 npm install 时遇到过两个问题,一个是 Your PYTHONPATH points to a site-packages dir ...,解决方案参考 https://mazhuang.org/wiki/python/#your-pythonpath-points-to-a-site-packages-dir,另一个是 No Xcode or CLT version detected,解决方案参考 https://mazhuang.org/wiki/mac/#no-xcode-or-clt-version-detected。 第三步,找到你认为有资格的 SSH / PGP 私钥,一般是放在 ~/.ssh 目录下,比如我的是 ~/.ssh/id_rsa; 第四步,点击网页上的 Step 4 的 Click To Show Your Handshake Wallet Address,得到你的 HNS 币钱包 address: 然后执行: ./bin/hs-airdrop <path to key> <address> <fee> 比如我执行的是 ./bin/hs-airdrop ~/.ssh/id_rsa xxxxxxx 0.01 如果是用 GPG key 的,使用命令(不明白含义的可以 ./bin/hs-airdrop –help 查看命令帮助文档): ./bin/hs-airdrop <导出的 .asc/.pgp/.gpg 文件> <gpg-id> <address> -f 0.01 这里有几点可以加速命令执行的: 一、fee 可以设置高一点,比如 10,这会加快确认速度。 二、可以先把 https://github.com/handshake-org/hs-tree-data clone 到 ~/.hs-tree-data,这样在以上命令执行过程中需要下载的文件就在本地了。 注: 这一步会用到私钥,有很多人担忧这里存在安全隐患,文档上有说明说是用私钥只用于生成加密证明,不会被上传,证明里也不会包含私钥的任何信息,hs-airdrop 的源码是开源的,可以 review 它的代码,或者实在不放心的可以在操作完之后就把用于 GitHub 的密钥都换掉。 第五步,上一步执行成功后,会在最后展示一段 Base64,将它贴到网页里并提交: 如果没有得到 Base64,而是其它提示,有可能是没有资格,也有可能是出错了,可以参考下文末 V2EX 链接里的讨论内容。 正常这时候就能在 https://www.namebase.io/dashboard 看到有一笔交易在 Pending 中了: 注: 这一步正常应该是很快变成 Airdrop: waiting for more confirmations,但有的人可能遇到较长时间显示 Airdrop: almost mined… 的情况,有的等一段时间后可以好,有的则一直在这个状态。这种情况知乎网友 Kenkk 问过客服,回复是 Some airdrops were stuck. Please generate a base64 with a new address from these instructions and submit it again,也就是重新生成钱包地址,然后重新执行第五步的命令即可,注意 fee 可以设置大点,比如 10。 Step 2. 身份验证 点 namebase 网页如图上所示位置的“验证了”开始验证身份,会要填名字、问是否居住在美国、上传证件照片,可以上传护照、驾驶证、身份证,我用的驾驶证。 网友们说这里校验并不严格,不放心的用网上找的图也可以。 Step 3. 等待 HNS 入账 要等待的时间不等,我等了十几个小时。 Step 4. 提取到 BTC 钱包 点击 namebase 网站菜单的 Buy & Sell – Sell HNS,完成提现到 BTC 钱包: 像我就是以前不接触币圈,没有 BTC 钱包,现去 OKEX 注册了一个,注册链接:https://www.okex.me/join/1876977,(链接上包含我的推荐 ID,如果不喜欢,可以去掉)。注册以后在“我的资产”-“充币”里可以获取 BTC 钱包地址。 然后就又要等待一会儿了,约一二十分钟,BTC 到账了。 Step 5. 提现到银行卡 在 OKEX “我的资产”-“资产划转”里将 BTC 划转到法币账户,然后就可以去“交易”-“法币交易”里按提示操作就行了,需要绑定一张银行卡用作收款,我挂出去不到十秒就被买走了,然后两分钟内银行卡里到账,我确认出币后交易完成。 至此大功告成,按网友们的反馈,按 HNS 换 BTC 行情,实际到手 500 到 1400 刀不等。 Step 6. 收尾 如果担心信息泄漏的,可以更换 GitHub 密钥、删除 OKEX 上绑定的信息等。 相关讨论和记录 https://v2ex.com/t/645480 https://shidenggui.com/articles/namebase-airdrop

    2020/02/21 GitHub

  3. 让 Tapd 的源码关联功能支持 Gitee 平台

    Tapd 是腾讯提供的越来越完善的项目管理工具,Gitee 是国内相对比较稳的代码托管平台。本文记录了让 Tapd 的源码关联功能支持 Gitee 平台的方法,及摸索过程中遇到的问题的解决步骤。 背景 想要使用 Tapd + Gitee 的组合来管理业余项目,但 Tapd 目前官方支持的代码托管平台只有 Gitlab、GitHub 和腾讯工蜂,并不能直接支持 Gitee,直觉上 Gitee 是基于 Gitlab 开发的,所以尝试在 Tapd 里开启了 Gitlab 服务,然后直接将 webhook 地址配置到 Gitee 项目里,却并不能生效。 求索 这种问题我应该肯定不是第一个遇到,于是在 Tapd 的论坛里搜索 Gitee 关键字,果然在帖子 https://www.tapd.cn/forum/view/67001 里找到了方案。 方案 方案的原理简单来说就是 Gitee 在触发 webhook 时,向目标网址发起的请求和 GitLab 很雷同,只是有个别 Header 的名字不一样,但缺失特定的 Header 信息后无法正常触发 Tapd 的源码关联,所以可以通过 Nginx 反向代理来将缺失的 Header 补全,然后将请求转发给 Tapd 即可。 方案示意图 对比直接支持的 Gitlab 的示意: 所以前提条件是你有一个可以在公网访问到的 Nginx 服务器,且可以自己修改配置。 网友介绍方案及原理的 GitHub 仓库:https://github.com/notzheng/Tapd-Git-Hooks 操作步骤 在 Tapd 项目里开启 Gitlab 服务; 在你可用的公网 Nginx 服务器的配置文件里添加一段配置: server { listen 80; server_name tapdhooks.yourdomain.com; location ~ ^/(\d+)/([a-z0-9]+) { proxy_set_header X-Gitlab-Event $http_X_Gitee_Event ; proxy_set_header X-Gitlab-Token $http_X_Gitee_Token ; proxy_pass https://hook.tapd.cn ; } } 将 tapdhooks.yourdomain.com 解析到该 Nginx 服务器 IP; 将替换过域名的 webhook 链接配置到 Gitee 项目里; 比如原 webhook 链接:https://hook.tapd.cn/32198210/adcc961bc533c74a257ef96295812fa7 将 https://hook.tapd.cn 替换成 http://tapdhook.yourdomain.com 得到新的链接 http://tapdhooks.yourdomain.com/32198210/adcc961bc533c74a257ef96295812fa7 搞定! 小插曲 事情就是这么简单,但往往实操的时候不会这么顺利,会有些小插曲,比如我就遇到了。 如上配置之后,我向 Gitee push 代码却发现并没有在 Tapd 看到源码关联,在 Gitee 配置 webhook 的地方 test 了一下,报 502 bad gateway。 把 test 请求在 postman 里构造出来,然后使用 hook.tapd.cn 的原链接,请求是成功的,加上 Nginx 新增的 Header,也没有问题,但换回自己域名的链接就报 502 了。在 Nginx 服务器上将错误日志打印出来: 2019/09/12 15:51:25 [crit] 24721#24721: *287854 SSL_do_handshake() failed (SSL: error:1411B041:SSL routines:SSL3_GET_NEW_SESSION_TICKET:malloc failure) while SSL handshaking to upstream, client: 28.39.21.123, server: tapdhooks.yourdomain.com, request: “POST /32198210/adcc961bc533c74a257ef96295812fa7 HTTP/1.1”, upstream: “https://119.29.122.86:443/32198210/adcc961bc533c74a257ef96295812fa7”, host: “tapdhooks.yourdomain.com” 所以是 Nginx 向 https://hook.tapd.cn 链接发起请求时,SSL 握手错误了。 在网上搜了一些网友们的帖子后,得出的结论基本是因为客户端与服务端支持的 SSL protocol 版本不一致导致的,用工具查了一下 Tapd 服务器支持的 protocol 版本是 TLSv2,而我 Nginx 服务器的 OpenSSL 版本较低,可能不支持这个,于是先是升级了服务器上的 OpenSSL 的版本,然后通过重新编译升级了 Nginx 的 OpenSSL 版本,之后问题解决。这两步自己维护 Ngninx 服务器的同学应该不在话下,在此不再赘述,以下是我参考的链接: 升级服务器 OpenSSL 版本: CentOS之——升级openssl为最新版 升级 Nginx 的 OpenSSL 版本:nginx旧版本openssl升级 参考 分享一个让源码关联支持Gogs/Gitee等平台的解决方案 Tapd Git Hooks nginx旧版本openssl升级 CentOS之——升级openssl为最新版

    2019/09/14 Tools

  4. 记一个折磨了我一天半的 Bug

    最近开始学习后台开发,虽然与我以前从事的 Android 开发一样都是使用 Java 语言,但是技术栈完全不同,有太多的必备的「新」概念要去学习,而在对它们,以及别人写的代码有充分的了解之前,就可能会遇上这种一杯茶,一根烟,一个 Bug 一天根本改不完的情况。 最近遇见的这个 Bug 是在修改项目遗留的问题时偶然发现的,简而言之就是这样: 服务 A 在从外界接收到推送的一条数据后,将数据插入到库里,然后通过 MQ 推送一条消息给 服务 B,服务 B 会根据收到的消息进行一些处理,其中包括远程调用 服务 A 的方法去查询这条数据,但是在测试环境总是报查询不到这条数据。 遇到问题之后,先进行了一些排查: 怀疑传参或者数据插库没有成功,于是将查询参数打印出来,手动复制参数到库里去查——有数据; 怀疑实际执行的 SQL 有问题,于是请同事帮忙配置 MyBatis 在日志里输出 SQL,原样复制出来去库里查——有数据; 在本地连接测试环境数据库,代码里下断点调试——能正常取到数据; 纳闷了一阵以后,继续排查: 怀疑测试环境程序数据库连接有问题,于是测试了一些其它查库的功能——数据正常; 怀疑测试环境的包有问题,于是请运维同事将 jar 包从容器里拷贝下来,核对配置——没问题; 怀疑测试环境远程调用失败了,于是在远程调用处加日志——没有异常; 怀疑测试环境注册了多余的 服务 A 的节点,于是去 Dubbo Admin 里核对节点——数量正常,网段正常; 怀疑测试环境的部署的 服务 A 的某个节点部署有问题,于是请运维同事一个一个 telnet 上去手动执行远程调用——能正常取到数据; 在一条失败 case 之后,马上向 服务 B 手动再次推送相同的消息——能取到数据; 直到我终于留意到一个现象:从日志来看,服务 A 插库与 服务 B 远程调用 服务 A 的方法的时间只相差 1 毫秒。会不会是一切发生得太快了,库里还查不到刚刚写入的数据?抑或者查询的时候插库还根本没有生效? 带着这个疑惑我终于认真去看插库并发消息那块的代码了,于是就看到这样一段代码: @Override @Transactional(...) public boolean doSomething() { ... // 插入数据 // 发送消息 ... } 是的没错,插入数据和发送消息写在了一个事务里面。虽然我对数据库了解不多,但对事务的特性还是有所了解——发送消息的时候,数据库里确实还没有刚刚插入的数据,事务提交后才会生效,也就是说,服务 B 收到消息后远程调用回 服务 A 想查找刚刚插入的数据,能否查到全凭运气,取决于此时事务已经执行完。 问题时序示意: 要确保消息发出时数据库里已经存在数据了也很简单,将事务粒度控制一下,只包含插入数据这块逻辑即可,插入成功了再发送消息。 PS:如果对消息投递可靠性要求高,可能需要对投递消息失败的情况做一些补偿机制。 @Override public boolean doSomething() { ... // 事务开始 // 插入数据 // 事务结束 if (插入数据成功) { // 发送消息 } ... } 正常时序示意: 总结: 在理解别人写的逻辑的时候不要做预设,你认为别人不可能犯如此低级的错误而直接排除在外的情况,可能恰好是问题所在; 在排查可能是时序导致的问题时,少用断点调试,用日志更合适; 本地调试时尽量将场景模拟完整一点,从中途某一环开始则有可能越过问题触发条件而无法复现。

    2019/05/25 Java

  5. 我的 2018 盘点

    又到了回顾以明得失,展望以知进退的时候。 首先想感慨一下我的拖延癌应该已经是确定到了晚期了,2018 年伊始的时候也不是没想过做一下 2017 年盘点,春节后第一次出国游走,回来之后也想着要写一个游记,而现在已经是 2019 年一月的下旬了,它俩还没有影子……咳咳嗯,忘记它们吧。 2018 年实际并没有列出一个明确的目标和计划,来说必须做成哪些事情,所以在回顾的时候,发现并没有参照和评价的标准,这其实是一件很恐怖的事情。目标是如此的重要,而我,可能过了一个没有明确目标的一年。 大事记 我们结婚了。 异地恋三年,然后在回武汉大半年后,终于携手小别同学一起走进婚姻的殿堂。回首这不算漫长的岁月,有甜蜜,有煎熬,有过许多争执,所幸最后都能求大同存小异。希望余生每一个或难忘或平淡的日子,都有你一起。 转型技术管理。 身处职业焦虑期的我,最近几年也在一直在思索转型之路,寻觅着转型之机。所以在有机会出现,又蒙新老领导举荐的情形下,我踏上了由程序员转向技术管理的征途,由一名自认合格的程序员摇身一变,成为了一名暂不合格的技术管理。而焦虑其实并不会消失,只是会由一种转为另一种,保持危机感,路漫漫其修远兮…… 分类记录 按例会罗列 2018 年的一些数字,及与以前的简单对比。 阅读和影音娱乐 数据主要来自豆瓣上的记录。 从豆瓣上统计了下最近四年标记的数据,对比一下发现 2017 年可能于我来说真的是艰难的一年——最近几年里标记非技术类书籍数量的低谷,所谓「闲书」,有闲情逸志了才会想起来去看的书,无关忙碌与否,更多反映的是心情与心态。 2018 年情况似乎有所好转,书籍数量回升,电影电视剧数量锐减,这倒不是因为我花在电影电视上的时间少了,跟着小别同志电视剧都看了几部了,应该只是有很多忘标记了…… 网络活动 GitHub 贡献日历: 过去一年总共有 345 次 Commit/Issue/PR 记录,比 2017 年的 815 次下降明显; 最长连击记录只有 8 天(2017 年是 28 天),2018-04-26 到 2018-05-03; 有 5 个自然周没有任何记录。 项目方面,awesome-adb 的 Star 数突破了 5K,vim-markdown-toc 的 Star 数超过 200。 另外 Follower 数超过了 500。 与之前几年相比,花在 GitHub 上的时间明显少了。这是一个值得警惕的现象——即使如今职业身份发生了变化,但技术仍是立身之本,不可轻易荒废。 技术社区与博客 与前面两年在掘金、知乎专栏的相对高产相比,这一年我在这方面比较沉默,看得少,输出也少,只有寥寥两篇。 2018 年在个人博客上只发表了 3 篇文章,没错……平均每个季度不足一篇。 期待后续能多做一些复盘,然后将有价值的经验与教训记录成文——不局限于技术,也包括管理经验等等。 运动 今年的运动量以羽毛球为主,辅以跑步和少量游泳。 羽毛球 作为公司武汉办公室羽毛球活动的主要发起者,2018 年共组织活动 30 次,另外参加民间小伙伴们组织的活动 15 次以上。技术上进步不大,最大的收获是结识了一帮可爱的小伙伴。 跑步 主要是在公司与几个小伙伴偶尔下班了会一起去跑,共计 28 次,127 公里。 今年收获的唯一一枚奖牌是光谷半程马拉松活动的健康跑完赛奖牌。 游泳 暑假期间去湖北经济学院游过几次,考了武汉的深水证。 主要受益于打羽毛球频度还可以,2018 年体重没有增长,希望自己能持续健康地打下去。 工作 2018 年换过两次部门,一次主动一次被动,工作岗位也由 Android 开发工程师转为了移动端技术经理。 角色的转变带来了许多新的挑战,以前管理自己得心应手的方式,放到团队和项目的维度可能就不一定好使了,如何打造好的流程与团队,这将是我接下来很长一段时间要持续学习的课题。 游戏 这一年不怎么玩游戏了。平时没啥感觉,就是去参加年会的时候发现一堆人围在一起打农药,另一堆人四处溜达或者大眼瞪小眼,有点逗。 旅行 2018-02 泰国(曼谷、普吉岛) 2018-10 河南(郑州、洛阳) 基本达成我和小别同学一起制定的每年一次国内,一次国外的小目标。 近几年悄然发生的一个变化是:以前每到一个地方,可能想着好不容易来一回,该看的该吃的都不想错过。如今我们已经学会享受假期,行程安排是怎么舒服怎么来,真正的放松心情,愉悦自己。 其它 另外值得一说的还有,今年从水兵同学手上收了一把木吉他,开始了自学之路。周末有空的时候会拿出来练练,练得不勤所以进展也不大,目前只学会了两首最简单的弹唱,但纯把它作为一个业余爱好来讲,我对此还算满意,表扬一下自己。 练琴的时候偶尔会想起,高中时期攒钱买了第一把吉他,只自学了一点点基础知识,后来大学时期送给了表弟;包子离开帝都的时候将他的琴送给了我,也没怎么练过,我离开帝都的时候又留给了另一位同学……不知道它们如今怎么样了哈哈。 结语 2018 年整体来说过得不坏,升职加薪了,业余也过得还算精彩。暴露的一个大问题就是没有制定很明确的目标和计划。 2019 年得认真做个年度目标和计划,并尽力去达成(此处可以考虑采用 OKR)。毕竟立 FLAG 是用来破的是一回事,立都不立又是另一回事了…… :-P 另外就是要多陪陪家人了,现在离家并不远,但回家的频次并不比在帝都时相距一千多公里时高,不要子欲养而亲不待时后悔。 好了最后给大家拜个晚年,祝大家新年快乐,笃定前行。

    2019/01/20 Blog

  6. 一份简明的 Markdown 笔记与教程

    为部门内知识分享准备的素材,记录了 Markdown 的优点、应用场景和编辑工具,介绍了标准语法与扩展语法,以及一些应用 Markdown 的奇技淫巧。个人使用 Markdown 的经验持续补充中,最新完整版请参见 https://github.com/mzlogin/markdown-intro 自从 2014 年左右接触到 Markdown 以来,对它的使用就一发而不可收拾。从最开始使用它在 GitHub Pages 里写博客,到用它编辑项目的 README 文件,再到撰写开发文档,编辑微信公众号文章和邮件内容等等,这期间也见证了它在各类平台和网站上的普及和被原生支持,可以说,Markdown 如今已经渗透了我在技术和网络活动的方方面面,成为了我撰写文本文档的首选。 那么首先我们一起来看一下它的「定义」: Markdown 是一种轻量级标记语言,创始人为 John Gruber。它允许人们「使用易读易写的纯文本格式编写文档,然后转换成有效的 XHTML(或者 HTML)文档」。——维基百科 本文档的目的不在于面面俱到地介绍 Markdown,只是作为我对其理解的笔记整理,希望能同时帮助一些对 Markdown 感兴趣的人快速上手,或是作为一个工具,供对其已经有所了解的人在需要时参考。 接下来请随我一起深入了解这门并不神秘的实用标记语言。 目录 背景 优点 使用场景 编辑工具 语法 标题 段落 行内格式 引用块 超链接 图片 列表 代码块 水平分割线 嵌入 HTML 扩展语法 表格 任务列表 删除线 自动链接 emoji 奇技淫巧 画流程图和时序图 插入数学公式 用 Markdown 做 PPT 用 Markdown 写微信公众号 更多 参考 背景 优点 专注于文字内容; 纯文本,易读易写,可以方便地纳入版本控制; 语法简单,没有什么学习成本,能轻松在码字的同时做出美观大方的排版。 使用场景 各类代码托管平台 主流的代码托管平台,如 GitHub、GitLab、BitBucket、Coding、Gitee 等等,都支持 Markdown 语法,很多开源项目的 README、开发文档、帮助文档、Wiki 等都用 Markdown 写作。 技术社区和写作平台 StackOverflow、CSDN、掘金、简书、GitBook、有道云笔记 论坛 V2EX、光谷社区 个人感觉比较遗憾的一点是各平台可能采用不同语言实现的 Markdown 解析引擎,或采用同一解析引擎的不同版本,而且可能有不同程度的定制与扩展,这导致在不同平台上使用 Markdown 写作时体验并不完全一致。不过幸好对于大家公认的一些标准语法,各家都是支持的。 编辑工具 理论上任何一款文本编辑器都能用于编辑 Markdown 文档,它们分别提供了不同程度的语法高亮、预览等功能,以下只是列举其中一部分,选择自己称手的即可。 现代编辑器 VSCode / Atom 传统编辑器 Vim / Emacs / Sublime Text / Notepad++ IDE 自带编辑器 IntelliJ IDEA / Android Studio / WebStorm 专用编辑器 Ulysses / Mou / Typora / Markpad 在线编辑器 各种支持 Markdown 的网站都提供了在线编辑器 语法 标题 Markdown: # atx-style 一级标题 ## 二级标题 ###### 六级标题 Setext-style 一级标题 === 二级标题 --- 预览效果: atx-style 一级标题 二级标题 六级标题 Setext-style 一级标题 二级标题 对应 HTML: <h1>atx-style 一级标题</h1> <h2>二级标题</h2> <h6>六级标题</h6> <h1>Setext-style 一级标题</h1> <h2>二级标题</h2> 段落 中间没有空行的连续不断的几行文字被视为一个段落。 Markdown: 白日依山尽, 黄河入海流。 (句号后面没空格) 欲穷千里目, 更上一层楼。 (句号后面有俩空格) 预览效果: 白日依山尽, 黄河入海流。 (句号后面没空格) 欲穷千里目, 更上一层楼。 (句号后面有俩空格) 对应 HTML: <p>白日依山尽,</p> <p>黄河入海流。 (句号后面没有空格)</p> <p>欲穷千里目,</p> <p> 更上一层楼。 <br> (句号后面有俩空格) </p> 行内格式 对段落或者部分文本的强调效果。 Markdown: 后面俩字**加黑** 后面俩字*斜体* 预览效果: 后面俩字加黑 后面俩字斜体 对应 HTML: <p> 后面俩字 <strong>加黑</strong> </p> <p> 后面俩字 <em>斜体</em> </p> 引用块 Markdown: > 引用块段落一。 > > 引用块段落二。 >> 内嵌引用块段落一。 > > ### 引用块内的标题 预览效果: 引用块段落一。 引用块段落二。 内嵌引用块段落一。 引用块内的标题 对应 HTML: <blockquote> <p>引用块段落一。</p> <p>引用块段落二。</p> <blockquote> <p>内嵌引用块段落一。</p> </blockquote> <h3 id="引用块内的标题">引用块内的标题</h3> </blockquote> 超链接 Markdown 支持行内式链接和引用式链接。 Markdown: 行内式 [博客](https://mazhuang.org "我的个人博客") 链接,带 title。 行内式 [GitHub](https://github.com/mzlogin) 链接。 引用式 [博客][1] 链接。 引用式 [GitHub][2] 链接,带 title。 [1]: https://mazhuang.org [2]: https://github.com/mzlogin "我的 GitHub 主页" 预览效果: 行内式 博客 链接,带 title。 行内式 GitHub 链接。 引用式 博客 链接。 引用式 GitHub 链接,带 title。 对应 HTML: <p>行内式 <a href="https://mazhuang.org" title="我的个人博客">博客</a> 链接,带 title。</p> <p>行内式 <a href="https://github.com/mzlogin">GitHub</a> 链接。</p> <p>引用式 <a href="https://mazhuang.org">博客</a> 链接。</p> <p>引用式 <a href="https://github.com/mzlogin" title="我的 GitHub 主页">GitHub</a> 链接,带 title。</p> 图片 在超链接的写法前加一个 !,就是引用图片的方法。 Markdown: ![Alt text](https://mazhuang.org/favicon.ico "favicon") 预览效果: 对应 HTML: <img src="https://mazhuang.org/favicon.ico" alt="Alt text" title="favicon"> 列表 包括有序列表和无序列表。 Markdown: - 苹果 - 葡萄 - 榴莲 1. 苹果 2. 葡萄 3. 榴莲 预览效果: 苹果 葡萄 榴莲 苹果 葡萄 榴莲 对应 HTML: <ul> <li>苹果</li> <li>葡萄</li> <li>榴莲</li> </ul> <ol> <li>苹果</li> <li>葡萄</li> <li>榴莲</li> </ol> 其中无序列表的标记可以使用 +、- 或 *,有序列表前的数字可以是乱序的。 代码块 支持行内代码和代码块。 Markdown: Android 里使用 `TextUtils` 类的 `isEmpty` 方法来判断字符串是否为空。 ```java if (TextUtils.isEmpty(text)) { return null; } ``` 预览效果: Android 里使用 TextUtils 类的 isEmpty 方法来判断字符串是否为空。 if (TextUtils.isEmpty(text)) { return null; } 对应 HTML: <p>Android 里使用 <code>TextUtils</code> 类的 <code>isEmpty</code> 方法来判断字符串是否为空。</p> <div class="highlight highlight-source-java"><pre><span class="pl-k">if</span> (<span class="pl-smi">TextUtils</span><span class="pl-k">.</span>isEmpty(text)) { <span class="pl-k">return</span> <span class="pl-c1">null</span>; }</pre></div> 上例中的语言标记 java 可选填,可用于在编辑器和渲染后的效果里添加语法高亮。 块式代码也可以对整个代码段缩进四个空格,或一个 Tab 来实现。 水平分割线 使用一个单独行里的三个或以上 *、- 来生产一条水平分割线,它们之间可以有空格。 Markdown: *** ----- - - - 预览效果: 对应 HTML: <hr /> <hr /> <hr /> 嵌入 HTML Markdown 标记语言的目的不是替代 HTML,也不是发明一种更便捷的插入 HTML 标签的方式。它对应的只是 HTML 标签的一个很小的子集。 对于那些没有办法用 Markdown 语法来对应的 HTML 标签,直接使用 HTML 来写就好了。 扩展语法 本节的内容是介绍一些受到广泛支持的 Markdown 扩展语法。 表格 Markdown: | 编号 | 姓名(左) | 年龄(右) | 性别(中) | | ----- | :-------- | ---------: | :------: | | 0 | 张三 | 28 | 男 | | 1 | 李四 | 29 | 男 | 预览效果: 编号 姓名(左) 年龄(右) 性别(中) 0 张三 28 男 1 李四 29 男 对应 HTML: <table> <thead> <tr> <th>编号</th> <th align="left">姓名(左)</th> <th align="right">年龄(右)</th> <th align="center">性别(中)</th> </tr> </thead> <tbody> <tr> <td>0</td> <td align="left">张三</td> <td align="right">28</td> <td align="center">男</td> </tr> <tr> <td>1</td> <td align="left">李四</td> <td align="right">29</td> <td align="center">男</td> </tr> </tbody> </table> 任务列表 在 GitHub / GitLab 里有较好的支持。 Markdown: - [x] 洗碗 - [ ] 清洗油烟机 - [ ] 拖地 预览效果: 洗碗 清洗油烟机 拖地 对应 HTML: <ul class="contains-task-list"> <li class="task-list-item"><input type="checkbox" id="" disabled="" class="task-list-item-checkbox" checked=""> 洗碗</li> <li class="task-list-item"><input type="checkbox" id="" disabled="" class="task-list-item-checkbox"> 清洗油烟机</li> <li class="task-list-item"><input type="checkbox" id="" disabled="" class="task-list-item-checkbox"> 拖地</li> </ul> 如果是在 GitHub / GitLab 的 Issue 里,会附赠任务完成比例提示效果: 还可以直接在网页上拖动调整顺序,勾选和取消勾选。 删除线 Markdown: 后面三个字打上~~删除线~~。 预览效果: 后面三个字打上删除线。 对应 HTML: <p>后面三个字打上<del>删除线</del>。</p> 自动链接 自动链接扩展,即:当识别到 URL,或用 <、> 包括的 URL 时,会自动为其生成 a 标签。 Markdown: https://github.com <example@gmail.com> 预览效果: https://github.com example@gmail.com 对应 HTML: <p><a href="https://github.com">https://github.com</a></p> <p><a href="mailto:example@gmail.com">example@gmail.com</a></p> emoji 以 GitHub Pages 为例。 Markdown: :camel: :blush: :smile: 预览效果: :camel: :blush: :smile: 对应 HTML: <p> <img class="emoji" title=":camel:" alt=":camel:" src="https://assets-cdn.github.com/images/icons/emoji/unicode/1f42b.png" height="20" width="20"> <img class="emoji" title=":blush:" alt=":blush:" src="https://assets-cdn.github.com/images/icons/emoji/unicode/1f60a.png" height="20" width="20"> <img class="emoji" title=":smile:" alt=":smile:" src="https://assets-cdn.github.com/images/icons/emoji/unicode/1f604.png" height="20" width="20"> </p> 奇技淫巧 脑洞清奇的工程师们还发掘了很多使用 Markdown 的方法,大部分都是引入第三方 JavaScript 插件来实现。对这部分我只做简述,对其中的部分功能比如作图等,还是推荐用专门的可视化工具去做。 画流程图和时序图 有部分网站和编辑器实现了对 Markdown 里流程图和时序图的支持,比如我们使用的项目管理工具 TAPD 的在线编辑器,还有 VSCode + 插件 Markdown Preview Enhanced 等。 以我们使用的项目管理工具 TAPD 的在线编辑器为例: 插入数学公式 仍然以 TAPD 为例: 应该是利用 JavaScript 支持了 LaTeX 公式语法。 用 Markdown 做 PPT 有专门的工具 Marp,另外使用 VSCode + 插件 Markdown Preview Enhanced 也可以实现。 用 Markdown 写微信公众号 可以将公众号素材用 Markdown 编辑好后,贴到在线排版工具以后,复制到公众号编辑器里即可。有多种页面主题和代码主题可选择。 我维护的工具地址:https://md.mazhuang.org 更多 想象力丰富的工程师们还扩展了很多基于 Markdown 的玩法,包括但不限于: 自动生成 / 更新 Table of Contents 流程图 / 时序图 制作幻灯片 集成 PlantUML / GraphViz 的能力 导出 HTML / PDF / 电子书 … 以上功能基本都可以用 VSCode + 插件 Markdown Preview Enhanced 实现。 另外可以参考我以前的一篇博客 关于 Markdown 的一些奇技淫巧。 参考 Markdown: Syntax - DARING FIREBALL Markdown - 维基百科 GitHub Flavored Markdown Spec 关于 Markdown 的一些奇技淫巧 欢迎关注我的微信公众号,接收 markdown-intro 最新动态。

    2018/09/06 Markdown

  7. Android 源码分析 —— Handler、Looper 和 MessageQueue

    本系列文章在 https://github.com/mzlogin/rtfsc-android 持(jing)续(chang)更(duan)新(geng)中,欢迎有兴趣的童鞋们关注。 书接上文,在分析 Toast 源码的过程中我们涉及到了 Handler,这个在 Android 开发里经常用到的类——线程切换、顺序执行、延时执行等等逻辑里往往少不了它的身影,跟它一起搭配使用的通常是 Runnable 和 Message,还有它身后的好基友 Looper 与 MessageQueue。Runnable 相信大家都很熟悉了,本文的主角就是标题里的三剑客——Handler、Looper 和 MessageQueue,当然少不了说到 Message。 本文使用的工具与源码为:Chrome、插件 insight.io、GitHub 项目 aosp-mirror/platform_frameworks_base 目录 初步印象 Handler Looper MessageQueue Message 提出问题 解答问题 Thread 与 Looper Looper 与 MessageQueue Handler 与 Looper 消息如何分发到对应的 Handler Handler 能用于线程切换的原理 Runnable 与 MessageQueue 能否创建关联到其它线程的 Handler 消息可以插队吗 消息可以撤回吗 找到主线程消息循环源码 总结 结论汇总 遗留知识点 本篇用到的源码分析方法 后话 初步印象 按惯例,第一步还是从 Android 的官方 API 文档里来建立对这几个类的初步印象,文档开头的说明里往往有一些比较关键的知识点。 官方文档链接: Handler Looper MessageQueue Message 这几个类开头的说明本身也不长,为了避免断章取义误人子弟,就将其直译版完整地放在下面,当然更推荐的方式是自己去看原文。 Handler 可以用 Handler 发送和处理与某线程的 MessageQueue 相关联的 Message/Runnable 对象。每个 Handler 实例只能与一个线程和它的消息队列相关联。当创建一个 Handler 时,它会绑定到当前线程和消息队列——从那时起,它将 Message 和 Runnable 传递给绑定的消息队列,并在它们从队列里被取出时执行对应逻辑。(译注:此处描述不准确,创建 Handler 时并不一定是绑定到当前线程。) Handler 主要有两个用途: 在未来某个时间点处理 Messages 或者执行 Runnables; 将一段逻辑切换到另一个线程执行。 可以使用 Handler 的以下方法来调度 Messages 和 Runnables: post(Runnable) postAtTime(Runnable, long) postDelayed(Runnable, Object, long) sendEmptyMessage(int) sendMessage(Message) sendMessageAtTime(Message, long) sendMessageDelayed(Message, long) 其中 postXXX 系列用于将 Runnable 对象加入队列,sendXXX 系列用于将 Message 对象加入队列,Message 对象通常会携带一些数据,可以在 Handler 的 handlerMessage(Message) 方法中处理(需要实现一个 Handler 子类)。 在调用 Handler 的 postXXX 和 sendXXX 时,可以指定当队列准备好时立即处理它们,也可以指定延时一段时间后处理,或某个绝对时间点处理。后面这两种能实现超时、延时、周期循环及其它基于时间的行为。 为应用程序创建一个进程时,其主线程专用于运行消息队列,该消息队列负责管理顶层应用程序对象(activities,broadcast receivers 等)以及它们创建的窗口。我们可以创建自己的线程,然后通过 Handler 与主线程进行通信,方法是从新线程调用我们前面讲到的 postXXX 或 sendXXX 方法,传递的 Runnable 或 Message 将被加入 Handler 关联的消息队列中,并适时进行处理。 Looper 用于为线程执行消息循环的类。线程默认没有关联的消息循环,如果要创建一个,可以在执行消息循环的线程里面调用 prepare() 方法,然后调用 loop() 处理消息,直到循环停止。 大多数与消息循环的交互都是通过 Handler 类。 下面是实现一个 Looper 线程的典型例子,在 prepare() 和 loop() 之间初始化 Handler 实例,用于与 Looper 通信: class LooperThread extends Thread { public Handler mHandler; public void run() { Looper.prepare(); mHandler = new Handler() { public void handleMessage(Message msg) { // 在这里处理传入的消息 } }; Looper.loop(); } } MessageQueue 持有将被 Looper 分发的消息列表的底层类。消息都是通过与 Looper 关联的 Handler 添加到 MessageQueue,而不是直接操作 MessageQueue。 可以用 Looper.myQueue() 获取当前线程的 MessageQueue 实例。 Message 定义一个可以发送给 Handler 的消息,包含描述和任意数据对象。消息对象有两个额外的 int 字段和一个 object 字段,这可以满足大部分场景的需求了。 虽然 Message 的构造方法是 public 的,但最推荐的得到一个消息对象的方式是调用 Message.obtain() 或者 Handler.obtainMessage() 系列方法,这些方法会从一个对象回收池里捡回能复用的对象。 提出问题 根据以上印象,及以前的使用经验,提出以下问题来继续本次源码分析之旅: Thread 与 Looper,Looper 与 MessageQueue,Handler 与 Looper 之间的数量对应关系是怎样的? 如果 Looper 能对应多个 Handler,那通过不同的 Handler 发送的 Message,那处理的时候代码是如何知道该分发到哪一个 Handler 的 handlerMessage 方法的? Handler 能用于线程切换的原理是什么? Runnable 对象也是被添加到 MessageQueue 里吗? 可以在 A 线程创建 Handler 关联到 B 线程及其消息循环吗? 如何退出消息循环? 消息可以插队吗? 消息可以撤回吗? 上文提到,应用程序的主线程是运行一个消息循环,在代码里是如何反映的? 解答问题 Thread 与 Looper 前文有提到,线程默认是没有消息循环的,需要调用 Looper.prepare() 来达到目的,那么我们对这个问题的探索就从 Looper.prepare() 开始。 /** Initialize the current thread as a looper. * This gives you a chance to create handlers that then reference * this looper, before actually starting the loop. Be sure to call * {@link #loop()} after calling this method, and end it by calling * {@link #quit()}. */ public static void prepare() { prepare(true); } private static void prepare(boolean quitAllowed) { if (sThreadLocal.get() != null) { throw new RuntimeException("Only one Looper may be created per thread"); } sThreadLocal.set(new Looper(quitAllowed)); } 在有参数版本的 prepare 方法里,我们可以得到两个信息: 一个线程里调用多次 Looper.prepare() 会抛出异常,提示 Only one Looper may be created per thread,即 一个线程只能创建一个 Looper prepare 里主要干的事就是 sThreadLocal.set(new Looper(quitAllowed)) 源码里是怎么限制一个线程只能创建一个 Looper 的呢?调用多次 Looper.prepare() 并不会关联多个 Looper,还会抛出异常,那能不能直接 new 一个 Looper 关联上呢?答案是不可以,Looper 的构造方法是 private 的。 private Looper(boolean quitAllowed) { mQueue = new MessageQueue(quitAllowed); mThread = Thread.currentThread(); } 在概览整个 Looper 的所有公开方法后,发现只有 prepare 和 prepareMainLooper 是做线程与 Looper 关联的工作的,而 prepareMainLooper 是 Android 环境调用的,不是用来给应用主动调用的。所以从 Looper 源码里掌握的信息来看,想给一个线程关联多个 Looper 的路不通。 另外我们从源码里能观察到,Looper 有一个 final 的 mThread 成员,在构造 Looper 对象的时候赋值为 Thread.currentThread(),源码里再无可以修改 mThread 值的地方,所以可知 Looper 只能关联到一个线程,且关联之后不能改变。 说了这么多,还记得 Looper.prepare() 里干的主要事情是 sThreadLocal.set(new Looper(quitAllowed)) 吗?与之对应的,获取本线程关联的 Looper 对象是使用静态方法 Looper.myLooper(): // sThreadLocal.get() will return null unless you've called prepare(). static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<Looper>(); // ... private static void prepare(boolean quitAllowed) { if (sThreadLocal.get() != null) { throw new RuntimeException("Only one Looper may be created per thread"); } sThreadLocal.set(new Looper(quitAllowed)); } // ... /** * Return the Looper object associated with the current thread. Returns * null if the calling thread is not associated with a Looper. */ public static @Nullable Looper myLooper() { return sThreadLocal.get(); } 使用了 ThreadLocal 来确保不同的线程调用静态方法 Looper.myLooper() 获取到的是与各自线程关联的 Looper 对象。关于 ThreadLocal,又可以另开一个小话题了。 小结: Thread 若与 Looper 关联,将会是一一对应的关系,且关联后关系无法改变。 Looper 与 MessageQueue 直接来看源码: public final class Looper { // ... final MessageQueue mQueue; // ... private Looper(boolean quitAllowed) { mQueue = new MessageQueue(quitAllowed); // ... } } Looper 对象里有一个 MessageQueue 类型成员,在构造的时候 new 出的,并且它是一个 final,没有地方能修改它的指向。 小结: Looper 与 MessageQueue 是一一对应的关系。 Handler 与 Looper 在前面略读 Looper 源码的过程中,我发现 Handler 基本没有出场,那么现在,从构造 Handler 的方法开始分析。 Handler 的构造方法有 7 个之多,不过有 3 个标记为 @hide,所以我们可以直接调用的有 4 个,这 4 个最终调用都到了其它的两个构造方法,捡出来我们要看的重点: public class Handler { // ... /** * ... * @hide */ public Handler(Callback callback, boolean async) { // ... mLooper = Looper.myLooper(); if (mLooper == null) { throw new RuntimeException( "Can't create handler inside thread that has not called Looper.prepare()"); } mQueue = mLooper.mQueue; // ... } /** * ... * @hide */ public Handler(Looper looper, Callback callback, boolean async) { mLooper = looper; mQueue = mLooper.mQueue; // ... } // ... final Looper mLooper; final MessageQueue mQueue; // ... } Handler 对象里有 final Looper 成员,所以一个 Handler 只会对应一个固定的 Looper 对象。构造 Handler 对象的时候如果不传 Looper 参数,会默认使用当前线程关联的 Looper,如果当前线程没有关联 Looper,会抛出异常。 那么能不能绑定多个 Handler 到同一个 Looper 呢?答案是可以的。在源码里并没有找到相关的限制说明,所以这种适合用个小 Demo 来验证,例如以下例子,就绑定了两个 Handler 到主线程的 Looper 上,并都能正常使用(日志 receive msg: 1 和 receive msg: 2 能依次输出)。 public class MainActivity extends AppCompatActivity { private static final String TAG = MainActivity.class.getSimpleName(); private Handler mHandler1; private Handler mHandler2; private Handler.Callback mCallback = new Handler.Callback() { @Override public boolean handleMessage(Message msg) { Log.v(TAG, "receive msg: " + msg.what); return false; } }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mHandler1 = new Handler(mCallback); mHandler2 = new Handler(mCallback); mHandler1.sendEmptyMessage(1); mHandler2.sendEmptyMessage(2); } } 小结: Handler 与 Looper 是多对一的关系,创建 Handler 实例时要么提供一个 Looper 实例,要么当前线程有关联的 Looper。 消息如何分发到对应的 Handler 因为消息的分发在是 Looper.loop() 这个过程中,所以我们先来看这个方法: public static void loop() { // ... for (;;) { Message msg = queue.next(); // might block // ... try { msg.target.dispatchMessage(msg); // ... } finally { // ... } // ... } } 这个方法里做的主要工作是从 MessageQueue 里依次取出 Message,然后调用 Message.target.dispatchMessage 方法,Message 对象的这个 target 成员是什么东东呢?它是一个 Handler,它最终会被设置成 sendMessage 的 Handler: public class Handler { // 其它 Handler.sendXXX 方法最终都会调用到这个方法 public boolean sendMessageAtTime(Message msg, long uptimeMillis) { // ... return enqueueMessage(queue, msg, uptimeMillis); } // ... private boolean enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis) { msg.target = this; // 就是这里了 // ... } // ... } 所以是用哪个 Handler.sendMessage,最终就会调用到它的 dispatchMessage 方法: private static void handleCallback(Message message) { message.callback.run(); } // ... /** * Handle system messages here. */ public void dispatchMessage(Message msg) { if (msg.callback != null) { handleCallback(msg); } else { if (mCallback != null) { if (mCallback.handleMessage(msg)) { return; } } handleMessage(msg); } } 消息分发到这个方法以后,执行优先级分别是 Message.callback、Handler.mCallback,最后才是 Handler.handleMesage 方法。 小结: 在 Handler.sendMessage 时,会将 Message.target 设置为该 Handler 对象,这样从消息队列取出 Message 后,就能调用到该 Handler 的 dispatchMessage 方法来进行处理。 Handler 能用于线程切换的原理 实际上一小节的结论已经近乎揭示了其中的原理,进一步解释一下就是: 小结: Handler 会对应一个 Looper 和 MessageQueue,而 Looper 与线程又一一对应,所以通过 Handler.sendXXX 和 Hanler.postXXX 添加到 MessageQueue 的 Message,会在这个对应的线程的 Looper.loop() 里取出来,并就地执行 Handler.dispatchMessage,这就可以完成线程切换了。 Runnable 与 MessageQueue Handler 的 postXXX 系列方法用于调度 Runnable 对象,那它最后也是和 Message 一样被加到 MessageQueue 的吗?可是 MessageQueue 是用一个元素类型为 Message 的链表来维护消息队列的,类型不匹配。 在 Handler 源码里能找到答案,这里就以 Handler.post(Runnable) 方法为例,其它几个 postXXX 方法情形与此类似。 /** * Causes the Runnable r to be added to the message queue. * The runnable will be run on the thread to which this handler is * attached. * * @param r The Runnable that will be executed. * * @return Returns true if the Runnable was successfully placed in to the * message queue. Returns false on failure, usually because the * looper processing the message queue is exiting. */ public final boolean post(Runnable r) { return sendMessageDelayed(getPostMessage(r), 0); } // ... private static Message getPostMessage(Runnable r) { Message m = Message.obtain(); m.callback = r; return m; } 可以看到,post 系列方法最终也是调用的 send 系列方法,Runnable 对象是被封装成 Message 对象后加入到消息队列的,Message.callback 被设置为 Runnable 本身,还记得前文 Handler.dispatchMessage 的执行顺序吗?如果 Message.callback 不为空,则执行 Message.callback.run() 后就返回。 小结: Runnable 被封装成 Message 之后添加到 MessageQueue。 能否创建关联到其它线程的 Handler 创建 Handler 时会关联到一个 Looper,而 Looper 是与线程一一绑定的,所以理论上讲,如果能得到要关联的线程的 Looper 实例,这是可以实现的。 在阅读 Looper 源码的过程中,我们有留意到(好吧,其实应该是平时写代码时有用到): public final class Looper { // ... private static Looper sMainLooper; // guarded by Looper.class // ... /** * Returns the application's main looper, which lives in the main thread of the application. */ public static Looper getMainLooper() { synchronized (Looper.class) { return sMainLooper; } } } 可见获取主线程的 Looper 是能实现的,平时写代码过程中,如果要从子线程向主线程添加一段执行逻辑,也经常这么干,这是可行的: // 从子线程创建关联到主线程 Looper 的 Handler Handler mHandler = new Handler(Looper.getMainLooper()); mHandler.post(() -> { // ... }); 从子线程创建关联到其它子线程的 Looper 是否可行呢?这个用 Demo 来验证: new Thread() { @Override public void run() { setName("thread-one"); Looper.prepare(); final Looper threadOneLooper = Looper.myLooper(); new Thread() { @Override public void run() { setName("thread-two"); Handler handler = new Handler(threadOneLooper); handler.post(() -> { Log.v("test", Thread.currentThread().getName()); }); } }.start(); Looper.loop(); } }.start(); 执行后日志输出为 thread-one。 小结: 可以从一个线程创建关联到另一个线程 Looper 的 Handler,只要能拿到对应线程的 Looper 实例。 消息可以插队吗 这个问题从API 文档、Handler 源码里都可以找到答案,答案是可以的,使用 Handler.sendMessageAtFrontOfQueue 和 Handler.postAtFrontOfQueue 这两个方法,它们会分别将 Message 和 Runnable(封装后)插入到消息队列的队首。 我目前尚未遇到过这种使用场景。 小结: 消息可以插队,使用 Handler.xxxAtFrontOfQueue 方法。 消息可以撤回吗 同上,可以从 Handler 的 API 文档中找到答案。 可以用 Handler.hasXXX 系列方法判断关联的消息队列里是否有等待中的符合条件的 Message 和 Runnable,用 Handler.removeXXX 系列方法从消息队列里移除等待中的符合条件的 Message 和 Runnable。 小结: 尚未分发的消息是可以撤回的,处理过的就没法了。 找到主线程消息循环源码 我们前面提到过一个小细节,就是 Looper.prepareMainLooper 是 Android 环境调用的,而从该方法的注释可知,调用它就是为了初始化主线程 Looper,所以我们要找到主线程消息循环这部分源码,搜索 prepareMainLooper 被哪些地方引用即可。 使用 insight.io 插件的功能,在 Looper.prepareMainLooper 上点一下即可看到引用处列表,一共两处: 从文件路径和文件名上猜测应该是第一处。 public final class ActivityThread { public static void main(String[] args) { // ... Looper.prepareMainLooper(); // ... Looper.loop(); // ... } } 就是我想象中的模样。这里只是简单找到这个位置,继续深入探索的话可以开启一个新的话题了,后续的篇章里再解决。 总结 结论汇总 Thread 若与 Looper 关联,将会是一一对应的关系,且关联后关系无法改变。 Looper 与 MessageQueue 是一一对应的关系。 Handler 与 Looper 是多对一的关系,创建 Handler 实例时要么提供一个 Looper 实例,要么当前线程有关联的 Looper。 在 Handler.sendMessage 时,会将 Message.target 设置为该 Handler 对象,这样从消息队列取出 Message 后,就能调用到该 Handler 的 dispatchMessage 方法来进行处理。 Handler 会对应一个 Looper 和 MessageQueue,而 Looper 与线程又一一对应,所以通过 Handler.sendXXX 和 Hanler.postXXX 添加到 MessageQueue 的 Message,会在这个对应的线程的 Looper.loop() 里取出来,并就地执行 Handler.dispatchMessage,这就可以完成线程切换了。 Runnable 被封装成 Message 之后添加到 MessageQueue。 可以从一个线程创建关联到另一个线程 Looper 的 Handler,只要能拿到对应线程的 Looper 实例。 消息可以插队,使用 Handler.xxxAtFrontOfQueue 方法。 尚未分发的消息是可以撤回的,处理过的就没法了。 遗留知识点 ThreadLocal 应用的启动流程 本篇用到的源码分析方法 文档优先 后话 关于 Handler、Looper 和 MessageQueue 的分析在此先告一段落,这部分的内容比较容易分析,但里面细节挺多的,写得有点杂且不全,有点只见树木不见森林的感觉,想要配合画一些图,但找不到合适的画图形式。对此类主题的解析方式必须要再探索优化一下,大家有好的建议请一定告知。 最后,照例要安利一下我的微信公众号「闷骚的程序员」,扫码关注,接收 rtfsc-android 的最近更新。

    2018/06/11 Android

  8. 光谷社区第三方 Android 客户端 v2.0 发布

    我最完整的业余作品 光谷社区第三方 Android 客户端 (虽然也还不是很完善)今天发布了重大更新,版本号由 v1.3 更新到 v2.0,欢迎所有身在武汉和心系武汉的朋友们试用体验,也欢迎各路开发者大神们对照源码(后文有链接)交流指点。 本次更新主要是界面和交互风格的全面改版,以及少量的功能更新,将这描述为「重大更新」并非夸张,主要是开发模式相比之前有所变化。 以前基本是自己一个人在战斗,功能自己想着往上加,界面和交互自己想着做,程序员自己设计的东西,大家都懂的——能用但是不好用。 现在就好多了,社区里热心的 UI 设计师小哥 colinlee 为应用设计了界面,我们经过讨论之后确定交互体验,开发完之后再一起视觉走查、完善,妈妈再也不用担心应用界面土得掉渣了,同时也更让我觉得光谷社区是一个有温度有活力的社区,可以找到志趣相投的小伙伴们一起做点有意义的事情。 好了言归正传,先来看一下新版界面: 首页 帖子详情 节点列表 抽屉 挺赞的吧~然后是少得可怜的功能更新: 新增关注/取消关注用户的功能; 对评论点赞的功能。 其实好多东西都隐含在界面和交互的更新里了,需要慢慢体会……:laughing: 最后奉上下载二维码: 在微信里识别二维码后需要点击右上角从浏览器打开链接,然后下载 APK 安装。 下载链接:https://www.coolapk.com/apk/164523 源码地址:https://github.com/mzlogin/guanggoo-android 预计在不久的将来还会再更新几个版本,将一些未经设计的界面进行新界面补全,将消息提醒、今日热议、发贴回贴时上传图片、屏蔽用户等功能逐渐加上去。大家有什么好建议,或者 bug 反馈,可以通过「关于我们」界面里的发邮件吐槽功能让我知道,或者去光谷社区发贴艾特我也行。也欢迎更多开发者朋友一起加入合作,磨炼磨炼技术也好,结识更多朋友也罢,也许人生的新篇章就在不经意间打开了。

    2018/04/30 Android

  9. 解决两个 Android 模拟器之间无法网络通信的问题

    本文解决的是一个小众场景的问题: 出差在外,需要调试局域网内的两台 Android 设备之间通过 TCP 通信的情况,可手边又不是随时有多台可用的设备,于是想在笔记本上同时跑两台 Android 模拟器来构造调试环境,但是发现它俩的 IP 地址竟然都是 10.0.2.15,场面一度十分尴尬…… 谷狗之后,众多相关的博客和问答贴将我引向了官方文档页面: Interconnecting emulator instances 原来官方指南上解释过相关的知识,现将我关心和以前迷惑的部分翻译摘录如下,如果希望对此有个更全面的了解,还是推荐完整阅读 Android 官方文档里有关 Emulator 的章节 https://developer.android.com/studio/run/emulator.html 首先讲一点预备知识,再说解决方案。 模拟器的网络地址空间 每个模拟器都运行在一个虚拟路由/防火墙服务后面,这个服务将模拟器和宿主机器的网络接口、配置以及 Internet 隔离开来。对模拟器而言,宿主机器和其它模拟器对它是不可见的,它只知道自己是通过以太网连接到路由/防火墙。 每个模拟器的虚拟路由管理 10.0.2/24 的网络地址空间,所有地址都是 10.0.2.xx 格式。地址预分配的情况如下: 网络地址 描述 10.0.2.1 路由/网络地址 10.0.2.2 宿主机器的 loopback interface,相当于电脑上的 127.0.0.1 10.0.2.3 首选 DNS Server 10.0.2.4 10.0.2.5 10.0.2.6 可选的第二、第三、第四 DNS Server 10.0.2.15 模拟器的网络地址 127.0.0.1 模拟器的 loopback interface 需要注意的是所有模拟器的网络地址分配都是一样的,这样一来,如果有两个模拟器同时运行在一台电脑上,它们都会有各自的路由,并且给两个模拟器分配的 IP 都是 10.0.2.15。它们被路由隔离,相互不可见。 另外一点就是模拟器上的 127.0.0.1 是指它自己,所以如果想访问宿主机器上运行的服务,要使用 10.0.2.2。 实现两台模拟器之间的通信 现在来解决标题和文首提到的问题,主要用到了网络重定向。 假设开发环境是: PC 是指运行模拟器的宿主电脑 emulator-5554 是模拟器 1,将在 TCP 通信中作为 server 端 emulator-5556 是模拟器 2,将在 TCP 通信中作为 client 端 配置步骤: 在 emulator-5554 上运行 server,侦听 10.0.2.15:58080 在 PC 上运行 cat ~/.emulator_console_auth_token,得到一个 token 在 PC 上运行 telnet localhost 5554 auth <token> redir add tcp:51212:58080 <token> 是指第 2 步中得到的 token。 51212 是 PC 端口,58080 是 5554 模拟器的端口。 在 emulator-5556 上运行 client 程序,连接 10.0.2.2:51212 至此,两台模拟器之间已经可以通过 TCP 愉快地通信了。 它们之间的网络连接和通信示意图如下: 注: 以上步骤中用到的端口号都是可以根据你的需求替换的 Windows 下 telnet 命令默认没有启用,具体启用方法请搜狗一下 模拟器的网络限制 模拟器上运行的 Apps 可以连接到宿主电脑上的网络,但这是通过模拟器间接实现,不是直接连到宿主电脑的网卡。模拟器可以看作是宿主电脑上运行的一个普通程序。 因为模拟器的特殊网络配置,可能无法支持一些网络协议,比如 ping 命令使用的 ICMP 协议。目前,模拟器不支持 IGMP 和 multicast。 试验了一下,模拟器的 shell 里 ping www.sogou.com 一直卡在那,在手机的 shell 里就可以。 额外的发现 在阅读 Android 官方文档里关于模拟器的章节时,意外地发现有一节 Sending a voice call or SMS to another emulator instance 就是说模拟器可以给另外的模拟器打电话和发短信,电话号码就是端口号,比如 emulator-5554 模拟器,电话号码就是 5554,这个号码也可以从模拟器的窗口标题栏上找到,比如 Android Emulator - Nexus_5X_API_19:5554,里面那个 5554 就是。 后话 天下博文,大部分都逃不出官方文档与公开源码的范畴(比如本文就是),而且都是选定文档里讲的某一小部分来进行讲解演绎,这在作为扩展视野、快速上手、快速解决问题等用途时还是比较实用的,但如果想系统、全面地学习,官方文档一般是更好的选择。

    2017/12/03 Android

  10. Android 源码分析 —— 从 Toast 出发

    本系列文章在 https://github.com/mzlogin/rtfsc-android 持续更新中,欢迎有兴趣的童鞋们关注。 (图 from Android Developers) Toast 是 Android 开发里较常用的一个类了,有时候用它给用户弹提示信息和界面反馈,有时候用它来作为辅助调试的手段。用得多了,自然想对其表层之下的运行机制有所了解,所以在此将它选为我的第一个 RTFSC Roots。 本篇采用的记录方式是先对它有个整体的了解,然后提出一些问题,再通过阅读源码,对问题进行一一解读而后得出答案。 本文使用的工具与源码为:Chrome、插件 insight.io、GitHub 项目 aosp-mirror/platform_frameworks_base 目录 Toast 印象 提出问题 解答问题 Toast 的超时时间 能不能弹一个时间超长的 Toast? Toast 能不能在非 UI 线程调用? 应用在后台时能不能 Toast? Toast 数量有没有限制? Toast.makeText(…).show() 具体都做了些什么? 总结 补充后的 Toast 知识点列表 遗留知识点 本篇用到的源码分析方法 后话 Toast 印象 首先我们从 Toast 类的 官方文档 和 API 指南 中可以得出它具备如下特性: Toast 不是 View,它用于帮助创建并展示包含一条小消息的 View; 它的设计理念是尽量不惹眼,但又能展示想让用户看到的信息; 被展示时,浮在应用界面之上; 永远不会获取到焦点; 大小取决于消息的长度; 超时后会自动消失; 可以自定义显示在屏幕上的位置(默认左右居中显示在靠近屏幕底部的位置); 可以使用自定义布局,也只有在自定义布局的时候才需要直接调用 Toast 的构造方法,其它时候都是使用 makeText 方法来创建 Toast; Toast 弹出后当前 Activity 会保持可见性和可交互性; 使用 cancel 方法可以立即将已显示的 Toast 关闭,让未显示的 Toast 不再显示; Toast 也算是一个「通知」,如果弹出状态消息后期望得到用户响应,应该使用 Notification。 不知道你看到这个列表,是否学到了新知识或者明确了以前不确定的东西,反正我在整理列表的时候是有的。 提出问题 根据以上特性,再结合平时对 Toast 的使用,提出如下问题来继续本次源码分析之旅(大致由易到难排列,后文用 小 demo 或者源码分析来解答): Toast 的超时时间具体是多少? 能不能弹一个时间超长的 Toast? Toast 能不能在非 UI 线程调用? 应用在后台时能不能 Toast? Toast 数量有没有限制? Toast.makeText(…).show() 具体都做了些什么? 解答问题 Toast 的超时时间 用这样的一个问题开始「Android 源码分析」,真的好怕被打死……大部分人都会嗤之以鼻:Are you kidding me? So easy. 各位大佬们稍安勿躁,阅读大型源码不是个容易的活,让我们从最简单的开始,一点一点建立自信,将这项伟大的事业进行下去。 面对这个问题,我的第一反应是去查 Toast.LENGTH_LONG 和 Toast.LENGTH_SHORT 的值,毕竟平时都是用这两个值来控制显示长/短 Toast 的。 文件 platform_frameworks_base/core/java/android/widget/Toast.java 中能看到它们俩的定义是这样的: /** * Show the view or text notification for a short period of time. This time * could be user-definable. This is the default. * @see #setDuration */ public static final int LENGTH_SHORT = 0; /** * Show the view or text notification for a long period of time. This time * could be user-definable. * @see #setDuration */ public static final int LENGTH_LONG = 1; 啊哦~原来它们只是两个 flag,并非确切的时间值。 既然是 flag,那自然就会有根据不同的 flag 来设置不同的具体值的地方,于是使用 insight.io 点击 LENGTH_SHORT 的定义搜索一波 Toast.LENGTH_SHORT 的引用,在 aosp-mirror/platform_frameworks_base 里一共有 50 处引用,但都是调用 Toast.makeText(...) 时出现的。 继续搜索 Toast.LENGTH_LONG 的引用,在 aosp-mirror/platform_frameworks_base 中共出现 42 次,其中有两处长得像是我们想找的: 第一处,文件 platform_frameworks_base/core/java/android/widget/Toast.java private static class TN extends ITransientNotification.Stub { ... static final long SHORT_DURATION_TIMEOUT = 4000; static final long LONG_DURATION_TIMEOUT = 7000; ... public void handleShow(IBinder windowToken) { ... mParams.hideTimeoutMilliseconds = mDuration == Toast.LENGTH_LONG ? LONG_DURATION_TIMEOUT : SHORT_DURATION_TIMEOUT; ... } ... } 这个 hideTimeoutMilliseconds 是干嘛的呢? 文件 platform_frameworks_base/core/java/android/view/WindowManager.java 里能看到这个 /** * ... * ... . Therefore, we do hide * such windows to prevent them from overlaying other apps. * * @hide */ public long hideTimeoutMilliseconds = -1; 在 GitHub 用 blame 查看到改动这一行的最近一次提交 aa07653d,它的 commit message 能表明它的用途: Prevent apps to overlay other apps via toast windows It was possible for apps to put toast type windows that overlay other apps which toast winodws aren't removed after a timeout. Now for apps targeting SDK greater than N MR1 to add a toast window one needs to have a special token. The token is added by the notificatoion manager service only for the lifetime of the shown toast and is then removed including all windows associated with this token. This prevents apps to add arbitrary toast windows. Since legacy apps may rely on the ability to directly add toasts we mitigate by allowing these apps to still add such windows for unlimited duration if this app is the currently focused one, i.e. the user interacts with it then it can overlay itself, otherwise we make sure these toast windows are removed after a timeout like a toast would be. We don't allow more that one toast window per UID being added at a time which prevents 1) legacy apps to put the same toast after a timeout to go around our new policy of hiding toasts after a while; 2) modern apps to reuse the passed token to add more than one window; Note that the notification manager shows toasts one at a time. 它并不是用来控制 Toast 的显示时间的,只是为了防止有些应用的 toast 类型的窗口长期覆盖在别的应用上面,而超时自动隐藏这些窗口的时间,可以看作是一种防护措施。 第二处,文件 platform_frameworks_base/services/core/java/com/android/server/notification/NotificationManagerService.java 里 long delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY; 在同一文件里能找到 LONG_DELAY 与 SHORT_DELAY 的定义: static final int LONG_DELAY = PhoneWindowManager.TOAST_WINDOW_TIMEOUT; static final int SHORT_DELAY = 2000; // 2 seconds 点击查看 PhoneWindowManager.TOAST_WINDOW_TIMEOUT 的定义: 文件 platform_frameworks_base/services/core/java/com/android/server/policy/PhoneWindowManager.java /** Amount of time (in milliseconds) a toast window can be shown. */ public static final int TOAST_WINDOW_TIMEOUT = 3500; // 3.5 seconds 至此,我们可以得出 结论:Toast 的长/短超时时间分别为 3.5 秒和 2 秒。 Tips: 也可以通过分析代码里的逻辑,一层一层追踪用到 LENGTH_SHORT 和 LENGTH_LONG 的地方,最终得出结论,而这里是根据一些合理推断来简化追踪过程,更快达到目标,这在一些场景下是可取和必要的。 能不能弹一个时间超长的 Toast? 注:这里探讨的是能否直接通过 Toast 提供的公开 API 做到,网络上能搜索到的使用 Timer、反射、自定义等方式达到弹出一个超长时间 Toast 目的的方法不在讨论范围内。 我们在 Toast 类的源码里看一下跟设置时长相关的代码: 文件 platform_frameworks_base/core/java/android/widget/Toast.java ... /** @hide */ @IntDef({LENGTH_SHORT, LENGTH_LONG}) @Retention(RetentionPolicy.SOURCE) public @interface Duration {} ... /** * Set how long to show the view for. * @see #LENGTH_SHORT * @see #LENGTH_LONG */ public void setDuration(@Duration int duration) { mDuration = duration; mTN.mDuration = duration; } ... /** * Make a standard toast that just contains a text view. * * @param context The context to use. Usually your {@link android.app.Application} * or {@link android.app.Activity} object. * @param text The text to show. Can be formatted text. * @param duration How long to display the message. Either {@link #LENGTH_SHORT} or * {@link #LENGTH_LONG} * */ public static Toast makeText(Context context, CharSequence text, @Duration int duration) { return makeText(context, null, text, duration); } ... 其实从上面 setDuration 和 makeText 的注释已经可以看出,duration 只能取值 LENGTH_SHORT 和 LENGTH_LONG,除了注释之外,还使用了 @Duration 注解来保证此事。Duration 自身使用了 @IntDef 注解,它用于限制可以取的值。 文件 platform_frameworks_base/core/java/android/annotation/IntDef.java /** * Denotes that the annotated element of integer type, represents * a logical type and that its value should be one of the explicitly * named constants. If the {@link #flag()} attribute is set to true, * multiple constants can be combined. * ... */ 不信邪的我们可以快速在一个 demo Android 工程里写一句这样的代码试试: Toast.makeText(this, "Hello", 2); Android Studio 首先就不会同意,警告你 Must be one of: Toast.LENGTH_SHORT, Toast.LENGTH_LONG,但实际这段代码是可以通过编译的,因为 Duration 注解的 Retention 为 RetentionPolicy.SOURCE,我的理解是该注解主要能用于 IDE 的智能提示警告,编译期就被丢掉了。 但即使 duration 能传入 LENGTH_SHORT 和 LENGTH_LONG 以外的值,也并没有什么卵用,别忘了这里设置的只是一个 flag,真正计算的时候是 long delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY;,即 duration 为 LENGTH_LONG 时时长为 3.5 秒,其它情况都是 2 秒。 所以我们可以得出 结论:无法通过 Toast 提供的公开 API 直接弹出超长时间的 Toast。(如节首所述,可以通过一些其它方式实现类似的效果) Toast 能不能在非 UI 线程调用? 这个问题适合用一个 demo 来解答。 我们创建一个最简单的 App 工程,然后在启动 Activity 的 onCreate 方法里添加这样一段代码: new Thread(new Runnable() { @Override public void run() { Toast.makeText(MainActivity.this, "Call toast on non-UI thread", Toast.LENGTH_SHORT) .show(); } }).start(); 啊哦~很遗憾程序直接挂掉了。 11-07 13:35:33.980 2020-2035/org.mazhuang.androiduidemos E/AndroidRuntime: FATAL EXCEPTION: Thread-77 java.lang.RuntimeException: Can't create handler inside thread that has not called Looper.prepare() at android.widget.Toast$TN.<init>(Toast.java:390) at android.widget.Toast.<init>(Toast.java:114) at android.widget.Toast.makeText(Toast.java:277) at android.widget.Toast.makeText(Toast.java:267) at org.mazhuang.androiduidemos.MainActivity$1.run(MainActivity.java:27) at java.lang.Thread.run(Thread.java:856) 顺着堆栈里显示的方法调用从下往上一路看过去, 文件 platform_frameworks_base/core/java/android/widget/Toast.java 首先是两级 makeText 方法: // 我们的代码里调用的 makeText 方法 public static Toast makeText(Context context, CharSequence text, @Duration int duration) { return makeText(context, null, text, duration); } // 隐藏的 makeText 方法,不能手动调用 public static Toast makeText(@NonNull Context context, @Nullable Looper looper, @NonNull CharSequence text, @Duration int duration) { Toast result = new Toast(context, looper); // 这里的 looper 为 null ... 然后到了 Toast 的构造方法: public Toast(@NonNull Context context, @Nullable Looper looper) { mContext = context; mTN = new TN(context.getPackageName(), looper); // looper 为 null ... } 到 Toast$TN 的构造方法: // looper = null TN(String packageName, @Nullable Looper looper) { ... if (looper == null) { // Use Looper.myLooper() if looper is not specified. looper = Looper.myLooper(); if (looper == null) { throw new RuntimeException( "Can't toast on a thread that has not called Looper.prepare()"); } } ... } 至此,我们已经追踪到了我们的崩溃的 RuntimeException,即要避免进入抛出异常的逻辑,要么调用的时候传递一个 Looper 进来(无法直接实现,能传递 Looper 参数的构造方法与 makeText 方法是 hide 的),要么 Looper.myLooper() 返回不为 null,提示信息 Can't create handler inside thread that has not called Looper.prepare() 里给出了方法,那我们在 toast 前面加一句 Looper.prepare() 试试?这次不崩溃了,但依然不弹出 Toast,毕竟,这个线程在调用完 show() 方法后就直接结束了,没有调用 Looper.loop(),至于为什么调用 Toast 的线程结束与否会对 Toast 的显示隐藏等起影响,在本文的后面的章节里会进行分析。 从崩溃提示来看,Android 并没有限制在非 UI 线程里使用 Toast,只是线程得是一个有 Looper 的线程。于是我们尝试构造如下代码,发现可以成功从非 UI 线程弹出 toast 了: new Thread(new Runnable() { @Override public void run() { final int MSG_TOAST = 101; final int MSG_QUIT = 102; Looper.prepare(); final Handler handler = new Handler() { @Override public void handleMessage(Message msg) { switch (msg.what) { case MSG_TOAST: Toast.makeText(MainActivity.this, "Call toast on non-UI thread", Toast.LENGTH_SHORT) .show(); sendEmptyMessageDelayed(MSG_QUIT, 4000); return; case MSG_QUIT: Looper.myLooper().quit(); return; } super.handleMessage(msg); } }; handler.sendEmptyMessage(MSG_TOAST); Looper.loop(); } }).start(); 至于为什么 sendEmptyMesageDelayed(MSG_QUIT, 4000) 里的 delayMillis 我设成了 4000,这里卖个关子,感兴趣的同学可以把这个值调成 0、1000 等等看一下效果,会有一些意想不到的情况发生。 到此,我们可以得出 结论:可以在非 UI 线程里调用 Toast,但是得是一个有 Looper 的线程。 ps. 上面这一段演示代码让人感觉为了弹出一个 Toast 好麻烦,也可以采用 Activity.runOnUiThread、View.post 等方法从非 UI 线程将逻辑切换到 UI 线程里执行,直接从 UI 线程里弹出,UI 线程是有 Looper 的。 知识点:这里如果对 Looper、Handler 和 MessageQueue 有所了解,就容易理解多了,预计下一篇对这三剑客进行讲解。 应用在后台时能不能 Toast? 这个问题也比较适合用一个简单的 demo 来尝试回答。 在 MainActivity 的 onCreate 里加上这样一段代码: view.postDelayed(new Runnable() { @Override public void run() { Toast.makeText(MainActivity.this, "background toast", Toast.LENGTH_SHORT).show(); } }, 5000); 然后待应用启动后按 HOME 键,等几秒看是否能弹出该 Toast 即可。 结论是:应用在后台时可以弹出 Toast。 Toast 数量有没有限制? 这个问题将在下一节中一并解答。 Toast.makeText(…).show() 具体都做了些什么? 首先看一下 makeText 方法。 文件 platform_frameworks_base/core/java/android/widget/Toast.java /** * Make a standard toast to display using the specified looper. * If looper is null, Looper.myLooper() is used. * @hide */ public static Toast makeText(@NonNull Context context, @Nullable Looper looper, @NonNull CharSequence text, @Duration int duration) { Toast result = new Toast(context, looper); LayoutInflater inflate = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null); TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message); tv.setText(text); result.mNextView = v; result.mDuration = duration; return result; } 这个方法里就是构造了一个 Toast 对象,将需要展示的 View 准备好,设置好超时时长标记,我们可以看一下 com.android.internal.R.layout.transient_notification 这个布局的内容: 文件 platform_frameworks_base/core/res/res/layout/transient_notification.xml <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:background="?android:attr/toastFrameBackground"> <TextView android:id="@android:id/message" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginHorizontal="24dp" android:layout_marginVertical="15dp" android:layout_gravity="center_horizontal" android:textAppearance="@style/TextAppearance.Toast" android:textColor="@color/primary_text_default_material_light" /> </LinearLayout> 我们最常见的 Toast 就是从这个布局文件渲染出来的了。 我们继续看一下 makeText 里调用的 Toast 的构造方法里做了哪些事情: /** * Constructs an empty Toast object. If looper is null, Looper.myLooper() is used. * @hide */ public Toast(@NonNull Context context, @Nullable Looper looper) { mContext = context; mTN = new TN(context.getPackageName(), looper); mTN.mY = context.getResources().getDimensionPixelSize( com.android.internal.R.dimen.toast_y_offset); mTN.mGravity = context.getResources().getInteger( com.android.internal.R.integer.config_toastDefaultGravity); } 主要就是构造了一个 TN 对象,计算了位置。 TN 的构造方法: TN(String packageName, @Nullable Looper looper) { // XXX This should be changed to use a Dialog, with a Theme.Toast // defined that sets up the layout params appropriately. final WindowManager.LayoutParams params = mParams; params.height = WindowManager.LayoutParams.WRAP_CONTENT; params.width = WindowManager.LayoutParams.WRAP_CONTENT; params.format = PixelFormat.TRANSLUCENT; params.windowAnimations = com.android.internal.R.style.Animation_Toast; params.type = WindowManager.LayoutParams.TYPE_TOAST; params.setTitle("Toast"); params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE; mPackageName = packageName; if (looper == null) { // Use Looper.myLooper() if looper is not specified. looper = Looper.myLooper(); if (looper == null) { throw new RuntimeException( "Can't toast on a thread that has not called Looper.prepare()"); } } mHandler = new Handler(looper, null) { ... }; } 设置了 LayoutParams 的初始值,在后面 show 的时候会用到,设置了包名和 Looper、Handler。 TN 是 App 中用于与 Notification Service 交互的对象,这里涉及到 Binder 和跨进程通信的知识,这块会在后面开新篇来讲解,这里可以简单地理解一下:Notification Service 是系统为了管理各种 App 的 Notification(包括 Toast)的服务,比如 Toast,由这个服务来统一维护一个待展示 Toast 队列,各 App 需要弹 Toast 的时候就将相关信息发送给这个服务,服务会将其加入队列,然后根据队列的情况,依次通知各 App 展示和隐藏 Toast。 接下来看看 show 方法: /** * Show the view for the specified duration. */ public void show() { if (mNextView == null) { throw new RuntimeException("setView must have been called"); } INotificationManager service = getService(); String pkg = mContext.getOpPackageName(); TN tn = mTN; tn.mNextView = mNextView; try { service.enqueueToast(pkg, tn, mDuration); } catch (RemoteException e) { // Empty } } 调用了 INotificationManager 的 enqueueToast 方法,INotificationManager 是一个接口,其实现类在 NotificationManagerService 里,我们来看 enqueueToast 方法的实现: 文件 platform_frameworks_base/services/core/java/com/android/server/notification/NotificationManagerService.java @Override public void enqueueToast(String pkg, ITransientNotification callback, int duration) { ... synchronized (mToastQueue) { ... try { ToastRecord record; int index = indexOfToastLocked(pkg, callback); // If it's already in the queue, we update it in place, we don't // move it to the end of the queue. if (index >= 0) { record = mToastQueue.get(index); record.update(duration); } else { // Limit the number of toasts that any given package except the android // package can enqueue. Prevents DOS attacks and deals with leaks. if (!isSystemToast) { int count = 0; final int N = mToastQueue.size(); for (int i=0; i<N; i++) { final ToastRecord r = mToastQueue.get(i); if (r.pkg.equals(pkg)) { count++; if (count >= MAX_PACKAGE_NOTIFICATIONS) { Slog.e(TAG, "Package has already posted " + count + " toasts. Not showing more. Package=" + pkg); return; } } } } Binder token = new Binder(); mWindowManagerInternal.addWindowToken(token, TYPE_TOAST, DEFAULT_DISPLAY); record = new ToastRecord(callingPid, pkg, callback, duration, token); mToastQueue.add(record); index = mToastQueue.size() - 1; keepProcessAliveIfNeededLocked(callingPid); } // If it's at index 0, it's the current toast. It doesn't matter if it's // new or just been updated. Call back and tell it to show itself. // If the callback fails, this will remove it from the list, so don't // assume that it's valid after this. if (index == 0) { showNextToastLocked(); } } finally { Binder.restoreCallingIdentity(callingId); } } } 主要就是使用调用方传来的包名、callback 和 duration 构造一个 ToastRecord,然后添加到 mToastQueue 中。如果在 mToastQueue 中已经存在该包名和 callback 的 Toast,则只更新其 duration。 这段代码里有一段可以回答我们的上一个问题 Toast 数量有没有限制 了: // Limit the number of toasts that any given package except the android // package can enqueue. Prevents DOS attacks and deals with leaks. if (!isSystemToast) { int count = 0; final int N = mToastQueue.size(); for (int i=0; i<N; i++) { final ToastRecord r = mToastQueue.get(i); if (r.pkg.equals(pkg)) { count++; if (count >= MAX_PACKAGE_NOTIFICATIONS) { Slog.e(TAG, "Package has already posted " + count + " toasts. Not showing more. Package=" + pkg); return; } } } } 即会计算 mToastQueue 里该包名的 Toast 数量,如果超过 50,则将当前申请加入队列的 Toast 抛弃掉。所以上一个问题的 结论是:Toast 队列里允许每个应用存在不超过 50 个 Toast。 那么构造 ToastRecord 并加入 mToastQueue 之后是如何调度,控制显示和隐藏的呢?enqueueToast 方法里有个逻辑是如果当前列表里只有一个 ToastRecord,则调用 showNextToastLocked,看一下与该方法相关的代码: @GuardedBy("mToastQueue") void showNextToastLocked() { ToastRecord record = mToastQueue.get(0); while (record != null) { ... try { record.callback.show(record.token); scheduleTimeoutLocked(record); return; } catch (RemoteException e) { ... if (index >= 0) { mToastQueue.remove(index); } ... } } } ... @GuardedBy("mToastQueue") private void scheduleTimeoutLocked(ToastRecord r) { mHandler.removeCallbacksAndMessages(r); Message m = Message.obtain(mHandler, MESSAGE_TIMEOUT, r); long delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY; mHandler.sendMessageDelayed(m, delay); } private void handleTimeout(ToastRecord record) { if (DBG) Slog.d(TAG, "Timeout pkg=" + record.pkg + " callback=" + record.callback); synchronized (mToastQueue) { int index = indexOfToastLocked(record.pkg, record.callback); if (index >= 0) { cancelToastLocked(index); } } } ... @GuardedBy("mToastQueue") void cancelToastLocked(int index) { ToastRecord record = mToastQueue.get(index); try { record.callback.hide(); } catch (RemoteException e) { ... } ToastRecord lastToast = mToastQueue.remove(index); mWindowManagerInternal.removeWindowToken(lastToast.token, true, DEFAULT_DISPLAY); keepProcessAliveIfNeededLocked(record.pid); if (mToastQueue.size() > 0) { // Show the next one. If the callback fails, this will remove // it from the list, so don't assume that the list hasn't changed // after this point. showNextToastLocked(); // 继续显示队列里的下一个 Toast } } ... private final class WorkerHandler extends Handler { ... @Override public void handleMessage(Message msg) { switch (msg.what) { case MESSAGE_TIMEOUT: handleTimeout((ToastRecord)msg.obj); break; ... } } } 即首先调用 record.callback.show(record.token),通知 App 展示该 Toast,然后根据 duration,延时发送一条超时消息 MESSAGE_TIMEOUT,WorkHandler 收到该消息后,调用 cancelToastLocked 通知应用隐藏该 Toast,并继续调用 showNextToastLocked 显示队列里的下一个 Toast。这样一个机制就保证了只要队列里有 ToastRecord,就能依次显示出来。 机制弄清楚了,再详细看一下应用接到通知 show 和 hide 一个 Toast 后是怎么做的: 文件 platform_frameworks_base/core/java/android/widget/Toast.java private static class TN extends ITransientNotification.Stub { ... TN(String packageName, @Nullable Looper looper) { ... mHandler = new Handler(looper, null) { @Override public void handleMessage(Message msg) { switch (msg.what) { case SHOW: { IBinder token = (IBinder) msg.obj; handleShow(token); break; } case HIDE: { handleHide(); ... break; } ... } } }; } /** * schedule handleShow into the right thread */ @Override public void show(IBinder windowToken) { if (localLOGV) Log.v(TAG, "SHOW: " + this); mHandler.obtainMessage(SHOW, windowToken).sendToTarget(); } /** * schedule handleHide into the right thread */ @Override public void hide() { if (localLOGV) Log.v(TAG, "HIDE: " + this); mHandler.obtainMessage(HIDE).sendToTarget(); } ... public void handleShow(IBinder windowToken) { ... mWM.addView(mView, mParams); ... } ... public void handleHide() { ... mWM.removeViewImmediate(mView); ... } } 显示过程:show 方法被远程调用后,先是发送了一个 SHOW 消息,接收到该消息后调用了 handleShow 方法,然后 mWM.addView 将该 View 添加到窗口。 隐藏过程:hide 方法被远程调用后,先是发送了一个 HIDE 消息,接收到该消息后调用了 handleHide 方法,然后 mWM.removeViewImmediate 将该 View 从窗口移除。 这里插播一条结论,就是前文留下的为什么调用 Toast 的线程线束之后没弹出的 Toast 就无法弹出了的问题,因为 Notification Service 通知应用进程显示或隐藏 Toast 时,使用的是 mHandler.obtainMessage(SHOW).sendToTarget() 与 mHandler.obtainMessage(HIDE).sendToTarget(),这个消息发出去后,Handler 对应线程没有在 Looper.loop() 过程里的话,就没有办法进入到 Handler 的 handleMessage 方法里去,自然也就无法调用显示和隐藏 View 的流程了。Looper.loop() 相关的知识点将在下篇讲解。 总结 补充后的 Toast 知识点列表 Toast 不是 View,它用于帮助创建并展示包含一条小消息的 View; 它的设计理念是尽量不惹眼,但又能展示想让用户看到的信息; 被展示时,浮在应用界面之上; 永远不会获取到焦点; 大小取决于消息的长度; 超时后会自动消失; 可以自定义显示在屏幕上的位置(默认左右居中显示在靠近屏幕底部的位置); 可以使用自定义布局,也只有在自定义布局的时候才需要直接调用 Toast 的构造方法,其它时候都是使用 makeText 方法来创建 Toast; Toast 弹出后当前 Activity 会保持可见性和可交互性; 使用 cancel 方法可以立即将已显示的 Toast 关闭,让未显示的 Toast 不再显示; Toast 也算是一个「通知」,如果弹出状态消息后期望得到用户响应,应该使用 Notification; Toast 的超时时间为 LENGTH_SHORT 对应 2 秒,LENGTH_LONG 对应 3.5 秒; 不能通过 Toast 类的公开方法直接弹一个时间超长的 Toast; 应用在后台时可以调用 Toast 并正常弹出; Toast 队列里允许单个应用往里添加 50 个 Toast,超出的将被丢弃。 遗留知识点 本篇涉及到了一些需要进一步了解的知识点,在后续的篇章中会依次解读: Handler、Looper 和 MessageQueue WindowManager Binder 与跨进程通信 本篇用到的源码分析方法 查找关键变量被引用的地方; 按方法调用堆栈一层层逻辑跟踪与分析; 使用 git blame 查看关键代码行的变更日志; 后话 到此,上面提到的几个问题都已经解答完毕,对 Toast 源码的分析也告一段落。 写这篇文章花费的时间比较长,所以并不能按照预计的节奏更新,这里表示抱歉。另外,各位如果有耐心读到这里,觉得本文的思路是否清晰,是否能跟随文章的节奏理解一些东西?因为我也在摸索写这类文章的组织形式,所以也希望能收到反馈和建议,以作改进,先行谢过。 最后,照例要安利一下我的微信公众号「闷骚的程序员」,扫码关注,接收 rtfsc-android 的最近更新。

    2017/11/12 Android