2018年5月14日星期一

飞利浦 Hue 智能照明系统开发(Part 1 - API 入门)

Hue Bridge

我家里有超过 10 个的飞利浦 Hue 智能灯泡,我通常使用 Amazon Echo 和 iOS HomeKit 控制它们,例如说睡觉时对着 Echo 喊「Alexa, turn off bedroom.」,或者在 iPhone 上通过 Control Center 迅速开关一盏灯。在我买了一个 Raspberry Pi 架设加密 DNS 后,我就开始思考是否能在 Raspberry Pi 上跑一个程序控制 Hue 做更复杂的事情。在此之前,我先要自己搞明白 Hue 的 API 是如何工作的以及能做什么,于是我开始阅读 Hue API 文档

Hue API 是很典型的 REST API,我们可以通过 bridge 上面的 HTTP 服务器调用 Hue API 操作这个 bridge 连接的所有灯泡。Hue API 可以操作的对象包括灯泡、房间、传感器、规则等等,不过在我们能够调用这些 API 之前,首先我们必须找到 bridge 的 IP,然后才能连上它的 HTTP 服务器。

寻找 Hue Bridge

大多数人家里的网络都是用 DHCP 动态分配 IP,因此我们不知道 Hue bridge 的 IP 是什么,就算知道了将来还可能变。我们要如何连接 bridge 呢?最简单的方法是发一个请求到这个地址:

https://www.meethue.com/api/nupnp

这是飞利浦自己的服务器,不是我们家里 bridge 的服务器。它会根据我们的公网 IP 来查询我们家是否有 bridge 通过同一个公网 IP 连接着飞利浦的服务器,如果有的话它就会返回这个(或这些)bridge 的信息,包括内网 IP。例如说:

[
  {
    "id": "001788fffe000999",
    "internalipaddress": "10.0.0.9"
  }
]

这个 JSON 里面只有一个 bridge,这也是最常见的状况。(很少人会需要在家里装超过一个 bridge。)这一个 bridge 的 IP 是 10.0.0.9,也就是 internalipaddress 属性的值。那 id 属性的值是什么呢?这是这个 bridge 的序列号,如果这个 bridge 的 IP 将来发生了变化,我们可以通过这个序列号来确认这是同一个 bridge。

在找到 bridge 的 IP 后,我们就可以连接上去了。我这里提到的只是最容易上手的 bridge 寻找办法,更复杂的方法可以看官方的 Hue Bridge Discovery Guide

Hue Bridge 本地授权

能够连接到 bridge 的 IP 并不意味着能够操作 bridge,否则 bridge 就没有任何安全性可言了。任何新的客户端连接 bridge 之前,都需要有人去手动按一下 bridge 上面的物理按钮,然后新的客户端才能获取到授权。

首先,我们要按下 bridge 上的物理按钮,然后发送一个 POST 请求到这个地址:

http://<bridge_ip>/api

这个 POST 请求需要带上一个简单的 JSON:

{
  "devicetype": "<app_name>#<device_name>"
}

这个 JSON 只有一个叫做 devicetype 的属性,用来命名这个新增的客户端,格式为应用名称加上 # 再加上设备名称。(一个应用可以安装在多台设备上,这样的命名格式要求可以帮助区分。)官方文档说这个字符串长度不能超过 40 个字符,其中应用名称不超过 19 个字符,设备名称不超过 20 个字符。实际上当前的 API 版本(1.24.0)只检查字符串长度是否超过 40,不检查应用名称和设备名称是否超过允许长度。

这个请求成功的话,返回的 JSON 会是这样子的:

[
  {
    "success": {
      "username": "83b7780291a6ceffbe0bd049104df"
    }
  }
]

我们获取到了一个叫做 username 的东西,这实际上是个密钥一样的东西,因为只要掌握了它就能操作 bridge。我们需要把这个密钥保存下来,然后我们就可以进行其他任意操作了。

测试请求

拿到 username 后,我们当然要测试一下能不能用。Hue API 传输 username 这个密钥的方式很有趣,我们需要把它放在 URL 里面,例如这样子:

http://<bridge_ip>/api/<username>/config

发一个 GET 请求到这个地址,我们就能获得这个 bridge 的所有配置信息。成功获取信息意味着 username 是有效的密钥,之后我们就能用它来获取其他信息了。以下是一些可以尝试 GET 请求获取的地址:

http://<bridge_ip>/api/<username>/lights
http://<bridge_ip>/api/<username>/groups
http://<bridge_ip>/api/<username>/schedules
http://<bridge_ip>/api/<username>/scenes
http://<bridge_ip>/api/<username>/sensors
http://<bridge_ip>/api/<username>/rules
http://<bridge_ip>/api/<username>/resourcelinks
http://<bridge_ip>/api/<username>/capabilities

总结

拥有上述知识后,我们就可以进一步探索 Hue API 了。为了方便方便我自己,我写了一个叫做 Hue Explorer 的开源项目用于连接我自己的 bridge 并查看上面的信息,如果你想要看源代码的话你可以到 GitHub 上查看。我暂时只做了一部分的 JSON 可视化,例如说灯可以可视化为这样子:

Hue Explorer v.0.2.0

如果你想要用第三方工具发送 REST 请求,可以试一下免费的 Postman。如果你懒得下载,可以直接访问 http://<bridge_ip>/debug/clip.html,这是一个每一个 bridge 都自带的调试工具,可以用来手工编写 REST 请求。

在下一篇文章里,我会开始讲述每一个具体的 Hue API 能做什么,不想错过的话欢迎通过邮件RSS/Atom 订阅我的博客。

2018年4月28日星期六

用 Raspberry Pi 架设加密 DNS 客户端

dig through DNS-over-HTTPS

最近 Cloudflare 宣布使用 1.1.1.1 作为 DNS,并且强调隐私保护。由于 Cloudflare DNS 支持 DNS-over-TLS 和 DNS-over-HTTPS,这使得加密 DNS 成为了热门话题

因为操作系统往往不支持加密 DNS,所以要使用加密 DNS 必须使用一个加密 DNS 的客户端,然后这个客户端同时作为一个明文 DNS 服务器向操作系统提供正常的 DNS 服务。我可以选择在每一台我使用的设备上安装一个加密 DNS 客户端(对于 iOS 来说则是 NetworkExtension),我也可以选择在家里假设一个加密 DNS 客户端然后把路由器 DNS 指向过去,之后家里所有设备的 DNS 都会跟着变。我选择了后者,因为这样做比较方便,也为我提供了一个折腾 Raspberry Pi 的借口——我需要把加密 DNS 客户端部署到 Raspberry Pi 上让它长期为家里的局域网提供 DNS 服务。

(为什么不用 OpenWRT 呢?因为我家里已经在用 Eero 来做路由器了,它可以通过 mesh Wi-Fi 来提供更好的覆盖。如果我要多买一个 OpenWRT 路由放在 Eero 前面,那我还不如买个 Raspberry Pi 来玩玩呢。)

Raspberry Pi

我买了这个 Raspberry Pi 套装,因为它自带盒子和电源。电源不重要,我家已经有很多 USB 电源,但是我总不能一块电路板随便一放吧,所以必须买个盒子。然后我还买了张 64GB 的 microSD。因为我所有 microSD 都是 64GB 的,所以我只买 64GB 的方便有需要时随意替换。

收到 Raspberry Pi 之后,我就按照官方 NOOBS 的指引下载和准备安装。然而 NOOBS 复制到 SD 卡后无论如何 Raspberry Pi 都无法正常启动,只亮红灯没有视频输出。搜索之后发现绿灯不亮就是没有读取 SD 卡进行启动。我开头怀疑是我下载的 NOOBS 有问题,于是换成 NOOBS Lite 和 Raspbian,但都是不行。我也怀疑过是不是下载的 zip 数据有问题,但 sha256 checksum 正确。

实在找不到问题了,我就开始搜索到底 Raspberry Pi 是如何进行引导的,发现它必须从 FAT 分区进行引导。Raspberry Pi 自己的官方文档教大家使用一个叫做 SD Association’s Formatting Tool 的软件来格式化 SD 卡,但这个软件在面对超过 32GB 的卡时就会傻傻地使用 exFAT 来进行格式化。其实使用 Mac 内置的 Disk Utility 不就好咯,就算是超过 32GB 的 SD 卡也可以选择格式化为 FAT。

把 SD 格为 FAT 后,所有问题都解决了。NOOBS 能够正常启动,接着 Raspbian 也能够顺利装上。Raspberry Pi 安装好之后我尝试启用 VNC 以便我用 Mac 远程控制,结果那上面装的 VNC 和 Mac 自带的 Screen Sharing 客户端不兼容,我只好降级到用 SSH,不过也能完成绝大多数操作了。

启用 SSH 后 Raspbian 会提醒你改默认密码,没有改的话记得改掉,否则太不安全了。因为 Raspbian 连 dig 这么基本的命令都没有,需要通过 apt-get 来安装,所以我们需要先更新一下然后把 dig 装上:

sudo apt-get update
sudo apt-get install dnsutils

DNS-over-HTTPS

我基本上就是按照 Cloudflare 的 DNS-over-HTTPS 指引 来做的。一开始我觉得 Raspbian 既然是 Debian 系的就下载了 Debian 的安装包,结果发现安装不上去。接着尝试用 Linuxbrew 来装 homebrew 的版本,结果装上后发现不能执行。看到「exec format error」并且搜索后才突然明白到,Raspberry Pi 不是基于 x86/x64 架构的,而是基于 ARM 架构的。那到底 Raspberry Pi 是 32 位还是 64 位的呢?理论上 Raspberry Pi 3 B+ 是 64 位的 CPU,但在 Raspbian 上执行 uname -a 的话会显示:

Linux raspberrypi 4.9.80-v7+ #1098 SMP Fri Mar 9 19:11:42 GMT 2018 armv7l GNU/Linux

所以其实不是 64 位的,如果要选正确的版本那必须选 32 位的 ARM。只要选择正确的版本,Cloudflared 和 Dnscrypt-Proxy 都是可以用的。我两个都装了,都能在 localhost:53 上跑起来,最后选择了 Dnscrypt-Proxy 是因为配置方便。(Dnscrypt-Proxy 有配置文件模板,改改就可以用了,不需要对着文档写一个新的。)

Dnscrypt-Proxy 的安装跟着官方指引做就可以了,选择 Linux 版本 来下载。记得下载 Linux ARM 的版本,不要用 Android 或者 ARM64 的版本。(尽管 Dnscrypt-Proxy 是可以安装在 Pi-Hole 上面的,但我不想安装 Pi-Hole 来过滤广告所以选择了非 Pi-Hole 的版本。)尽管官方指引叫你检查一下是否有别的 DNS 服务正在使用 53 端口,但新装的 Raspbian 应该是不会有任何服务占用 53 端口的所以这一步可以略过。

Dnscrypt-Proxy 下载和解压好之后就可以开始配置了。假设我们已经在 Dnscrypt-Proxy 解压好的目录里:

cp example-dnscrypt-proxy.toml dnscrypt-proxy.toml
sudo ./dnscrypt-proxy

这时候 Dnscrypt-Proxy 应该能够跑起来,在 Raspberry Pi 上用 dig 验证一下就知道了:

dig +short @127.0.0.1 cloudflare.com AAAA

这个验证必须在 Raspberry Pi 上做,因为 Dnscrypt-Proxy 的默认配置只监听 localhost:53 端口,从另外一台机器连上来 53 端口是不行的。如果 Dnscrypt-Proxy 正常工作了,我们就可以开始改配置了。打开 dnscrypt-proxy.toml,然后把 server_nameslisten_addresses 改掉。(在 SSH 上面,用 nanovi 都可以编辑 dnscrypt-proxy.toml。)

首先找到 server_names,把前面注释这一行的 # 删掉,然后把后面的内容改为你想要的服务。因为 Cloudflare 和 Google 都支持 DNS-over-HTTPS,而且都是可靠的大公司,所以我在这两家之间选。因为 Google 不强调隐私,有可能记录数据,所以我只用 Cloudflare 的,按照 Cloudflare 的文档把这一行改为这样子:

server_names = ['cloudflare', 'cloudflare-ipv6']

接着找到 listen_addresses,你会发现它只监听 IPv4 和 IPv6 的 localhost,所以其他机器不能用 Raspberry Pi 来做 DNS。这时候你要想办法把 Raspberry Pi 的 IP 绑上去。我的做法是这样子的:因为我家里路由器的 IP 是 192.168.0.1,然后 DHCP 范围是 192.168.0.10–192.168.0.199,所以 192.168.0.2–192.168.0.9 是不会被动态分配出去的。我把 Raspberry Pi 的有线网 IP 写死为 192.168.0.2,然后把它加到监听地址端口列表上:

listen_addresses = ['127.0.0.1:53', '[::1]:53', '192.168.0.2:53']

搞掂之后,可以再启动一下 Dnscrypt-Proxy:

sudo ./dnscrypt-proxy

然后从另外一台机器使用 dig 测试一下:

dig +short @192.168.0.2 cloudflare.com AAAA

如果没有问题的话,就可以把 Dnscrypt-Proxy 当装系统服务启动了:

sudo ./dnscrypt-proxy -service install
sudo ./dnscrypt-proxy -service start
sudo systemctl enable dnscrypt-proxy

之后登录到路由器,把路由器的 DNS 改为 192.168.0.2 就可以了,家里所有设备的 DNS 都会经过 Raspberry Pi 上的 Dnscrypt-Proxy 走 DNS-over-HTTPS 连接 Cloudflare 服务器。尽管 Dnscrypt-Proxy 的官方指引还说要把 Linux 上的 DNS 客户端指向 localhost,但因为我暂时不在 Raspberry Pi 上做别的事情所以不在意 Raspberry Pi 本身发出的 DNS 请求是否加密。只要它作为 DNS 服务器服务好我家里的其他设备就行。

已知问题

上述做法是有一些已知问题的。首先,如果我们请求使用 SNI 的 HTTPS 服务的话,我们还是会明文传输域名的,就算 DNS 加密了还是会存在域名泄漏的情况。如果多个不同证书的 HTTPS 域名要在一个 IP 上共处,那必须使用 SNI 否则 SSL 握手时无法决定用哪个证书的密钥。因此 SNI 常见于跑在云平台上的服务,因为云平台往往在多个服务之间共享 IP,但每一个服务来自不同的客户有不同的证书。对于大型网站来说这不常见,因为无论一个大型网站旗下有多少域名,它都可以选择把所有域名放在同一个证书里面。

其次,我没有做 IPv6 的配置,只让 Dnscrypt-Proxy 绑定了一个 IPv4 地址。这时候如果 IPv6 分配了不一样的 DNS,那使用 IPv6 DNS 查询时还是会走明文的。如果你所处的网络完全不使用 IPv6,那是没问题的。我知道 Comcast 是会分配 IPv6 地址和 IPv6 DNS 的,所以如果不在路由器上设置 IPv6 DNS(或者是不能设置)的话,那 IPv6 DNS 就有可能是 Comcast 分配下来的,也就是明文 DNS。(其他 ISP 也一样。)

最后,如果你喜欢我的文章,欢迎通过邮件订阅我的博客。

2018年4月27日星期五

网上吵架原则

在网上跟别人吵架辩论时,我有两条原则:

  1. 不能在三个回合内结束的架不吵。如果一件事情不能在三个回合内说清楚,那就是自己没搞清楚,不要浪费时间去跟别人吵。
  2. 拥有不能公开的信息时不跟别人吵架。如果一件事情自己觉得能够说清楚,但有部份信息不能够公开,那肯定说服不了别人,说了还不如不说,因为说了之后别人会觉得你缺乏证据强词夺理。

2018年4月22日星期日

如何把 Blogger 文章导入到 Facebook Instant Article

Facebook Instant Article in Pages Manager

如果你跟我一样还在用 Blogger 这么远古的工具来写博客,同时又想追赶一下 Facebook Instant Article 的潮流,那你可以跟着我这篇文章做一遍来把 Blogger 的文章导入到 Instant Article。

启用 Instant Article

假设你跟我一样已经有一个在用的 Blogger 博客了,你不需要在 Blogger 上做什么改动,直接开始注册 Instant Article 就可以了。打开 Facebook Instant Article 的入口页面,然后点击注册。Facebook 会问你要为哪个 Page 启用 Instant Article,你选一个就是了。我用 Cat Chen Posts 这个页面来导出我所有的 Blogger 文章,所以我就选择了这个 Page。如果你还没有 Page 的话,可以先创建一个,因为没有 Page 是不能创建 Instant Article 的。但只要在 Page 上把一篇文章转化为 Instant Article 了,之后在个人帐号分享这篇文章的链接也会显示为 Instant Article。

关联域名

接下来你需要跟随 Facebook Instant Article 的配置工具来一步一步完成配置。首先,你要把自己的 BlogSpot 网站关联到你的 Page 下面来。Facebook 会让你把这样一行加到你的 BlogSpot 模板上:

<meta property="fb:pages" content="{page-id}" />

每一个具体 Page 的 {page-id} 都不一样,你复制粘贴 Facebook 配置工具上显示的那一行就可以了。把这一行复制下来后,你可以去 Blogger 里配置 Theme,选择修改 HTML,然后把这一行贴到 <head>...</head> 里面。之后把你 BlogSpot 的域名填到 Facebook 配置工具里去,Facebook 就会抓取你 BlogSpot 的首页并且进行验证。(如果你之前在 Google Webmasters 之类的服务做过类似的域名认证操作,这一步应该很容易。)

导入 RSS

接下来要把 Blogger 的 RSS 导入到 Facebook。如果你的 RSS 没有搞过什么花样,这是非常简单的事情。但如果你像我一样在 RSS 上做过各种优化,那这一步可以很复杂。

FeedBurner

如果你像我一样启用了 FeedBurner,你会发现 FeedBurner 导出的 RSS 可能是 Facebook 不愿意接受的格式,因为 FeedBurner 的优化加了太多东西进去。(但如果不加任何优化的话,Facebook 是可以接受的。)你可以使用 BlogSpot 自带的 feed,但既然你用了 FeedBurner 你很可能跟我一样让 BlogSpot 把自带的 feed 重定向到 FeedBurner,这时候如何才能获取到 BlogSpot 自带的 feed 呢?关键在于 querystring。以我自己的 feed 为例:

http://chinese.catchen.me/feeds/posts/default?alt=rss&redirect=false

加上 ?alt=rss 会强制输出符合 Facebook 期望的 RSS 格式。加上 ?redirect=false 或禁用 FeedBurner 重定向。两者都用上,就能让 Facebook 得到一个它能够解析和接受的 RSS。

合并多个 RSS

我用有多个 Blogger,因此我有多个 RSS 但 Facebook 只接受一个,这怎么办呢?这个问题可以用 RSS Mix 解决。把多个 RSS 的地址输入进去,它会生成一个 RSS,然后把这个合并后的 RSS 给 Facebook 就可以了。

提交审核

搞掂 RSS 后,就可以把 RSS 地址交给 Facebook 了。直接填进去 Production RSS Feed 是没问题的,将来要测试新版本的 RSS 可以用 Development RSS Feed。如果 RSS 里面已经有至少 10 篇文章,那就可以提交审核了,否则还需要写满 10 篇文章才能提交审核。

我把博客提交审核时没有遇到任何问题,但因为审核这个事情因人而异所以很难说你会不会遇到什么问题。如果遇到问题的话可以根据 Facebook 的提示进行修改。审核一旦通过了,你就可以把 Production RSS Feed 里面的内容发布为 Instant Article 了。

发布 Instant Article

尽管 Facebook 从 RSS 中读取了文章,但并不会自动把 Instant Article 发出去。你需要去 Production Articles 里面查看 RSS 导入了的文章,然后把你想要发布的发布出去,不发布的话它们会当作草稿一直存着。

在发布之前,你可能会看到某些文章标题旁边有个感叹号,那意味着 Facebook 在解析这篇文章时遇到了问题,不解决这些问题这篇文章就无法被发布出去。这时候你需要做的事情就是编辑文章 HTML,然后把问题都解决掉。具体哪些问题会出现,要看你在 Blogger 中使用的 HTML 有多复杂。尽管 Facebook 使用的 Instant Article 格式也是 HTML,但其实只是一个 HTML 子集,如果你使用的 HTML 超出了这个子集 Facebook 就会尝试进行调整,如果调整后还是有问题你就会看到那个感叹号。

我最常遇到的问题是图片嵌入在段落内。Facebook Instant Article 规定图片必须放在 <figure>...</figure> 里面,如果你在写作时只是用了 <img />,那 Facebook 就会尝试智能地在外面包一层 <figure>...</figure>。但如果你原本的 <img /> 是嵌套在 <p>...</p> 里面的话,那 Facebook 处理后就会变成了 <p><figure><img /></figure></p>。由于 Instant Article 中的 <p>...</p><figure>...</figure> 是互斥的,只能是平级关系,不能互相嵌套,所以 Facebook 就会报错。(错误信息还很奇怪,Facebook 会告诉你元素内没有文本,但其实意思是 <p>...</p> 内不能嵌套 <figure>...</figure>。)解决的办法很简单,把外面那层 <p>...</p> 去掉就可以了。

除了上述问题外,你还可能遇到其他跟 Instant Article HTML 子集不兼容的问题。Facebook 提供的错误信息不一定容易理解,但自行搜索一下总能找到答案。只要把问题都解决了,文章就能够当作 Instant Article 发布了。

测试 Instant Article 效果

最简单的测试方式是用 Facebook Pages Manager (iOS | Android)。在里面打开自己的 Page,如果看到文章下面有个 Instant Article 的闪电符号那意味着文章成功发布为 Instant Article 了。点击进去就能看到文章以 Instant Article 渲染的样子,如果跟自己想要的样子不一样可以回去继续修改 HTML。这篇文章开头的截图就是来自 Facebook Pages Manager,里面显示的是我之前一篇文章的 Instant Article 版本。

2018年4月15日星期日

移动网页的 iPhone X 适配

Instagram web without iPhone X fix (portrait)

一个月前我在 iPhone X 的 Mobile Safari 中打开 Instagram web,发现页面底下的导航栏跟 iPhone 的 home indicator 重叠在一起不方便使用。我想既然 Apple 为 iPhone X 专门更新的 Human Interface Guidelines 并为 native app 引入了 safe area 和 inset 等概念,那 Mobile Safari 应该有对应的 web 概念吧。搜索了一下,发现 Apple 确实对 Mobile Safari 增加了对应的功能。既然 Instagram 是我们公司的产品,那就动手去改吧。

改造的第一步是对页面加上这一句:

<meta name='viewport' content='initial-scale=1, viewport-fit=cover'>

因为大多数移动页面都已经有类似的声明,所以只要加上 viewport-fit=cover 就行了。不加的话,下面所有的 CSS inset 声明都不会生效。

第二部是把竖屏(portrait mode)时的页面底部导航栏往上挪。这时候我们可以把导航栏到屏幕底部的距离设置为 env(safe-area-inset-bottom),然后浏览器自动会使用正确的数值来进行布局。(在 Safari 显示自己的工具栏时,这个值会神器地变为 0,使得页面底部导航栏紧贴 Safari 工具栏。)假设我们使用 padding-bottom 把导航栏往上挪,那么我们可以写 padding-bottom: env(safe-area-inset-bottom)。(当然 Instagram web 的实际情况比这个复杂,如果你想研究的话可以用 Safari remote debugger + iPhone X Simulator 来看。)这样竖屏的问题就修复了。

Instagram web with iPhone X fix (portrait)

如果这是个 native app 的话,问题可能到此就结束了,因为 native app 可以选择不支持横屏(landscape mode)。然后网页必须支持横屏,因为浏览器本身可以横屏。(当然你也可以很霸道地在浏览器横屏时只显示一句提示让用户把屏幕直过来,这样就可以不支持横屏了。)因为 iPhone X 屏幕顶上的那个缺口(notch),Mobile Safari 在横屏时默认会在页面两侧加白边,确保任何没对 iPhone X 做修改的页面能够正常显示。

Instagram web without iPhone X fix (landscape)

这两侧的白边很不好看,因为会让原本应该贯穿全屏的横线终止在屏幕内。在加上 viewport-fit=cover 后,两侧的白边会消失掉,因为 Mobile Safari 把这看作开发者愿意对 iPhone X 布局负责,之后如何处理横屏一侧缺口就是开发者的责任了。之前对 Instagram web 的竖屏调整一旦放到横屏就会发现新问题。

Instagram web with some iPhone X fix (landscape)

页面顶部标题栏两侧的按钮太靠近屏幕边缘了。因为 iPhone X 屏幕边缘有圆角,所以按钮放在那里并不好按。此外那也在 Apple 定义的 safe area 之外,本来就不应该放可点击元素。为此我们必须使用 env(safe-area-inset-left)env(safe-area-inset-right) 把这两个按钮往页面中间挪。假设我们使用 margin-leftmargin-right 来控制布局的话,我们可以这样写:

.leftButton {
  margin-left: env(safe-area-inset-left);
}
.rightButton {
  margin-right: env(safe-area-inset-right);
}

这样子横屏是修复了,但又会给竖屏引入新的问题。在原本的竖屏设计中,按钮离两侧屏幕边缘 16px。在我们把 16px 替换成 env(safe-area-inset-left)env(safe-area-inset-right) 之后,竖屏时这两个按钮就贴着屏幕边缘了。为此我们要引入 max() 来保证按钮离屏幕边缘至少有 16px

.leftButton {
  margin-left: max(16px, env(safe-area-inset-left));
}
.rightButton {
  margin-right: max(16px, env(safe-area-inset-right));
}

这时候竖屏横屏都没问题了,唯一问题是 Safari 以外的浏览器都被弄晕了,这 maxenv 都是什么呀?我们还没支持呢,而且是否会被标准化也很难说。幸好大多数浏览器都支持 @support,我们可以用它来进行筛选,把专门写给 Safari 看的 CSS 留给 Safari 看。

.leftButton {
  margin-left: 16px;
}
.rightButton {
  margin-right: 16px;
}

@supports (margin: max(16px)) {
  .leftButton {
    margin-left: max(16px, env(safe-area-inset-left));
  }
  .rightButton {
    margin-right: max(16px, env(safe-area-inset-right));
  }
}

到此所有的问题都解决了,Instagram web 也能在横屏中正常显示了。王子和公主从此幸福地生活在一起。

Instagram web with all iPhone X fix (landscape)

故事当然不会到这里就结束了。首先,Instagram web 可不止这一个页面。这些页面的竖屏都不会有问题,但横屏就很难说了,有可能某些元素在使用 viewport-fit=cover 之后被布局到了 safe area 之外,需要把它们挪回来。这些问题我见到一个修一个,但永远也不知道是否有遗留的。当然这个问题在 native app 里面也存在,除非从零开始设计一个新的 app 并在设计原则和布局框架上对 safe area 作出考虑,否则一个 app 无论怎么改都无法证明改全了,而且开发新功能时一不小心没测 iPhone X 就可能出现不兼容的问题。

其次,Mobile Safari 在横屏模式时如果显示地址栏就会导致页面底部导航栏处于半隐藏状态,而非原来的全隐藏状态。

iPhone X Safari Apple bug (landscape)

为什么会发生这样的事情呢?因为在显示地址栏时 Safari 会把整个 viewport 往屏幕下方挪动地址栏的高度。这时候 viewport 高度是不会改变的,因此 viewport 的一部分就跑到屏幕外去了。(但 viewport 的定义不就是屏幕内可见区域么?Apple 你自己发明了这个概念,现在说改就改。)Apple 对此的解释是,显示地址栏的 animation 必须保持 60 FPS,但 viewport 高度变化过程受页面布局速度影响而无法做到 60 FPS,所以这是 feature 不是 bug。(Chrome for iOS 在显示地址栏时会调整 viewport 高度,但因为不是 60 FPS animation 所以会看到页面闪烁。)

我觉得 Apple 要把 viewport 偷偷隐藏掉一部分也不是问题,但在隐藏的时候至少应该把 env(safe-area-inset-bottom) 自动变会 0 吧?这样子底部导航栏至少可以完全隐藏掉。这个问题已经有其他人写过,并且那篇文章的作者已经给 Apple 开 bug。

最后一个问题,为什么 env(safe-area-inset-top) 没有被用到?因为 Mobile Safari 总会在屏幕顶部显示状态栏,所以网页永远都不需要自己想办法避让屏幕顶部的缺口。(那使用 <meta name="apple-mobile-web-app-capable" content="yes"> 强行进入全屏模式呢?iPhone X 会很恶心地在屏幕上方留下一个黑色区域。)估计唯一的例外是你自己写一个 app 并在里面放一个全屏的 WebView,这时候 WebView 内的网页就需要使用 env(safe-area-inset-top) 了。我没有试过做这样的事情,但可以参考别人的文章

总的来说,iPhone X 适配不是一个很难的技术问题,尤其是只做竖屏模式的话。

2018年3月9日星期五

Buy Me a Coffee

最近开了一个 Buy Me a Coffee 的页面,让大家帮我买咖啡。如果你想买一杯咖啡给我,你可以打开 chen.cat/buy-coffee 然后付 $5 给我,这正好够我去 Saint Frank Coffee 买一杯 Nitro Float。(如果你想买多几杯的话也可以哦!)

Nitro Float from Saint Frank Coffee

在刚刚创建这个页面时,我设置了一个目标:如果收到 5 杯咖啡(总共 $25)我就重新开始写博客。然后这个目标一天就超额完成了(总共 $40)。给我买咖啡的主要是我在 Facebook 的同事,其中 Shawn 最多,一下子就给我 5 杯的钱。

之后为了测试一下我知乎的转化率,我又把这个链接发到了我的知乎专栏,同时设定了一个新目标:如果收到 10 杯咖啡(总共 $50)我就在三月每周更新一篇博客。最终我从知乎收到了 12 杯咖啡(总共 $60),按照我知乎 24 万关注者来算这转化率为 0.005%。(因为 BMC 暂时还不提供任何手段区分用户来源,所以我并不能确保那 12 杯咖啡都是来自知乎的,只能说是在知乎专栏宣传后产生的。)

既然目标设定了,我就必须要写。为了监督我写作,大家可以订阅我的博客,或者关注我的 TwitterFacebook

手机无线充电真的更方便吗?

iPhone X with Anker wireless charger

之前一直在纠结要不要买个手机无线充电器来给 iPhone X 充电,因为 $60 买一个第一代产品总觉得有点浪费,而且还不是 Apple 自己的产品。(Apple 自己的 AirPower 至今连个日期和价格都还没有。)后来发现原来普通的 Qi 兼容无线充电器只需要 $20,唯一问题是不兼容 iOS 11.2 升级后新增的 7.5W 快充,只支持 5W 充电。+200% 的价格换 +50% 的充电速度不划算,所以我最终买了 Anker 的 5W 无线充电器。(Anker 还有一个 10W 的版本,但充 iPhone X 还是 5W,无法做到 7.5W。)

在买的时候我就想过了不同使用场景的利弊:

  1. 放在公司电脑桌上:我在座位时可以随时把手机放上去充电,需要去开会时随手可以拿走。好处是比插拔充电线稍微方便一点,但因为我桌面长期有闲置的充电线所以好处也不是非常明显。坏处是手机拿起来用时就不充电了。
  2. 放在家里床头柜上:晚上睡觉时充电。好处坏处跟前面的相似,外加一点坏处是充电灯睡觉时亮着会影响睡眠。
  3. 放在其他地方:除了上述两个地方以外,没有哪里我是会经常把手机放下来的,因此放在其他地方的坏处是使用频率会很低。

最终我选择了放在公司电脑桌上,然后发现这是个正确的选择。其实无线充电最大的便利就是不用插拔线,所以最值得把有线改为无线的地方应该具备两个特性:

  1. 这是一个固定的长期充电点。随身带一个无线充电器没有意义,无线充电器必须固定下来,所以必须固定在一个经常会把手机放下来充电的地方。
  2. 手机需要经常在充电和拿走之间切换。切换得约频发,无线充电消除的充电线插拔次数越多,价值越大。

我以前最头痛的问题是,iPhone 用了一年后电池容量衰减了,经常到下班的时候电量就少于 50%,晚上出去时要用手机很不方便,因此我白天需要注意剩余电量和及时充电。尽管我桌面有闲置的充电线,随时接上去就能充电,但接一下这个操作是有成本的,所以如果电量不是很低我就会懒得去充。换成无线充电最大的好处是把这个成本消除了,我什么时候回到座位都可以把手机放上去充电,无论我在座位停留多短暂都不觉得充电存在成本。

用这个方式去思考的话,很容易会发现在汽车导航时使用无线充电器也是合适的。这个充电点很固定,而且如果经常上下班开短途的话也算是频繁插拔。不过考虑到我还在用 iPhone 7+ 做导航(因为屏幕比 iPhone X 大),所以我暂时就不买整合无线充电的 car mount 了。

2018年2月9日星期五

活跃粉丝数

在知乎我大概能感觉到粉丝总数不如月活粉(monthly active follower)、日活粉(daily active follower)重要。在我不怎么用知乎的时候,无论我有多少粉回答后都不能得到多少赞和评论,必须要我频繁回答问题一两个月后赞和评论才能跟上来。我觉得这是因为我的粉丝中的大部分都已经不活跃,所以基数大也没有用。只有不停地吸引新粉丝,才能把月活粉、日活粉质量提上去,然后才能看到赞和评论的明显改善。

因此我觉得各大网站显示一个粉丝数其实挺没有意思的,基数大可能看起来很有面子,但其实无法转化为任何东西因为不活跃的粉丝跟僵死粉本质上毫无区别。不过要计算月活粉、日活粉需要增加网站计算负担,估计大家都不会做。

做直播估计是间接测算月活粉的最好办法,如果观众出现在直播上那一定是活跃的,就算不是直播而是帖子这些粉丝应该也会乐意交互。当然好像我这么懒的,直播能不搞就不搞,所以还要再想个办法测算月活粉。