我不是键盘狂热者,只是个人是比较偏向于全键盘操作,至于鼠标键盘之间的效率问题,某些方面讲也是仁者见仁,智者见智,推荐一下这篇文章: 使用鼠标可以提升编程效率吗? | NIL。我认为键盘操作比起鼠标操作更能容忍高延迟的屏幕显示,也认可光标到达目标控件前的移动距离确实低效。不过,即便如今键盘操作能覆盖我八九成的工作场景,鼠标操作仍作为一种手段的方法而时常故意为之。
对我而言最大的困扰其实是键鼠的切换成本。外接键盘鼠标的话,一次挪动手掌是需要肘与肩这样的大关节参与,而日常使用中整个操作序列难免要散布着几个鼠标操作,非常难受。特别是 macOS 下,默认的应用/窗口切换行为有时还是很让人迷惑,难以避免要用光标辅助一下。
当然做到全键盘操作,像聚焦(spotlight)、Alfred 这样工具是必不可少,我认为有点复杂度的生产工具都需要提供功能模糊搜索(fuzzy searching)甚至 transient.el 之类的功能。但像这样的工具配置还有快捷键相关的就不在这文章讨论之类,这篇文章主要分享我如何用键盘覆盖鼠标大部分场景下导航操作(Navigation)的经验。
很大一部分 GUI Apps 对全键盘使用并不友好,快捷键时常只能覆盖部分场景,更别谈记忆快捷键的成本了,记住的只能是日常通用操作或天天使用的生产力工具,当然像 cheatsheet 之类的辅助工具可能有所帮助。但更好的是一套一般化的导航方案,比如说遍历窗口的所有可交互元素,并为其编号,通过键盘输入记号与其交互。
vimac 就是类似的应用。它通过无障碍 API (Accessibility API),去遍历当前窗口的元素 1,采用 DFS 遍历为控件编上快捷键。触发的时候把光标移动到控件位置,并执行相应操作。
除了左键单击外还支持以下按键行为 2:
vimac 称之为 Hint 模式,默认按住空格键可以触发。另外 Hint 模式还遍历了以下区域的控件:
另外,名字有 vi
, 自然少不了 hjkl
。vimac 称之为 Scroll 模式。
Scroll 模式同样会遍历当前窗口的所有子控件,并找到所有如下元素的可滚动控件3:
然后将焦点定位到第一个 ScrollArea 4,可按 ⇥(Tab) 能切换到不同 ScrollArea
滚动是通过模拟鼠标滚轮实现的,比如 gg
, G
滚动到顶部/底部,就是执行滚动 Int16.max
个像素。
let event = CGEvent.init(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2, wheel1: yAxis, wheel2: xAxis, wheel3: 0)!
vimac 的关键功能是通过无障碍 API 实现的,vimac 只能在支持无障碍 API 的应用上生效,好在原生控件是默认支持的,大部分场景下都能起作用。一些 App 需要特殊支持:
浏览器需要单独拎出来说一说,重点当然是推荐 vimium 这扩展。vimac 相当于简化版的 vimium. vimium 的交互对象不限于控件(超链接),还能触达文本,可惜对文本的操作只有复制如果能支持右键菜单那就更好了。总之能熟悉 vi 的话用起来应该是得心应手的,按 ?
也能看到所有操作指南。
vimium 基本上能覆盖我大部分需要使用鼠标的场景。特别需要提一下的是 macOS 上 Chrome 的一个坑,当 ⌘+l
将焦点定位到地址栏后,竟没有快捷键让焦点回到页面。这个问题曾折腾了我不少时间,最终找到除了用鼠标点一下外的几种花式办法:
javascript:
⌘+f
搜索任意字符(但大概率会重置当前视口)⌘+⌥+↑
第四点对应的快捷键是, ⌘ Command
+ ⌥ Option
+ ↑
/ ↓
。其可以在地址栏、标签栏、书签、页面间切换焦点,虽然这个按键组合不太友好,但相比其他办法还是可以接受的。这是最近我整理资料才发现的,相当于 Windows/Linux 下的 F6/Ctrl+F6
, 应该是后面版本才更新的。
不过我养成用 vimium omnibar 代替地址栏操作的习惯后,就摆脱这困扰了。相信定位到地址栏的目的 80% 是复制当前地址,可以直接用 yy
搞定。如果需要编辑地址则 ge
搞定。
vimium 覆盖不了的场景,频率最高的就是对于非 iframe
的其他可滚动元素,无法避免用鼠标点一下获得焦点,hjkl
才能对该滚动区域生效。至今还不能完美解决5,还是比较难受的,具体见 Make it possible to focus a scrollable div without using the mouse · Issue #425
另外,对于一些页面如果有快捷键冲突需要禁用 vimium, 比如 gmail, youtube… 那么 vimac 也是能派上用场的。
vimium 还有不少代替品,比如 Surfingkeys. 如果要更能折腾的还有 Nyxt 浏览器 或者 EAF 可选。
macOS 上的窗口导航可是个大坑。如果把 ⌘-⇥(Tab)
当成窗口切换去用,肯定会遇到不少难以理解的迷惑行为。比如 WeChat 常常是唤不起窗口的。实际上 ⌘-⇥
切换的是应用而不是窗口,而切换应用时部分状态的窗口是不会被带起的。
这个坑的一大因素就是用户需要面对的窗口(或应用)状态繁多:
C-h
隐藏当前应用的所有窗口)切换应用时,能带起的是正常状态、全屏状态和隐藏状态下的窗口。但不一致的是 ⌘-`
只能循环切换正常状态下的窗口,或在切换界面按下 ↓
进入 App Exposé 也只能看到正常状态下的窗口。如果应用没有窗口或只有最小化的窗口也是能被切换,但切换后没有任何反应,也十分迷惑。
但当使用场景进入到多屏幕多空间,同时应用拥有多个全屏窗口或者混合全屏窗口和普通窗口。切换行为才是最让人迷惑的,我到现在也没搞懂它的逻辑,反正我在屏幕 A 的应用 A 的普通窗口 A 切换同屏幕的其他应用窗口后,就再也无法通过 ⌘-⇥
回到这个窗口,焦点会落在屏幕 C 的应用 A 的全屏窗口上。
在这里我强烈推荐使用 AltTab 代替 ⌘-⇥
,AltTab 的切换对象就是窗口,非常直观。同时支持所有状态的窗口,包括无窗口的应用,切换的行为相当于在 Dock 栏点击应用图标,会重新打开被关闭的窗口。对各种窗口状态与屏幕、空间都整理的比较清晰,可以微调过滤不要的窗口状态,同时在能在切换器界面显示窗口的状态与所在的空间。这个可能意义不大,但仍要截个图说明一下,因为我一开始也搞不清楚这些小图标的意思。
建议将 Shortcut 2 设置为 Active app 并绑定给 ⌘-`
。全面替换掉系统的默认行为 ,用了一段时间基本没有什么不适,可能有一些 Bug 但利大于弊。
另外 lwouis/alt-tab-macos 也是一个相当活跃的项目,作者还在一直保持更新。
屏幕间导航并不是指切换显示设置里的主屏幕,不过说起这个主屏幕主要是要提一下另外一个迷惑行为,一般认为 Dock 栏和 ⌘-⇥(Tab)
选择器是出现在主屏幕。实际上 Dock 栏和⌘-⇥
选择器出现的屏幕是,主屏幕沿着 Dock 栏方位继续延伸直到到达整体屏幕空间的边缘,这时所在的屏幕才是 Dock 栏和⌘-⇥
选择器出现的屏幕,更像是真正的主屏幕,这个屏幕在 AltTab 称之为 Screen including menubar。 虽然有点迷惑,但仔细想想也是合理的,毕竟 Dock 是需要边缘激活的。
重新回到主题上,那屏幕间导航是什么意思?控制屏幕焦点的主体不就是眼睛吗,切换屏幕不就是转动一下眼球或脖子的功夫?话是这样说没错,但是我认为 macOS 是有一个当前全屏的概念的,这个屏幕并不是拥有当前输入焦点的窗口所在的屏幕,AltTab 称之为 Active screen,而是当前鼠标指针所在的屏幕,AltTab 称之 Screen including mouse,姑且称为鼠标屏幕。
为什么需要定义这样一个概念,很遗憾这是 Mission Control 生效的屏幕,同时也是唤起 Spotlight 的屏幕。为了通过快捷键C-→/C-←
切换期望的屏幕的空间(Space)6,需要先切换当前的鼠标屏幕。屏幕间导航也就是指这个。
所以几年前我写了一个小工具 hopscotch, 来实现通过快捷键切换鼠标屏幕。更早之前用的是 @virushuo 的 CatchMouse 来解决这个这个痛点,不过这个工具只能通过编号切换显示器,对于这种场景还是有点别扭。更符合直觉的上一个或下一个循环切换,就趁着 swiftui 刚出来,用它撸了个这个小工具,边学边做,做的比较粗糙,也就自己用而已。
最近写这文章,就顺便把 AltTab 的活跃屏幕(Active scree)也搬运过来,实现快捷键将鼠标屏幕切换到活跃屏幕。这样,通过 ⌘-⇥
切换键盘输入焦点后,就能一个快捷键把 Mission Control 的焦点也带过来,十分符合操作逻辑。只可惜找不到不用无障碍 API 获取其它应用窗口的坐标和尺寸的办法,所以要使用这个功能只能赋予 Accessibility 权限。
用了 AltTab 后,其实 hopscotch 大部分作用场景是能被 AltTab 替代。主要区别是 Mission Control 是基于空间切换的,而 AltTab 是基于窗口切换的,目的都是为了切换到目标窗口,显然 AltTab 更为直接,但俗话说技多不压身啊,多个提供一种方法也没什么不好的。
1 见 vimac/TraverseGenericElementService.swift#L40
2 见 vimac/HintModeController.swift#L32
3 见 vimac/QueryScrollAreasService#L35
4 见 vimac/ScrollModeActiveViewController#L37
5 其实代码是有对滚动元素进行判断的,见 vimium/link_hints.js#973, vimium/scroller.js#L103
6 macOS 上的空间相当于虚拟桌面的意思,见在 Mac 上的多个空间中工作。
]]>最近尝试了下 Golang,练手 Goroutines 实现了个多连接的 http 下载工具 gotit。所谓的多连接下载,就是对支持 http-range
的资源的同时发起多个 http 请求,每个请求负责资源的不同部分,最终完成整个资源的下载。有一种情况是,下载中断后本地文件会留下多个不连续大小不一的未写入数据的空块。若重新恢复下载,如何为这些空块分配连接呢?考虑到连接数是可配置的,不应假定连接数与空块数存在相关性。一个容易想到的策略是:
举个例子,存在两个空块大小分别是10M
、5M
,可分配连接数为3
,连接数大于空块数,根据策略二,基于空块的大小按比例分配连接,可算出10M
得到2
个连接,5M
得到1
个连接。这里不讨论这样的策略是否有效,而是另外一个问题,假设连接数变为4
,继续按比例分配,10M
得到2.67
个连接5M
得到1.33
个,连接数是整数,一个连接可不能撕成 2个,肯定不能这样分配。那么多出来一个连接该给10M
还是5M
?如何分配这部分余数看似简单其实并不容易,难度在于如何找出最优解,甚至是如何定义最优解。这问题困扰了我相当长的时间,直到与比例代表制联系起来。
作为一个活到现在还没见过选票的“公民”要不是最近选举的新闻有点密集还真很难认识这个问题就是比例代表制中的整数分配问题(Apportionment)。所谓比例代表制,就是代议制民主中的两种主要选举方式之一。另外一种是多数制,也就是赢者通吃,票高者赢,可看下台湾高中的课件選舉制度的類型了解下。比例代表制常用于议会选举中,举一个粗略的例子,比如议会有 \(S\) 个席位,各党派按照获得的票数占总票数的比例分配议会的席位。把席位当作连接数,得票数当作空块大小,那么这两个问题就是等价的了。
19世纪中期,一位英国的大律师黑尔,提出一种方法。假定总投票数为 \(P\),总席位为 \(S\)。那可以简单得出获得一席所需要的票数,在这里称为数额(Quota)\(Q\)。
\[Q=\frac{P}{S}\]计票分为两轮,第一轮是各个党派根据数额和得票数取尽可能的席位,也就是得票数除以数额后取整数部分,比如党派 \(i\) 第一轮的得票数:
\[n_i=\Bigl\lfloor{\frac{P_i}{Q}}\Bigr\rfloor\]党派 \(i\) 得票数的余数也可以被计算出来:
\[R_i=P_i-{n_i}Q\]第二轮就是,根据各个党派得票数余数的大小,按从大到小顺序分配剩余席位,直到所有席位分配完成。所以这种方法也叫最大余额法。
最近被取消的香港立法会选举其中的地区直选,就是以黑尔数额的比例代表制。看一下一个实际的例子,以2012年立法会选举香港岛选区的结果为例.
表 1
编号 | 政党 | 票数 | 第一轮 | 余额 | 第二轮 | 总席位 |
---|---|---|---|---|---|---|
2 | 民主党 | 40,558 | 0 | 40,558 | 1 | 1 |
4 | 人民力量 | 18,667 | 0 | 18,667 | 0 | 0 |
5 | 民建联 | 33,901 | 0 | 33,901 | 1 | 1 |
7 | 工党 | 31,523 | 0 | 31,523 | 1 | 1 |
8 | 新民党 | 30,289 | 0 | 26,037 | 1 | 1 |
9 | 工联会 | 27,336 | 0 | 27,336 | 1 | 1 |
10 | 公民党 | 70,475 | 1 | 23,222 | 0 | 1 |
12 | 民建联 | 36,517 | 0 | 36,517 | 1 | 1 |
13 | 自由党 | 17,686 | 0 | 17,686 | 0 | 0 |
2012年香港岛选区总席位 \(S = 7\),总有效选票\(P = 330766\),根据黑尔配额公式可得 \(Q = P/S = 47252.3\) ,篇幅所限,表上省略了得票较低的名单。表上得票数大于 Q 的只有公民党,所有第一轮得票的政党只有公民党(Civic):
\(S_{Civic} =\Bigl\lfloor{P_{Civic}/Q}\Bigr\rfloor = 1\)
\(R_{Civic} = P_{Civic} - S_{Civic}*Q = 23165.7\)
计算余数后继续第二轮分配。第二轮席位余下 6 席,所以再选余额数最大的六个政党,分别是民主党、民建联、工党、新民党、工联会、民建联。至此 7 个席位分配完毕。全部计算结果可见此。
可见这种方法是简单直观且容易理解,也有人认为这种方法是美国建国之父汉密尔顿首先提出的,所以在美国也有叫汉密尔顿法(Hamilton’s Method of Apportionment)。然而这种看似简单直观似乎又公平的分配方法,并没有直觉认为的那么公平,这个后面再说。
黑尔数额并不是最大余额法的唯一方法,不过这些方法的区别是在数额的计算,而第二轮余数分配的机制是一样的,所有这一类的分配方法都统称为最大余额法。
同样是19世纪中期,同样是英国大律师特罗普,认为获取一个席位的票数也就是数额,并不是如黑尔所说的,而是:
\[Q=\frac{P}{S+1}\]他是的理由如下,竞争两个席位的情况,得票数超过 \(1/3\) 的必将赢得一席,如果是三个席位,那么得票数超过 \(1/4\) 将赢得一席。通过归纳法可得出,争夺 \(S\) 个席位的选举中,得票数超过 \(P/(S+1)\)的党派都将赢得一席。所以特罗普对数额 \(Q\)的定位就是,获得一席所需要的最低票数。
可以观察到,相比黑尔数额,特罗普数额的分母变大了,数额就变小了,也就是说获取一个席位的代价变低了。似乎对小党派更有利了。结合另外一种数额更低的,设计本意是为了偏袒小党派的因佩里亚利(Imperiali)数额
\[Q=\frac{P}{S+2}\]来看看是否如此:
政党 | 得票数 | 黑尔 | 特罗普 | 因佩里亚利 |
---|---|---|---|---|
A | 30 | 64 | 64 | 65 |
B | 12 | 25 | 26 | 25 |
C | 5 | 11 | 10 | 10 |
计算可看此。
在采用特罗普法或因佩里亚利法后,得票最少的 C 一席反而分别被 B 和 A 抢去。实践中也发现更小的数额反而更有利于得票多的大党派,这是因为更小的数额,导致第二轮剩下的席位变少了,小党派主要靠第二轮来取得席位,席位变少相应的获得席位的概率也就降低。
上面提到最大余额法并没有直觉上公平,其一便是不同数额的可以微调席位的分配。其二就是会出现非常反直觉的不公平结果。
美国为各州分配众议院席位的方法也是使用比例代表制分配。州相当于候选名单,州的人口就相当于选票。虽然现行的宪法规定众议院的席位固定为 435 席,但 20 世纪前众议院的席位并不是固定 435 席而是随人口的增长而增加。1880 的人口普查发现,总人口不变的情况下,众议院有 299 席位时,阿拉巴马州获得 8 个席位,当总席位增加到 300 时,阿拉巴马州反而失去一席剩下 7 席。制造一个例子很简单:
政党 | 得票数 | 4 席 | 5 席 |
---|---|---|---|
A | 5 | 2 | 3 |
B | 3 | 1 | 2 |
C | 1 | 1 | 0 |
把场景换成议会的不公平就非常显而易见了,议会总席位增加一席,投票保持不变的情况下某个政党却会因此失去一席。这种现象便称为阿拉巴马悖论(Alabama Paradox)。这就是最大余额法不可避免的问题。
阿拉巴马悖论,只存在理论计算中,现实中并未未发生,并不是阿拉巴马悖论不易发生,实际上预期每 8 次分配就会发生一次,而现实没发生原因是众议院不再采用最大余额法分配席位,实际上还有个前提就是要现实中两次投票的比例保持不变这概率也是几乎不可能😜。
另外一个悖论就曾在现实出现,1900 年弗吉尼亚得到 10 席缅因州得到 3 席。而 1901 年,这一年尽管当年弗吉尼亚的人口增加速度大于缅因州,反而被缅因州抢去一席。弗吉尼亚 9 席缅因州 4 席,见:1901年美国众议院选举-维基百科。这就是所谓的人口悖论(Population paradox),两个州的人口发生增长,增长率快州反而输给增长率慢的州一席。
回头重新看下2012年立法会区域直选香港岛区的结果(见表 1),可以发现民建联(DBA)出现了两次。为什么要分两个名单参选呢,假设一下如果合并为一个名单会怎么样?演算一下便可以发现,最终民建联只能得 1 个席位,而民主派的公民党反能获得 2 票。
而民建联最终选择了分拆名单,避免了这种情况发生,这种行为就是配票,而且是相当成功的配票例子。但配票也需要对选票分配的预测相当有信心才能执行,切忌不自量力,历史上香港立法会、台湾立法委配票失败的例子也不少见。
最大余额法,出现余数相等的情况怎么办?构造这个情况并不难,假设有 n 个候选人竞选 S 个席位,\(V_i\) 为候选人 i 的票数,\(V_i=a_i*Q+C\) ,Q 为黑尔数额,可得出 \(S=\frac{nC}{Q}+\sum\limits_{i=1}^{n}a_i\),构造任意 a、n、C、Q 满足以下条件便可:
\[\begin{align*} & C < Q \\ & \frac{nC}{Q}\in{\mathbb{Z}} \\ & a_i\in{\mathbb{Z}} \\ \end{align*}\]见此例子,出现这种情况只能更换数额算法,或不用最大余额法了。当然现实中是几乎不可能出现这种情况的。
另外一种与最大余额法相对于的就是最高均数法,而且最高均数法已被证明能避免最大余额法遇到的悖论。最高均数法取得席位的方式相当于依次对每个席位进行竞标,只是价格不是绝对而是相对,而且价格依取得的席位数增加而递减。以下表为例,三个政党竞选 4 个席位。
政党 | 得票数 | 第一轮 | 第二轮 | 第三轮 | 第四轮 | 总席位 |
---|---|---|---|---|---|---|
A | 80 | 80 | 40 | 26.67 | 26.67 | 3 |
B | 32 | 32 | 32 | 32 | 16 | 1 |
C | 14 | 14 | 14 | 14 | 14 | 0 |
第一轮 A 票数最高,A 得 1 席,A 的票数更新为 \(\frac{V_A}{2}\),第二轮还是 A 最高,A 获得第二席,所以 A 票数更新为 \(\frac{V_A}{3}\),第三轮 B 票数最高,B 获得第一席,B 票数更新为 \(\frac{V_B}{2}\),依次类推,直到所有席位分配完成。
可以看出,获得第 n 席的剩余票数为 \(\frac{V}{n+1}\)。换一句话说也就是,A 能获得 n 个席位,是因为其票数平均 n 分的均数最大,所以叫最高均数法。这是一个比较接近实际的例子,数据来自 2004 年的印度大选。
最高均数法也叫除数法,政党获得第 n 席的剩余票数等于得票数除以一个除数序列的第 n 项,这个序列可以一般化为 \(f(n)\),\(f(n)=n+1\) 时,称为洪德法(d’Hondt)。其他不同的最高均数法,也就是 \(f(n)\) 的取值不同而已。
const dHondt = (s) => s+1 // 洪德法
const sLague = (s) => s*2+1 // 聖拉古法(Sainte-Laguë)
const imperiali = (s) => (s+2)/2 // 因佩里亚利(Imperiali)
const modSLague = (s) => s==0?1:(2*s+1)*5/7 // 改良圣拉古法
不同的方法,最大的影响是\(f(n)\)的增长率,增长率越快越有利于小党派,相反则的有利大党派。大多方法都是一次函数,所以系数越大就越有利于小党派。除了亨廷顿-希尔法(Huntington–Hill method):
\[f(n)=\sqrt{n(n+1)}\]Huntington–Hill 的增长率其实是非常接近于 d’Hondt 的。它也是美国现行为各州分配众议院席位所用的方法。
最高均数法可以避免阿拉巴马悖论和人口悖论,基本上也是现行比例代表制选举中最常用的方法,但它也不是完美的。重新回过头来看这个问题,总投票数为 \(P\),总席位为 \(S\)。政党 i 的得票为 \(V_i\)。在一个理想的世界里政党 i 的席位为:
\[A_i=S\frac{V_i}{P}\]我们知道绝大多数情况下 \(A_i\) 是个小数,这个数称为 i 的自然数额(nature quota)。应用分配方法的目的就是对自然数额进行取整,而取整无外乎是向下取整或向上取整。
这个就是数额法则的定义,分配给一方的席位数量应该为其自然数额的向上取整整数或向下取整整数。能称为法则想必是十分重要,但看似完美的最高均数法就违反了这个法则。
实际上 Balinski & Young 已经证明了不违反数额法则同时又能避免悖论的分配方法是不可能的。也就是说整数分摊实际上没有严格意义上的公平解法。那么回最开始遇到的问题,寻找分配连接的最优解,如此绞尽脑汁也就情有可原了。不过公平不可能,最优解却是可能的。只需定义什么是最优,比如说实际席数与自然数额之间的距离最小便是最优(可见参考资料1、2),因为席位是整数搜索空间有限,只需遍历所有可能便可以找到最优,如果满足数额法则那么搜索的空间便更小了。
正因为绝对公平的不可能,国家政策的制定者就可以通过调整这些方法让政策偏向大多数或者少数,比如说加大最大余额法的数额,或者最高均数法选择增长率较快的除数序列,来让少数派的得到发声机会。当然选择相反的策略,也能说成是阻止极端主义的发展。好像政府怎么做都是对的,不过阻止极端主义这方面还真有反例,魏玛共和国的帝国议会让纳粹崛起就是一个鲜明的反例。
20 号夜 6 点半从汕头市区出发夜骑凤凰山。19:17 到达樟林,335 省道莲华段的路况不是太好,路窄大车多,水泥路面多处破裂,50左右速度过一处不平路面时,这次颠簸感觉到前轮有被『蛇咬』的可能性,一路有点担心爆胎。到意东镇后,想到接下来要离开市区,靠边停车检查一下车况,发现前轮胎压不足。开始有点担心是不是破胎泄气,不过想到前轮上次打气不知道是什么时候,起码得一年以上,说不定买车就没打过(😎)。还是自然泄气的可能性大一点。刚好前方不远意东三路和北桥路红灯路口有一家新本店,便跑去打了气,店主人不错也没收钱,检查了下没什么问题就继续出发了,准备开一段再看看胎压,最坏情况就在凤凰山住宿。摩托车胎没有公路车胎那么矫情(事实证明磕了一下没影响,车在山上停了一夜气还是饱的)
过了文祠镇就是山路了,山路基本没有路灯,一路远光,会车自觉关灯,但迎面来的汽车能做到自觉关灯不到三成,痛恨不已,遇到不关灯,为了避免会车后的短暂失明,只能低头减速。有一处一边路面修路封掉,迎面来的汽车还不关灯,简直是谋杀,幸好速度足够低才能避免这些危险。不过这段路还是得花多白天一倍的时间。
大概 9 点到凤凰镇,在 201 乡道停留休息了一下,开始走老路上山。晚上走老路还是很有挑战性的,而且快到凤溪水库那边,摩托车开始出现异响(往右压弯会出现),虽然大概知道是链条太松了,不过听着还是很紧张。爬升高一点开始有雾,当晚本来有月的(八月十一),不过在云里开车,能见度很差,有几次拐弯,以为弯道要完了结果还没有,只能急忙刹车减速。还有一次以为车辆要失控了,还用脚踩一下地面保持平衡。后面就不敢分神和后面那位叨叨,精神高度集中,老路两边有不少房子,山上人家大多早早休息。到了乌崬村左拐往天池方向的时候,摩托车刚好开到云上面,豁然开朗,满天繁星,月光下周围是望不到边际的云海,不远处的山峰耸立在云海上方,夜幕下显得壮观又可怖。因为时间太晚,又快到顶了,便没有停车拍照,实在可惜,10点左右到天池入口停车场时刚停好车云雾又来了。
准备收拾东西爬天池的时候,发现居然忘了带水袋,列好了清单事无巨细,一件件打钩就是忘了把水袋放进书包里,10 点的停车场售票处都已没有人,绕了一圈找不到商铺或者装水的容器。有点不知所措,既然没带水袋,那就灵活变通将错就错。解决晚餐再上去。直接在景区门口铺上防潮垫,席地而坐。地面平,离公厕近,接水洗锅都方便。
吃完刷了牙,带上头灯继续上路,售票处没有人,门还留了条通道,直接就逃票上去了。其实就算门没关还是可以从售票处后边绕过去的。这里到天池大概是 1200m,在800m、200m 处都有路牌提示。全程石梯和围栏,雾非常浓,大功率头灯也照不清2米外,慢慢走十几二十分钟就能到天池,总的来说不算陡,地图上看爬升大概 130m。
爬上去就看到雾里有亮光,随着灯光走过去,原来是另一伙露营的人。几男几女,两个帐篷。他们在走廊下露营,打了下招呼,就继续沿着走廊走。就在过了亭后的平地,抵着两个石头的地方搭起帐篷。因为雾太重不敢乱走,人也太累,心念念的薯片都没吃就躺下了。这个帐篷位置选的实在不好,离那伙人太近,他们一晚上都在聊天,在天池里抓鱼还大声嚷嚷。4 点多的时候又有一伙人在亭里休息喝茶聊天。基本一夜都没睡好。不过,幸好没睡好。3点出头的时候,听到他们沸腾起来,喊着看星星。
打开帐篷一看,雾都散开了。满天繁星,无一点云,实在美妙。还看到几颗流星。连忙叫起同伴,起来看星星,拍几张纪念照,总算不枉此行。没带脚架很难找角度,军事中心的灯光污染了一部分天区。时间有限,准备不足所以照片都是纪念照。没多久,雾又来了。安心躺下休息了。
5点多起来看了下又都是雾,实在太困,就不去碰运气看日出了。云海和雾真的是一线之间。这个时间段没有人来,只有远处几个摘茶工聊天的声音,终于能安安静静睡一觉了。 不过到七点多睡不下去了,就起来刷牙吃早餐。原来天池也是有厕所的,就在露营点走一段楼梯就到了。而且前面就是一大片草地,早上雾散了终于看清楚,昨晚这个营点选的实在不好。吃完早餐吃零食喝茶聊天。9点出头就收拾东西,到处逛逛。
回程准备去探探西北方向的路。顺便去那边的天子洞之类的景点。9点多的太阳实在是毒辣,沿着天池边走一段小路,左手边还有两个池塘,经过两个亭,在军事雷达下方的那个亭休息了会,看似路程还很远,似乎与回程的路线不通,太阳又毒辣,便决定往回走,到蘑菇亭方向的小路往上走,便是西北方向下山的路。
路是普通的山路,碎石比较多。走到尽头出大路的时候被一个铁门锁住了,两边还围了铁丝网。不过这个铁门轻易就翻过去。大路也是山土路,能通车。全程遇到过两辆车,不过长了很多,得两倍景区路线的距离,地图上测距大概 2 公里,不过还算好走,11点前顺利达到停车场。快点停车场的时候我以为出口的路也被封了,吓死了。便放下行李和同伴先去探探路,幸好是看错了,将错就错直接开摩托回来接同伴。
下山的时候又想碰碰运气,便走了新路,新路有路牌写明不允许摩托车行驶,酒店那里有设卡,不过大中午没有人,而且还留有一个摩托车可过的通道,我就不管直接通过了。快到凤凰镇的时候也有一个卡口,不过同样留有一个摩托车可通过的卡口,还有保安。自然镇定慢速通过,没有什么事,最后顺利到达凤凰镇,走新路下山果然快很多,慢速安全驾驶也只需半小时不到。白天开车也快很多。11点从天池停车场下来,1 点就到樟林。结束了19个小时的凤凰山之旅。
写一篇流水账记录,给自己以后也给有需要的朋友一些参考,客观上也透露了一些逃票的参考信息,并不是我鼓励逃票,如果可以还请花钱买票支持当地的旅游业和环境保护。不过对于中国景区的私人经营、收费过高、商业化运作过重的问题,也是深痛恶绝(并非指天池),所以对于逃票行为也是只能不予置评了。
]]>The boxes on a PowerPoint diagram are not a software system’s architecture.
近来读了 Uncle Bob 的 《清晰架构》(Clean Architecture),副标题叫『一个工匠的软件结构与设计指南』,自称为工匠的 Uncle Bob 全名叫 Robert C. Martin,是一位有 50 多年经验的软件工程师和作者。他是 SOLID 原则的命名者,同时也是《敏捷软件开发》、《代码整洁之道》的作者。《清晰架构》是 17 年出版的书也算是今年读的比较新的书了。全书分 35 章,近 400 页,虽说也不算特别多,不过啃下英文版也颇为吃力。本书听起来像是专业软件架构论述,其实更像是作者的杂谈,不少内容都可以在作者过去的博客找到。其中的软件开发哲学的故事让我很感兴趣。
那么什么是架构呢?作为一个常常把架构挂在嘴边的人,要给架构下个定义还真不容易。书中 Uncle Bob 给了一个很棒的定义:
软件架构是指,设计软件的人为软件赋予的形状,这个形状是指系统如何被划分为组件(Components),各个组件如何排列(Arrangement),组件之间如何沟通(Communication)。
搞笑搞软工用了公司的组织架构来类比,我觉得非常形象:一个中型公司有董事会、董事长、董事会、总经理、部门、组等,以及各部门里面的员工,由此形成组织架构图。可以看出所有的架构一定有三个东西:
除了组织架构外,公司还有他的业务,业务与架构是决定一家公司的两个维度,两者看似正交,实际当我们看到一家公司的架构图,我们大概也能猜出一星半点公司的业务,法务部或行政部可能看不出什么,但移动开发部很明显喊着公司有着移动开发的业务(Screaming Architecture)。往往业务的发展会导致架构的演化,架构的演化反过来促进业务的发展。
软件系统也是同样的道理,软件有两个量(Value):
功能衡量系统能不能满足需求?架构意味着系统是否易于变化。功能架构孰重要?作者在书中用了控制变量法来说明这个问题,假设有两个系统:
大部分人直觉上会选择那个马上就能跑起来的系统。但哪个系统更没用呢?第一个系统虽然能用,但是一旦需求变化了便不能用了,可能有点比较难想象,换句话说是修改系统的成本高于修改系统的收益,因为他非常难以更改。第二个系统虽然不能满足业务需要,但是它可以持续改善并最终变得有用。
若说架构比功能还有价值,又不合常理,一个精雕细琢的没有价值的软件,没有金主会为它埋单哪能诞生?除非是『软件艺术家』的自发行为。对一家软件公司来说,软件是其产品,产品要带来收益,所以功能仍是软件的第一价值,功能是软件能产生价值的部分。关系到我们的金主能不能赚到钱。架构退而次之是软件的第二价值,影响到软件的生命周期中的各种成本,间接地影响金主的收益。两者在软件不同的生命周期发挥着不同的作用,功能更多的是其紧急且重要部分,架构更多的是重要非紧急部分。
关于架构的意义:
为了能够最小化创建和维护软件的成本。
真实的软件开发是一个挣扎(Struggle)的过程,程序员会想着多快好省实现需求解决 Bug,产品会在软件的开发阶段多次变更需求,市场会卡着上线时间。倘若把功能是否实现作为标准,理性又短视的程序员会最小化自己的投入,只顾着实现软件的功能和应付变更,忽视任何架构方面的问题,一个不好的架构下各种混乱的依赖下的相互作用其维护成本可能是指数增长的,最终项目全变成意大利面条式的代码(Spaghetti code),挖坑的人可能已经换了一批又一批,而这些技术债务的主体并不是欠下债务的程序员,而是软件的所有人。一个本应为公司带来技术积累的项目,反而成为公司的历史包袱。
可能有勇士在某个时间点(维护成本不断增加直到高于重构成本的那个拐点)会提出重构可以解决问题。但重构只是可以解决一部分问题,可能还会引入新的问题。对于这种情况,作者这样说:如果最后才考虑架构,那么系统的开发成本会更高,并且最终几乎不可能成为系统的一部分。如果允许这种情况发生,则意味着软件开发团队没有做足够的努力。我们站在 PM 的角度看,他只关心需求,他们不懂也没有能力评估架构的重要性,因为这对他们来说是无关的细节。所以架构师必须要责任去强调架构的重要性,在开发过程中争取足够的时间进行架构设计。
有一点是确定,架构设计和单元测试确实会使项目的开发变慢,而债务也不一定是坏事,在一些时间点欠点技术债是可以获得更好的投资回报率。这一点在现实生活中可以找到很多应用,比如贷款买房,炒股加杠杆。在资源有限的情况下,功能和设计确实是一对矛盾,架构师的职责换一种表述就是利用其专业知识为功能和设计分配资源以获得最佳的回报率。
对于架构师的职责作者这样说,设计良好的架构,使系统易于理解、开发、维护和部署。最终目标是最大限度地降低系统的生命周期成本并最大限度地提高程序员的生产力。
Change is the only constant.
如果说软件开发中有不变的真理,那就是变化不可避免。为了应付变化。软件工程师总结出许多原则,比如封装变化,针对接口编程,依赖抽象,最少知识原则等等。
记得很久以前开始思考软件的设计的时候,最常犯的错误就是,为了减少代码量而设计,特别是滥用继承,导致代码僵化重构后面对变化更加痛苦,如果当时了解了种种原则,生活肯定容易许多。
本书用了三分之一的篇幅来讲这些原则。包括用于指导如何设计类的 SOLID 原则,和指导如何设计组件的六个原则。这里类不单单是面向对象的概念,而是更加泛化地指一组函数和数据,也称为模块(module)。 而组件指的是,指的是独立的可部署单元,是模块的集合。比如 java 中的 jar,ruby 的 gem。
SOLID 原则指导类的设计应该容忍变化、易于理解。组件原则指导组件的内聚和组件间的耦合,其实也是 java 的分包原则。
组件内聚:
组件耦合:
篇幅所限就不进行解释了,作者还介绍了两个度量组件稳定性和抽象性的量:
作者还聊了编程范式的发展,他的观点也挺有意思:编程的发展,是一个添加限制的过程。三种范式都从我们身上夺走了一些东西。每一种都限制了我们编写代码的方式。
面向对象最核心的就是增强了多态,引入多态后,高级函数依赖于低级函数的接口,而不是具体实现。把对具体实现的依赖分离出来。低级的实现,可以被独立部署更替。这就是依赖反转。对架构师来说提供了使用多态性来获得对系统中每个源代码依赖关系的绝对控制的能力。
另外,Dijkstra 《Go To Statement Considered Harmful》的故事也值得了解一下。
Every problem in computer science can be solved with a layer of indirection (besides the problem of too many layers of indirection)
清晰架构,在 2011 年作者的博客就有提到这个名词 Clean Architecture,另一篇引用比较多的博文 The Clean Architecture 写于 12 年,内容所讲的就是本书的清晰架构。
所以,清晰架构不是什么新东西,他是六边形架构(Hexagonal Architecture)的一个变体,基于以往多种架构的发展和归纳,提出来的一个新架构模型。
传统的分层架构是垂直形式的层次架构,清晰架构是由外往内的圆环状层次架构:
这样架构有几个特点:
核心的实体指的就是领域内的关键业务逻辑,最不可能发生变化的部分,比如苹果总是往下掉;保险总有承保人、投保人、被保险人、受益人;还贷的等额本息、等额本金算法。关键业务逻辑应该是系统中最独立和可重用的代码。
第二层是用例,用例是应用程序的业务逻辑。用例关注与输入数据并产生输出数据,但用例对象不应去关心数据如何传递给用户或其他任何组件。
接着是接口适配层,将实体和用例转化为更适用外层使用的形式。将外部数据转化为用例或实体使用的内部形式。这一层最强大的工具就是依赖倒置,以 MVP 为例,更新界面的时候,控制流的方向 Presenter -> UI,而依赖关系却是 UI 依赖于 Presenter 层(实现了 Presenter 层相应的 View 接口)
UI 在外层这个理所当然了,名字本身就说明了它直接和用户打交道,而且 UI 是掌控在设计和产品手里,越不能控制的东西越要小心其变化。`
传统的分层结构,数据库总是处于最底层,但其中数据才是关键,数据库不是。关系型数据库只不过是把数据按 b+ 树这样的结构存储起来,然后通过 SQL 来操作数据。我们也可以使用文件存储数据,甚至可以脱离硬盘直接在内存里面用数组、链表、树、图等数据结构组织我们的数据,毕竟内存价格越来越便宜了(五年一个取样,就可以无视近几年 PC 内存的涨幅了😆)。其中关键的点就是推迟决定,我们很难在初期对我们的数据使用场景做个准确的预测,数据库作为细节隔离在核心系统外,以后因为性能规模等等问题变动数据库更加容易。
但框架呢?框架总是鼓励我们和应用程序紧密的耦合在一起,它提供一种模式,只要我们服从这个模式就能获得极大的便利,代价就是这个框架和我们应用的各个层次紧密地耦合在一起。这有什么风险?其一,框架也许可以帮助您完成应用程序的一些早期功能。 但是随着产品的成熟,它可能会超出框架的功能。其二,框架可能会朝着你没有帮助的方向发展,你甚至可能会发现 API 消失、改变了,因为控制权在作者而不是在你手上。其三就是总有新框架出来,你可能希望切换到更新更好的框架。
最近一个朋友维护老项目遇到一个 android-async-http 的问题向我求助,android-async-http!!!这个不是我一开始做 Android 项目用的网络库吗?起码也是 11、12 年的事了。虽然是个不错的库但也不维护几年了吧。遇到底层的 Bug 别提有多棘手了,而且维护的价值已不大,投入太多时间又觉得亏,最终只能用一些额外的措施把它掩盖过去。
但分离框架,谈何容易呢。这几年写的 App,数据层也从手写 Dao 到 greenDao 到 DBFlow 再到现在的 Room。Room 虽然有官方加持,但以谷歌的尿性被弃用也是随时的事。如果引入 Room,最内层的 Entity 就得使用 Room 的注解便会对 Room 的依赖。如果要分离呢,就需要在数据层也定义一套 Entity,层层之间的数据传递还需要做转换,把核心的 Entity 转换为数据层的 Entity,这样做就很清晰架构了1,但是有必要吗?
架构的矛盾在于,我们不应该预见抽象的必要性。知道 YAGNI(You aren’t gonna need it)吗?这是个饱含智慧的理念:“你不需要它。”。因为过度工程往往比不够工程化更糟糕。典型的移动应用程序与典型的企业应用程序有很大不同。它要小得多,并且通常需要快速交付。记住架构是演化的,不能简单地在项目的开始时决定实施哪些边界以及忽略哪些边界。相反,随着系统的发展,边界会慢慢变的清晰,你会关注到它,再权衡实施和忽略的成本,在实施成本低于忽略成本的拐点时做出决定。
不能手里有个锤子看到什么都是钉子。就像当初学设计模式一样,学了之后又要让你忘掉它。
Android-CleanArchitecture 就是这样的设计,但在 Kotlin 版已经不用了。 ↩
网站在 18 年元旦终于重做的差不多了,五年前买的主机在去年 12 月过期了,促使我终于将拖了一年多的 Jekyll 迁移计划落地。Ruhoh 从 14 年就再没有更新,虽然用起来没什么大问题,但总有一些小毛病,比如生成速度过慢(Jekyll 也不快,15 款高配 MBP 全站生成都大概要 10秒),实时预览的功能也没有 Jekyll 的增长式构建那么强大。作为一个小众的静态博客生成系统基本也没什么生态,迁移到主流的 Jekyll 是迟早的事,只不过我对 Ruhoh 做了不少定制,迁移要在 Jekyll 这边重新实现一遍挺费时间的。
最大的问题就是笔记系统,Jekyll 和 Ruhoh 都支持页面资源的归类和管理,Jekyll 中叫 Collection,Ruhoh 叫 Resource。Jekyll 的理念是所有 Collection 生而平等,但有些 Collection 更平等。比如其他 Collection 就不像 Posts 那样自带标签汇总。Ruhoh 的 Resource 才是真平等,通过插件扩展的能力也更强大当然也更繁琐。之前的笔记和日记就是通过自定义 Resource 实现的。
用 Jekyll 实现笔记系统我没有对 Collection 进行扩展,通过配置来实现部分功能,舍弃了父类别也能生成页面的功能。笔记的树结构 json 通过 generator 插件实现。标签归纳也得自己实现。固定连接反而是最简单的,一条配置搞定:
scope:
type: "notes"
values:
layout: "note"
permalink: "/:collection/:path/"
另外还有一些自定义插件,比如 LaTeX, Kramdown
是默认支持的。至于 graphviz,我发现 Kramdown 的定制比 Redcarpet 麻烦。最后还是决定用自定义 liquid tags 实现。
{% graphviz %}
digraph G {
subgraph cluster_0 {
....
start [shape=Mdiamond];
end [shape=Msquare];
}
{% endgraphviz %}
还有一个就是迁移脚本,首先将文章的文件名转换为符合 Jekyll 规则。 Ruhoh 用的模板语言是 Mustache,眼看 Mustache 被微信小程序选用了有机会火了,又被我放弃了…… Markdown 引擎是 Redcarpet,Jekyll 则是 Liquid 和 Kramdown,所以还需对内文做一些转换和过滤。还是最重要的一点是将页面引用的本地资源进行整理。
对页面资源进行管理是由来已久的想法。要求每篇文章(Document)的都有一个独立的资源目录,然后目录内的文件都可以在正文内通过相对路径访问。这样就不用再将所有资源都挤在 media 文件夹,还得用一个`` 来获取 media 的相对路径。实现是通过一个 generator 将资源拷贝到相应路径实现的,最终的目录结构如下:
_res
├── notes
│ ├── Programming
│ │ ├── Android
│ │ │ └── loader
│ │ │ ├── Loader.png
│ │ │ └── loader_event.png
│ ├── Reading
│ │ └── computer-systems-a-programmer-s-perspective
│ │ └── CSAPP
│ │ ├── CSAPP-5.10&5.11.png
│ │ ├── CSAPP-5.5&5.6.png
│ │ ├── address_translation.png
│ │ └── process_address_space.png
└── posts
├── 2013-08-16-the-pain-of-note
│ └── note_system_review.js
├── 2013-10-22-the-pain-of-note-2
│ └── categories.org
├── 2016-12-24-hierarchy-fragment-pager-adapter
│ ├── b_aa_ba_ca.png
│ ├── b_ba_baa_baaa.png
│ └── b_ba_ca.png
├── 2017-10-20-lambda-in-android
│ └── desugar_diagram.png
Jekyll 是没有对资源进行操作的命令行接口的,这点还是 Ruhoh 做得比较好,可以对每个 Resource/Collection 的命令行接口进行定制。
Jekyll 要可以通过 Commands 插件实现命令行接口。见 jekyll-moon,目前扩展了 create
命令,实现了创建 post 和 note。接下来还需要实现创建资源目录、内文搜索,list 等。
后台的事说得差不多,接下来说说前台。主题和网站结构重新做了设计。基于 Materialize 主题也是像素级照搬。不过也算实现了一直很想做的 MD 设计。
用了 ES6 和 Sass,Jekyll 默认支持 Sass 处理器。至于 Es6 用的是 jekyll-babel,babel-source(5.8.35) 快两年没更新了,看来 Rubier 还是喜好 coffee-script 多一些。
网站最大的突破是终于有了能用的搜索,基于 Lunr 实现的浏览器端的搜索。目前只对标题和标签进行索引。
Lunr 默认不支持中文,它的建立索引的过程是先通过分词器进行处理,tokenizer.js
再对每个 token,通过 pipeline 进行处理,默认有三个 pipeline:
builder.pipeline.add(
lunr.trimmer, // 过来所以非 \W 字符,中文会在这里被过滤掉
lunr.stopWordFilter, // 过滤停止词
lunr.stemmer // 返回词干
)
考虑到中文分词的复杂性,所以我将分词放在 Ruby 端处理,找了一遍没有找到特别合适的分词器,主要是不想有 native extension,在 travis-ci 上部署不方便。所以目前只是单纯将中文与英文分隔开而已,见 index_generator.rb。要搜索中文最好还是得手动加上通配符,Lunr 的搜索用法可参考 Searching : Lunr
GitHub 虽然支持 Jekyll,但我加了不少 Jekyll 插件,想直接用 GitHub 来部署是不可能的。不过也不是没有办法的,比如用免费的持续集成服务 travis-ci.org 来实现持续部署,Travis 支持部署到 GitHub Page,几行配置搞定:
deploy:
provider: pages
skip_cleanup: true
github_token: $GITHUB_TOKEN # Set in travis-ci.org dashboard
local_dir: ${TRAVIS_BUILD_DIR}/_site
target_branch: master
on:
branch: develop
Jekyll 的持续集成可参考官方文档 Travis CI | jekyll
到这里基本实现了 push
后自动构建并部署到 https://douo.github.com 由 GitHub 来免费托管,也可以绑定自己的域名2,一切看起来都很美好。但要为自己的域名添加免费 HTTPS 支持就没那么容易。幸好 Netlify 恰能提供这样的服务,并支持绑定免费的 Let’s Encrypt - Free SSL/TLS Certificates 证书,具体可参考:GitHub Page 博客自定义域名添加 HTTPS 支持。
lambda 表达式是 java 8 新引入的语言特性,使用了通过 java 7 新引入的字节码指令 invokedynamic 来实现的(参考 Goetz-jvmls-lambda.pdf)。但在 dalvik 中并没有相应的指令,所以直接将 java 8 的字节码翻译为 dalvik 字节码目前是是不可行的。不过从 java lambda 的实现上来讲,实际上就是内部匿名类的语法糖。
既然是语法糖,那就是一个代码转换的事,把这个过程抽离出来另外实现,就可以在低版本的 jdk 中实现对 lambda 的支持。retrolambda,就是在字节码层面实现这个转换。retrolambda 的具体实现是基于 java 8 对 lambda 的底层实现来做的。在编译时,java 主要为当前类(lambda 表达式所在的类)生成一个方法,方法体(method body)就是 lambda body,这个方法称为 desugar 方法。运行时,第一次执行到这条 lambda 语句的时候,invokedynamic 调用引导方法(BSM),引导方法生成一个实现了具体函数式接口(Functional Interface,只有一个抽象方法的接口)的 VM 匿名类,这个类主要用于捕获 lambda 所需要的变量。第二步,把这个对象的构造函数和 invokdynamic 绑定起来,最后调用这个构造函数返回这个匿名类的实例,也就是所谓的 lambda object(以后再执行这条 invokedynamic 指令就是直接调用构造函数返回实例了)。调用的时候,再把接口方法需要的参数和捕获的变量传递给 desugar 方法来完成 lambda 的应用(可参考理解 invokedynamic)。
retrolambda 的做法是,源文件先用 java 8 编译,lambda body 转换为当前类的 desugar 方法编译器已经处理好了。接着解析编译后的 class 文件,遇到一条 invokedynamic 指令,就模仿它调用它的引导方法(LambdaReifier.reifyLambdaClass),把引导方法生成的匿名类作为当前类的匿名类保存下来,接下来还会对这些类再做一些变换,包括用单例优化无状态的 lambda 对象,将构造函数替换为工厂方法(BackportLambdaClass#visitEnd)。最后把 invokedynamic 替换为对该匿名类的实例化语句,就是这样把 invokedynamic 替换为等价的兼容代码。不过, retrolambda 的实现依赖于 java 对 lambda 的具体实现,后续的 java 版本不用匿名类了,那么 retrolambda 也就不能用了。
在 Android Studio 3.0 之前,要在基于 java 的 Android 开发中使用 lambda 表达一般都是用 retrolambda 来转换为 dex 能处理的字节码来实现的(就不提夭折的 Jack 了)。 不过 Android Studio 3.0 后,IDE 已经支持实现这个转换了,简称 desugar。具体如何开启可参看官方文档:Use Java 8 language features。IDE 的 desugar 过程比 retrolamda 的主要区别就是时机不同,原理上大致是一样的,IDE 的实现可见 LambdaDesugaring#visitInvokeDynamicInsn。 retrolambda 只能对当前项目进行转换,IDE 是在转换为 dex 之前做的转换,也就是说 IDE 还支持第三方用 java 8 编译的库。
原图见 Build Workflow - Android Studio Project Site
总之,Android 对 lambda 的实现与 java 8 并未太大区别,最主要的区别 java 8 的匿名类在运行时生成,而 Android 是在编译时生成(这样还可以避免了对 serializable lambda 的特殊对待)。
lambda 表达式在 java 中就是用于创建函数式接口实例(lambda object)的表达式,lambda 的实际使用中,主要将其分为两种类型,其一,无状态的(stateless) lambda 表达式,指的就是没有自由变量的 lambda 表达式。相对的,另一类就是有自由变量的 lambda 表达式。
什么是自由变量,把一道 lambda 表达式从其上下文抽离出来看一下:L1 = s -> Integer.valueOf(s)
。表达式中的两个量 Integer 和 s,Integer 是常量,而 s 在参数列表中声明了(类型省略),这里称 s 是一个绑定变量,所有量都是确定的,所以 L1 就是无状态的 lambda 表达式(可以认为它的调用不会产生任何副作用)。
另外一个例子:() -> System.out.println(Arrays.toString(args))
。args
是什么?脱离了上下文就无法确定了,如果在上下文中看,就很清楚 args
是什么了:
public static void main(String[] args) {
Runnable r = () -> System.out.println(Arrays.toString(args));
r.run();
}
args
在这里就是自由变量。要对 lambda 表达式求值前所有自由变量都是得已知的,java 中所有自由变量都必须在编译期确认(另外一种不同的实现可参考 Groovy),为自由变量确定值的过程称为变量捕获(capturing),把变量捕获后和 lambda 表达式绑定在一起的结构就是闭包(closure),lambda 对象实例就是一个闭包。java 中就是通过匿名类来存放这些捕获这些变量,而且是以 final 引用的形式,所以更应该说是值而不是变量。
先看一下最简单的无状态 lambda:
public class LambdaTest {
public void testStateless() {
Runnable r = (() -> System.out.println("pure"));
r.run();
}
}
编译后再反编译,可以看到,变成了两个类(可以在 build/intermediates/transforms/desugar
中找到):
LambdaTest:
public class LambdaTest {
public void testStateless() {
Runnable r = LambdaTest$$Lambda$0.$instance;
r.run();
}
static void lambda$testPure$0$LambdaTest(){
System.out.println("pure");
}
}
LambdaTest$$Lambda$0:
final class LambdaTest$$Lambda$0 implements Runnable {
static final Runnable $instance = new LambdaTest$$Lambda$0();
private LambdaTest$$Lambda$0() {
}
public void run() {
LambdaTest.lambda$testPure$0$LambdaTest();
}
}
lambda body 变成了 LambdaTest 中的一个静态方法,也就是所谓的 desugar 方法,另外还生成了一个类 LambdaTest$$Lambda$0
实现了函数式接口,在其实现方法里再去调用 desugar 方法,无状态 lambda 对象不需要保存额外的参数,这里用单例进行优化。
如果捕获了变量,以局部变量和形式参数为例,无论是局部变量还是上下文方法的形式参数,它们的值和类型都是编译时确定的:
public void capturingLocal(String strp) {
String str = "lexical";
Runnable r = () -> System.out.println(str + strp);
r.run();
}
LambdaTest$$Lambda$1:
final class LambdaTest$$Lambda$1 implements Runnable {
private final String arg$1;
private final String arg$2;
LambdaTest$$Lambda$1(String var1, String var2) {
this.arg$1 = var1;
this.arg$2 = var2;
}
public void run() {
LambdaTest.lambda$capturingLocal$1$LambdaTest(this.arg$1, this.arg$2);
}
}
原先的 lambda 表达式赋值语句变成了 Runnable r = new LambdaTest$$Lambda$1(str, strp)
,自由变量都通过 lambda 对象构造器进行捕获并保存起来,对 lambda 求值的时候再传递给 desugar 方法,这里 Runnable 的方法没有形式参数,如果有形式参数的话,这些捕获的变量会排在形式参数后面再传递给 desugar 方法。
如果在 lambda 中引用了对象字段:
private String stri = "instance";
public void capturingInstance() {
Runnable r = () -> System.out.println(stri);
r.run();
}
LambdaTest$$Lambda$4:
final class LambdaTest$$Lambda$4 implements Runnable {
private final LambdaTest arg$1;
LambdaTest$$Lambda$4(LambdaTest var1) {
this.arg$1 = var1;
}
public void run() {
this.arg$1.lambda$capturingInstance$4$LambdaTest();
}
}
可以看到 lambda 对象保存了上下文类的引用,无论是实例变量还是实例方法,实际上都有一个隐性的接收者就是 this
,当然也可以显性的声明,在 lambda body 中的 this
引用指向的就是其上下文的类,而不是 lambda 对象(与匿名类的区别)。在这里 lambda 表达捕获的变量就是实例变量的接收者 this
而不是实例变量本身。而且可以看到 lambda 的 desugar 方法变成了实例方法,用这种方式,lambda body 几乎不用做任何转换只需照搬进方法体就行。还包括对 super
的处理,lambda 对象无法捕获 super,只能通过调用 this 的实例方法来实现对 super 的调用,可见用 desugar 方法来实现是十分便利的。
this
的捕获,对于 Android 开发来说特别要注意,在 Activity 中使用 lambda 表达式的话,意味着会通过 final 引用的形象将当前 Activity 实例传递到外部去,稍不注意便会引起泄露。一个显而易见的技巧,将实例字段赋值给局部变量,就不会捕获 this 引用了。当然对于生命周期相关的对象来说还是不安全的,比如 View。
方法引用基本可以当成是 lambda 表达式的一个特例,方法引用都可以用相应的 lambda 表达式来代替,有一个例外就是带有类型参数方法的函数式接口,能用方法引用但不能用 lambda 表达式,见 java - Lambda Expression and generic method - Stack Overflow。方法引用也分为捕获与非捕获,对于无须捕获接的方法引用主要有:
什么是未绑定的实例方法?方法引用语法可以大致认为是接收者::方法名
这样的形式,方法可以是实例方法或者是静态方法,当方法是实例方法而接收者是类引用时,这时接收者就是一个未绑定的接收者:
list.filter(String::isEmpty)
isEmpty
是实例方法,而接收者是类引用,在这里接收者在运行会被替换为被替换为 list 内的元素,等价于这样的 lambda 表达式:
list.filter(s -> s.isEmpty())
注意非绑定的实例方法引用是有二义性的,java 根据方法的声明去推定 isEmpty
是实例方法还是静态方法,以下面的类为例:
public class C{
public static boolean isEmpty(C c);
public boolean isEmpty();
}
如上面的方法声明两个方法对于表达式 list.filter(C::isEmpty)
来说都是合法的,java 也就无法推断出这里是指哪个方法引用,所以编译器报错。
需要捕获的方法引用,也就是已绑定实例的方法引用,包括实例方法,内部类(数组)的构造器,super 方法。接收者就是闭包所要捕获的变量。但要注意一点方法引用是没有隐式声明的 this
引用的。比如下面两个方法,从语义上来说是等价的,
public void capturingInstance() {
Predicate<String> c = s -> stri.equals(s);
}
public void capturingIntanceMethod() {
Predicate<String> c = stri::equals;
}
但是他们捕获的引用却不一样,上文可知 lambda 表达式捕获的是隐式声明的 this
,而方法引用捕获的却是直接接收者:
final class LambdaTest$$Lambda$8 implements Predicate {
private final String arg$1;
private LambdaTest$$Lambda$8(String var1) {
this.arg$1 = var1;
}
static Predicate get$Lambda(String var0) {
return new LambdaTest$$Lambda$8(var0);
}
public boolean test(Object var1) {
return this.arg$1.equals((String)var1);
}
}
还有一点,使用方法引用,因为方法已经是现成的,大部分情况就没必要重新生成一个 desugar 方法。
但有例外,super 和可变参数,需要一个桥接方法。对于 super 来说,lambda 对象是无法不会当前类的 super 引用的,所以需要借由当前类的实例方法来实现对 super 的引用。
接收者也可以是表达式:
Predicate<String> c = (stri.equals("abc") ? "abc" : "bcd")::equals;
在这里捕获的是表达式求值的结果而不是表达式。
所以对于 Activity 来说,要格外注意下面几种情况可能导致引用泄露:
this
关键字的方法引用super
关键字的方法引用理解 MethodHandle(方法句柄)的一种方式就是将其视为以安全、现代的方式来实现反射的核心功能。
一个 java 方法的实体有四个构成:
同一个类中,方法名相同,签名不同,JVM 会视为不同的方法,不过在 Java 中只支持签名的参数列表部分,也就是重载多态。一次方法调用,除了要方法的实体外,还要调用者(caller)和接收者(receiver),调用者也就是方法调用语句所在的类。接收者是一个对象,每个方法调用都要一个接收者,它可以是隐藏的(this),也可以是类方法,比如: String.valueOf
,类也是 Class 的一个实例。
MethodType 表示方法签名。
用 MethodHandle 实现的方法调用的示例如下,可以看到方法的四个构成:
Object rcvr = "a";
try {
MethodType mt = MethodType.methodType(int.class); // 方法签名
MethodHandles.Lookup l = MethodHandles.lookup(); // 调用者,也就是当前类。调用者决定有没有权限能访问到方法
MethodHandle mh = l.findVirtual(rcvr.getClass(), "hashCode", mt); //分别是定义方法的类,方法名,签名
int ret;
try {
ret = (int)mh.invoke(rcvr); // 代码,第一个参数就是接收者
System.out.println(ret);
} catch (Throwable t) {
t.printStackTrace();
}
} catch (IllegalArgumentException | NoSuchMethodException | SecurityException e) {
e.printStackTrace();
} catch (IllegalAccessException x) {
x.printStackTrace();
}
详细可参考:
lambda 表达式 是怎么使用 inDy 呢?以一段简单的代码为例
public class LambdaTest {
public static void main(String[] args) {
Runnable r = () -> System.out.println(Arrays.toString(args));
r.run();
}
}
用 javap -v -p LambdaTest
查看字节码,可以发现寥寥几行 java 代码生成的字节码却不少,单单常量池常量就有 66 个之多。输出见 LambdaTest.class。
可以发现多出了一个新方法,方法体就是 lambda 体(lambda body),转换为源码如下:
private static void lambda$main$0(java.lang.String[] args){
System.out.println(Arrays.toString(args));
}
主要看一下 main 方法,并没有直接调用上面的方法,而是出现一条 inDy 指令:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
0: aload_0
1: invokedynamic #2, 0 // InvokeDynamic #0:run:([Ljava/lang/String;)Ljava/lang/Runnable;
6: astore_1
7: aload_1
8: invokeinterface #3, 1 // InterfaceMethod java/lang/Runnable.run:()V
13: return
可以看到 inDy 指向一个类型为 [CONSTANT_InvokeDynamic_info][] 的常量项 #2
,另外 0
是预留参数,暂时没有作用。
#2 = InvokeDynamic #0:#30 // #0:run:([Ljava/lang/String;)Ljava/lang/Runnable;
#0
表示在 Bootstrap methods 表中的索引:
BootstrapMethods:
0: #27 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#28 ()V
#29 invokestatic com/company/LambdaTest.lambda$main$0:([Ljava/lang/String;)V
#28 ()V
#30
则是一个 [CONSTANT_NameAndType_info][],表示方法名和方法类型(返回值和参数列表),这个会作为参数传递给 BSM。
#30 = NameAndType #43:#44 // run:([Ljava/lang/String;)Ljava/lang/Runnable;
再看回表中的第 0 项,#27
是一个 [CONSTANT_MethodHandle_info][],实际上是个 MethodHandle(方法句柄)对象,这个句柄指向的就是 BSM 方法。在这里就是:
java.lang.invoke.LambdaMetafactory.metafactory(MethodHandles.Lookup,String,MethodType,MethodType,MethodHandle,MethodType)
BSM 前三个参数是固定的,后面还可以附加任意数量的参数,但是参数的类型是有限制的,参数类型只能是
LambdaMetafactory.metafactory 带多三个参数,这些的参数的值由 Bootstrap methods 表 提供:
Method arguments:
#25 ()V
#26 invokestatic com/company/LambdaTest.lambda$main$0:()V
#25 ()V
inDy 所需要的数据大概就是这些,可参考 Java8学习笔记(2) – InvokeDynamic指令 - CSDN博客
每一个 inDy 指令都称为 Dynamic Call Site(动态调用点),根据 jvm 规范所说的,inDy 可以分为两步,这两步部分代码代码是在 java 层的,给 metafactory
方法设断点可以看到一些行为。
第一步 inDy 需要一个 CallSite(调用点对象),CallSite 是由 BSM 返回的,所以这一步就是调用 BSM 方法。代码可参考:java.lang.invoke.CallSite#makeSite
调用 BSM 方法可以看作 invokevirtual 指令执行一个 invoke 方法,方法签名如下:
invoke:(MethodHandle,Lookup,String,MethodType,/*其他附加静态参数*/)CallSite
前四个参数是固定的,被依次压入操作栈里
Lookup#lookupClass()
获取这个类"run"
methodType(Runnable.class,String[].class)
接下来就是附加参数,这些参数是灵活的,由Bootstrap methods 表提供,这里分别是:
methodType(void.class)
。sam 就 single public abstract method 的缩写caller.findStatic(LambdaTest.class,"lambda$main$0",methodType(void.class))
methodType(void.class)
上面说的固定其实应该是指 inDy 传递的实参类型是固定的,BSM 形参声明可以是随意,保证 BSM 能被调用就行,比如说 Lookup 声明为 Object 不影响调用。
接下来就是执行 LambdaMetafactory.metafactory
方法了,它会创建一个匿名类,这个类是通过 ASM 编织字节码在内存中生成的,然后直接通过 unsafe 直接加载而不会写到文件里。不过可以通过下面的虚拟机参数让它运行的时候输出到文件
-Djdk.internal.lambda.dumpProxyClasses=<path>
这个类是根据 lambda 的特点生成的,输出后可以看到,在这个例子中是这样的:
import java.lang.invoke.LambdaForm.Hidden;
// $FF: synthetic class
final class LambdaTest$$Lambda$1 implements Runnable {
private final String[] arg$1;
private LambdaTest$$Lambda$1(String[] var1) {
this.arg$1 = var1;
}
private static Runnable get$Lambda(String[] var0) {
return new LambdaTest$$Lambda$1(var0);
}
@Hidden
public void run() {
LambdaTest.lambda$main$0(this.arg$1);
}
}
然后就是创建一个 CallSite,绑定一个 MethodHandle,指向的方法其实就是生成的类中的静态方法 LambdaTest$$Lambda$1.get$Lambda(String[])Runnable
。然后把调用点对象返回,到这里 BSM 方法执行完毕。
更详细的可参考:
第二步,就是执行这个方法句柄了,这个过程就像 invokevirtual
指令执行 MethodHandle#invokeExact
一样,
加上 inDy 上面那一条 aload_0
指令,这是操作数栈有两个分别是:
传入 args,执行方法,返回一个 Runnable 对象,压入栈顶。到这里 inDy 就执行完毕。
接下来的指令就很好理解,astore_1
把栈顶的 Runnable 对象放到局部变量表的槽位1,也是变量 r
。剩下的就是再拿出来调用 run
方法。
接下来看一下 groovy 是如何使用 inDy 指令的。先复习一遍 groovy 的方法派发。
每当 Groovy 调用一个方法时,它不会直接调用它,而是要求一个中间层来代替它。 中间层通过钩子方法允许我们更改方法调用的行为。这个中间层就是 MOP(meta object proctol),MOP 主要承载的类就是 MetaClass 。一个简化版的 MOP 主要有这些方法:
invokeMethod(String methodName, Object args)
methodMissing(String name, Object arguments)
getProperty(String propertyName)
setProperty(String propertyName, Object newValue)
propertyMissing(String name)
可以大致认为在 Groovy 中的每个方法和属性访问调用都会转化上面的方法调用。而这些方法可以在运行时通过重写修改它的默认行为,MOP 作为方法派发的中心枢纽为 Groovy 提供了非常灵活的动态编程的能力。
现在来看一下一段简短的 groovy 代码,
class Test{
int a = 0;
static void main(args){
Test wtf = new Test()
wtf.a
wtf.doSomething()
}
}
通过 groovyc -indy Test.groovy
把它编译成字节码。 indy
选项的意思就是启用 invokedynamic 支持。
看一下编译后的 main 方法。
public static void main(java.lang.String...);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC, ACC_VARARGS
Code:
stack=1, locals=2, args_size=1
0: ldc #2 // class Test
2: invokedynamic #44, 0 // InvokeDynamic #0:init:(Ljava/lang/Class;)Ljava/lang/Object;
7: invokedynamic #50, 0 // InvokeDynamic #1:cast:(Ljava/lang/Object;)LTest;
12: astore_1
13: aload_1
14: pop
15: aload_1
16: invokedynamic #56, 0 // InvokeDynamic #2:getProperty:(LTest;)Ljava/lang/Object;
21: pop
22: aload_1
23: invokedynamic #61, 0 // InvokeDynamic #3:invoke:(LTest;)Ljava/lang/Object;
28: pop
29: return
可以看到一共有 4 条 inDy 指令,包括构造函数,访问成员变量,和不存在的方法调用都是 通过 invokedynamic 实现的。
再看一下引导方法表
BootstrapMethods:
0: #38 invokestatic org/codehaus/groovy/vmplugin/v7/IndyInterface.bootstrap:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;I)Ljava/lang/invoke/CallSite;
Method arguments:
#39 <init>
#40 0
1: #38 invokestatic org/codehaus/groovy/vmplugin/v7/IndyInterface.bootstrap:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;I)Ljava/lang/invoke/CallSite;
Method arguments:
#46 ()
#40 0
2: #38 invokestatic org/codehaus/groovy/vmplugin/v7/IndyInterface.bootstrap:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;I)Ljava/lang/invoke/CallSite;
Method arguments:
#51 a
#52 4
3: #38 invokestatic org/codehaus/groovy/vmplugin/v7/IndyInterface.bootstrap:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;I)Ljava/lang/invoke/CallSite;
Method arguments:
#58 doSomething
#40 0
可以发现所有 inDy 指令的引导方法都是 IndyInterface.bootstrap
以方法调用的 inDy 指令为例,它的方法名称是 “invoke”,方法签名是 methodType(Object.class,Test.class)
,BSM 方法还附带两个参数分别是实际的方法名:"doSomething"
和一个标志:0
BSM 方法最终调用的是 realBootstrap
方法:
private static CallSite realBootstrap(Lookup caller, String name, int callID, MethodType type, boolean safe, boolean thisCall, boolean spreadCall) {
MutableCallSite mc = new MutableCallSite(type); //这里是 MutableCallSite,lambda 表达式用的是 ConstantCallSite
MethodHandle mh = makeFallBack(mc,caller.lookupClass(),name,callID,type,safe,thisCall,spreadCall);
mc.setTarget(mh);
return mc;
}
主要的代码是调用 makeFallBack
来获取一个临时的 MethodHandle。因为在第一步 groovy 无法确定接收者(receiver),也是就是 invoke 方法的第一个实参(Test 实例),必须要在第二步确定 CallSite 后才会传递过来。所以方法解析要放在第二步。
protected static MethodHandle makeFallBack(MutableCallSite mc, Class<?> sender, String name, int callID, MethodType type, boolean safeNavigation, boolean thisCall, boolean spreadCall) {
MethodHandle mh = MethodHandles.insertArguments(SELECT_METHOD, 0, mc, sender, name, callID, safeNavigation, thisCall, spreadCall, /*dummy receiver:*/ 1); //MethodHandle(Object.class,Object[].class)
mh = mh.asCollector(Object[].class, type.parameterCount()).
asType(type);
return mh;
}
这个 fallback 方法其实就是 selectMethod
。insertArguments
在这里主要做了一个柯里化的操作,因为selectMethod
的方法签名是
methodType(Object.class, MutableCallSite.class, Class.class, String.class, int.class, Boolean.class, Boolean.class, Boolean.class, Object.class, Object[].class)
而 inDy 要求的方法签名却是
methodType(Object.class,Test.class)。
所以得经过 insertArguments
的变换,把确定的值填充进去,用最后的数组参数来接收 inDy 传递的参数。这样这个方法就能够被 inDy 调用了。第一步创建 CallSite 到这里就结束。
第二步,就是 selectMethod 方法的调用,这时候 groovy 已经知道方法的接收者 arguments[0]
,
public static Object selectMethod(MutableCallSite callSite, Class sender, String methodName, int callID, Boolean safeNavigation, Boolean thisCall, Boolean spreadCall, Object dummyReceiver, Object[] arguments) throws Throwable {
Selector selector = Selector.getSelector(callSite, sender, methodName, callID, safeNavigation, thisCall, spreadCall, arguments);
selector.setCallSiteTarget();
MethodHandle call = selector.handle.asSpreader(Object[].class, arguments.length);
call = call.asType(MethodType.methodType(Object.class,Object[].class));
return call.invokeExact(arguments);
}
首先创建一个方法解析器,在这里是 MethodSelector
。接着调用 setCallSiteTarget()
,这个方法就是用来解析实际的方法。具体的过程还是很复杂的,所以也没法说清楚,大体来说就是确定接收者的 MetaClass
,决定这个方法是实际的方法,还是交给 MetaClass
的钩子方法,然后就是创建这个方法的 MethodHandle,然后把这个 MethodHandle 的签名转化为要求的签名。这时 selecor.handle 就是最终调用的方法句柄了。接下来就是最终的方法调用了,到这里 inDy 指令就执行完毕了。
还有一个方法值得留意:
public void doCallSiteTargetSet() {
if (!cache) {
if (LOG_ENABLED) LOG.info("call site stays uncached");
} else {
callSite.setTarget(handle);
if (LOG_ENABLED) LOG.info("call site target set, preparing outside invocation");
}
}
这也是为什么用 MutableCallSite
的原因,如果编译器认为这个方法是可以缓存,那么就会把这个 CallSite 绑定到实际的 MethodHandle,后续的调用就不用再重新解析了。
没有相关经验,inDy 还是很不好理解的,学习了 java 8 和 groovy 对 inDy 的应用才有一点大致的认识,文中如果有什么错误,还请帮忙指出。
]]>Lifecycle 是 Android Architecture Components 的一个组件,用于将系统组件(Activity、Fragment等等)的生命周期分离到 Lifecycle
类,Lifecycle
允许其他类作为观察者,观察组件生命周期的变化。Lifecycle 用起来很简单,首先声明一个 LifecycleObserver
对象,用 @OnLifecycleEvent
注解声明生命周期事件回调的方法:
public class LifecycleObserverDemo implements LifecycleObserver {
@OnLifecycleEvent(Lifecycle.Event.ON_ANY)
void onAny(LifecycleOwner owner, Lifecycle.Event event) {
System.out.println("onAny:" + event.name());
}
@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
void onCreate() {
System.out.println("onCreate");
}
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
void onDestroy() {
System.out.println("onDestroy");
}
}
然后在 LifecycleRegistryOwner
比如 LifecycleActivity
加入这么一行代码:
getLifecycle().addObserver(new LifecycleObserverDemo());
然后?然后就没了,运行起来可以看到 LifecycleActivity
的生命周期发生变化时,LifecycleObserverDemo
总能得到通知。而 LifecycleActivity
只有寥寥几行代码,并没有覆盖任何回调方法。那么 Lifecycle 是怎么做到的,是不是有点黑魔法的感觉?
首先从注解入手,可以在 build 目录下发现注解处理器为我们生成了 LifecycleObserverDemo_LifecycleAdapter
,不过这只是一个适配器,用于将生命周期事件派发到 LifecycleObserverDemo
对应的方法。
public class LifecycleObserverDemo_LifecycleAdapter implements GenericLifecycleObserver {
final LifecycleObserverDemo mReceiver;
LifecycleObserverDemo_LifecycleAdapter(LifecycleObserverDemo receiver) {
this.mReceiver = receiver;
}
@Override
public void onStateChanged(LifecycleOwner owner, Lifecycle.Event event) {
mReceiver.onAny(owner,event);
if (event == Lifecycle.Event.ON_CREATE) {
mReceiver.onCreate();
}
if (event == Lifecycle.Event.ON_START) {
mReceiver.onStart();
}
if (event == Lifecycle.Event.ON_PAUSE) {
mReceiver.onPause();
}
if (event == Lifecycle.Event.ON_DESTROY) {
mReceiver.onDestroy();
}
}
public Object getReceiver() {
return mReceiver;
}
}
注解也没有生成任何相关的代码,而 Activity 不用写任何代码,那么 Lifecycle 是如何把 Activity 生命周期事件传递给 LifecycleObserver
的?
最终通过研读 Lifecycle 的代码,发现里面有个包可见的类 LifecycleDispatcher
,LifecycleDispatcher
是一个单例,在 LifecycleDispatcher#init(Context)
中,它通过 registerActivityLifecycleCallbacks
方法,向当前的 Application 注册一个 DispatcherActivityCallback
,但 Lifecycle 并没使用 ActivityLifecycleCallbacks
来监听并派发生命周期事件。
static void init(Context context){
...
((Application)context.getApplicationContext()).registerActivityLifecycleCallbacks(new LifecycleDispatcher.DispatcherActivityCallback());
...
}
static class DispatcherActivityCallback extends EmptyActivityLifecycleCallbacks {
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
...
if(manager.findFragmentByTag("android.arch.lifecycle.LifecycleDispatcher.report_fragment_tag") == null) {
manager.beginTransaction().add(new ReportFragment(), "android.arch.lifecycle.LifecycleDispatcher.report_fragment_tag").commit();
manager.executePendingTransactions();
}
}
}
而是通过一个无 UI 的 Fragment,在 DispatcherActivityCallback#onActivityCreated
可以看到它在 Activity#onCreate
时,为 Activity 添加一个 ReportFragment
。最终由 ReportFragment
来监听各个生命周期事件,然后传递给 LifecycleRegistry
。
public class ReportFragment extends Fragment {
...
public void onPause() {
super.onPause();
dispatch(Event.ON_PAUSE);
}
...
private void dispatch(Event event) {
if(this.getActivity() instanceof LifecycleRegistryOwner) {
((LifecycleRegistryOwner)this.getActivity()).getLifecycle().handleLifecycleEvent(event);
}
}
}
Activity 的生命周期事件都会派发到它的 Fragments,向 Activity 注册一个无 UI 的 Fragment 也叫 Headless Fragment 用于将各种 Activity 回调分离出来是个常用的做法,比如 RxPermissions 也是用这种方法来避免复写 Activity#onRequestPermissionsResult
。
顺便一提 Lifecycle 文档提到:
ON_CREATE, ON_START, ON_RESUME events in this class are dispatched after the LifecycleOwner’s related method returns. ON_PAUSE, ON_STOP, ON_DESTROY events in this class are dispatched before the LifecycleOwner’s related method is called.
正好是 Fragment 生命周期回调的触发顺序。
Activity
的生命周期变化是如何传递到 LifecycleObserver
有了清晰的图表:
还有一个问题, LifecycleDispatcher#init(Context)
并不是入口,它也需要被调用。那么他的调用者是谁?Google 这里的做法还是很巧妙的,如果这时把 apk 的 AndroidManifest.xml 提取出来,就会发现多了一个 ContentProvider 声明:
<provider
android:name="android.arch.lifecycle.LifecycleRuntimeTrojanProvider"
android:authorities="${applicationId}.lifecycle-trojan"
android:exported="false"
android:multiprocess="true" />
LifecycleRuntimeTrojanProvider
,运行时木马是什么鬼?实际上,它不是一个 ContentProvider,而是利用 ContentProvider 的特点在应用程序初始化时,向其注入两行代码:
LifecycleDispatcher.init(getContext());
ProcessLifecycleOwner.init(getContext());
这里 ContentProvider 之于 Application 的作用就类似于 Headless Fragment 之于 Activity 一样,目的都是避免继承系统组件。关于 ContentProvider 的生命周期可以看 android - ContentProvider destruction/lifecycle - Stack Overflow,
最后再提一下,Lifecycle 还提供了内置了另外三个 LifecycleOnwer:
有朋友在问 Lifecycle 有什么应用。我觉得 Lifecycle 最主要的作用就是在于解耦。以前我们使用一个生命周期敏感的模块 m,必须得在 Activity 子类里面添加类似下面的代码
super.onCreate()
m.init()
m.release()
super.onDestory()
这类组件之多,用起来之频繁。以致于我们经常要创建一个 BaseActivity
类来做这些脏活。不过,一旦我们建立了BaseActivity
,我们常常就能体会到 Java 单继承之痛。Activity不止一个啊:
LifecycleActivity
、AppcompatActivity
、FragmentActivity
还有第三方库的,比如 CordovaActivity
… 。随着项目的扩大,你很难只用一类 Activity。而且有生命周期的组件不止一个,这些组件的子类也花样繁多,我们建立了 BaseFragment
、BaseService
…同时也建立了更多痛苦。为什么我们的模块要和这些复杂性绑定在一起?生命周期敏感模块应该与独立起来,变成一个可以在任意有生命周期的组件安装的模块,所以 Lifecycle 就在帮我们做这种事情。
getLifecycle().addObserver(new LifecycleObserverDemo());
那么具体一点这类生命周期敏感的组件有哪些呢?官方以 LocationManager 为例,主要作用避免 Activity 遁入后台后继续定位浪费电量。这里我以 volley 为例,举一个网络请求经常要面对的问题:
volleyClient.query(new Respose.Listener(){});
上面的代码是个老生常谈的问题了,new Respose.Listener(){}
是 Activity 的一个匿名类,它有指向 Activity 的引用,而 Volley 是一个存活范围比 Activity 更大的实例,比如常常 VolleyClient 就是单例。这就导致了 Activity 销毁后不能及时释放,内存泄漏!当然,网络请求终会返回的,这个回调对象就会被销毁,从这个角度讲,问题也不是很大。另外一个就是请求返回的时候,我们会在 onSuccess 里操作 UI,如果 Activity 已经销毁了,没做检查的话那么就会崩溃。这些都不是大问题,但是很烦人。所以像 volley 请求这样的就是一个生命周期敏感的功能。
网络请求和定位回调一样都可以归类为生命周期敏感的数据源,Google 为这种类型的数据源提供了 LiveData。这就是 LifecycleObserver 一个典型的应用,当然这只是 LiveData 的一部分功能。
非数据源的生命周期敏感组件,比如说用户行为收集模块,它本身就是一个生命周期的监听者,在没有 ActivityLifecycleCallback
的年代(API<14),常常需要在各个 Activity 中手动加入开始记录和停止纪录的代码。有了 ActivityLifecycleCallback
之后,我们需要做的就变成在 Application#onCreate
加一句代码。那么现在把用户行为收集模块变成 LifecycleObserver
有什么好处?
很遗憾,对于这个例子我暂时还想不出有什么特别好的地方,但是它能说明 LifecycleObserver 一个最主要的特点。比如我们有十个 Activity,只有 Activity1 和 Activity2 需要记录,那么用 LifecycleObserver 就可以避免用配置去声明哪些 Activity 需要记录。直接在需要记录的 Activity加入如下代码
getLifecycle().addObserver(new OpRecorder());
这就是解耦的好处。能让一个与生命周期深度耦合的组件变成一个随处可安装的组件。
最后,还是要回到这篇文章的主题,我们从 Lifecycle 的代码可以学到一个更大的模式。
Activity 不只有生命周期回调,还有权限,onActivityResult 等等。那些需要与这些回调深度耦合的模块,利用 Lifecycle 用的技术 Headless Fragments 来解耦是个不错的方法。对于整个 Application 来说那就可以用更 tricky 的 Headless ContentProvider。
]]>决定是否显示菜单的代码是由 PagerAdapter#setPrimaryItem
实现的,属于主项(primary item)的 fragment 才会显示菜单项。以 FragmentPagerState 为例,具体代码如下:
Fragment fragment = (Fragment)object;
if (fragment != mCurrentPrimaryItem) {
if (mCurrentPrimaryItem != null) {
mCurrentPrimaryItem.setMenuVisibility(false);
mCurrentPrimaryItem.setUserVisibleHint(false);
}
if (fragment != null) {
fragment.setMenuVisibility(true);
fragment.setUserVisibleHint(true);
}
mCurrentPrimaryItem = fragment;
}
AA、CA 的显示就很好理解了,因为它们各自是 A 和 C 的主项(primary item),所以都调用了 setMenuVisibility(true)
。
要修复这个问题,一开始想到的是覆盖父 fragment 的 setMenuVisibility
方法,把值传递到当前子 fragment
@Override public void setMenuVisibility(boolean menuVisible) {
if (isAdded() && getChildFragmentManager().getFragments() != null) {
Fragment f = getChildFragmentManager().findFragmentByTag(
"android:switcher:" + mPager.getId() + ":" + mPager.getCurrentItem());// 不支持 FragmentStatePagerAdapter
if (f != null) {
f.setMenuVisibility(menuVisible);
}
}
super.setMenuVisibility(menuVisible);
}
这样从 A 滑到 B 时,AA 能隐藏了。但仍然不能解决问题,从 A 滑到 B 时离屏加载 C,并设置 C 的 MenuVisibility 为 false。FragmentPagerAdapter 几次 setMenuVisibility 都在 finishUpdate 之前,所以此时 C 还未添加到 Activity,CA 更不存在。等到 CA 加载时,已经不会再触发 C 的 MenuVisibility 了。
考虑自定义 FragmentPagerAdapter,主项(primary item)的 fragment 的 menuVisibility 同步父 Fragment 的状态,mParent
是适配器构造函数传入的 ViewPager 宿主 Fragment。
@Override public void setPrimaryItem(ViewGroup container, int position, Object object) {
super.setPrimaryItem(container, position, object);
if (mParent != null) ((Fragment) object).setMenuVisibility(mParent.isMenuVisible());
}
这样的问题是,从 A 滑到 B 时,只是根 ViewPager 的当前主项(primary item)发生变化,A 适配器和 B 适配器的主项不会发生变化,所以 setPrimaryItem 不会被触发,AA 的菜单仍然可见,而 BA 的菜单则仍然不可见。
幸运的是把修改两个地方合并起来,这样就覆盖了各种可能了。但未免过于繁琐,把问题重新整理一遍,建立模型,才是优雅的解决方法:
这样就能设计一个新的 PagerAdapter,把棘手的问题都放在 PagerAdapter 来做。
要实现第三点,适配器需要获得指向其子适配器的引用,适配器是 Fragments 的管理者,这些 Fragments 又是子适配器的宿主,只要让 Fragment 实现接口来获取其内部的适配器便行。
public interface AdapterHolder {
HierarchyFragmentPagerAdapter getAdapter();
}
/**
* 通知子 Adapter(宿主是 holder) ,父 Adapter(当前的 Adapter) visible 发生了变化。
* 或者通知子 Adapter,父 Adapter 希望它的 visible 发生变化
*/
private void notifyChildVisibleChanged(boolean visible, Fragment holder) {
if (holder instanceof AdapterHolder) {
HierarchyFragmentPagerAdapter adapter = ((AdapterHolder) holder).getAdapter();
if (adapter != null) {
adapter.setVisible(visible);
}
}
}
没有继承 AdapterHolder 都会被适配器当成没有子 Adapter。
相比第三点,第四点反而更麻烦。在 finishUpdate 之前,Fragment 是不知道它在树中的位置的。这时如果尝试用 getParentFragment()
是返回空,
Fragment parent = fragment.getParentFragment();
if (parent == null || !(parent instanceof AdapterHolder)) {
// 拿不到 parent 有两种情况
// Adapter 在根 Pager 里
// 也有可能是第一次初始化,当前 Fragment 还未和其父 Fragment 建立链接
setVisible(isVisible());
} else {
// 否则,只有父 Adapter 是 visible primary,当前 primary item 才可能是 visible primary.
setVisible(((AdapterHolder) parent).getAdapter().isVisible());
}
为了能够正确初始化,需要在构造函数做个 hack。
public HierarchyFragmentPagerAdapter(PagerAdapter adapter, AdapterHolder holder) {
mAdapter = adapter;
mVisible = true;
if (holder != null) {
if (holder instanceof Fragment) {
// 一个 hack,初始化 的 visible 状态
// holder 不是 Fragment 表示 Adapter 为根 Adapter
// menu visible 为 true,便断言宿主 Fragment 是 primary item.
mVisible = ((Fragment) holder).isMenuVisible();
}
}
}
剩下的便没什么,鉴于 PagerAdapter 有两个, FragmentPagerAdapter 和 FragmentStatePagerAdapter。所以适配器的设计便用代理模式比较合适,实现起来比想象中的简洁,用起来也简单,只需将实际的 PagerAdapter 外面包一层 HierarchyFragmentPagerAdapter
就行,具体的代码见:HierarchyFragmentPagerAdapter
Emacs 风格的快捷键通过前缀键来扩展更多打字区的快捷键,尽量把快捷键控制在打字键区,显著减少编码过程手腕的移动,是个经得起考验的快捷键方案(非 emacs 用户可能深痛恶觉)。
这套快捷键在 Emacs keymaps 的基础上进行自定义,首先减少对功能键区的使用,一来容易与系统快捷键冲突,二来手指移动的幅度过大难定位不容形成肌肉记忆,所以只保留编译运行相关的快捷键。至于编辑键区则更次,手腕必须得移动,眼睛也得跟着辅助定位,只保留少部分不常用的默认快捷键。数字键区最糟,手腕移动幅度最大,再说我的 87 键盘都没有小键盘-_-,直接弃用。至于鼠标,那更是万恶之源,整个手臂都得移动,还要眼睛配合才能用鼠标完成一次操作,写代码的过程大多是用鼠标辅助点击几次,然后又回到打字区继续敲,这样来回一次切换成本太高。何况程序员经常用鼠标点点点?多没 B 格啊。虽说如此,不过想要完全不用鼠标还是不太容易,只能说一个命令通过鼠标打开层层菜单来执行超过一次,第二次就应该用 Find Action 来执行,如果一天超过三次那就应该给它设个快捷键并记住。
Android Studio 相比 Eclipse 内置的 Emacs keymaps 强大了许多,不过 Eclipse 有 Emacs+, Android Studio 却没有这方面的插件。所有 Android Studio 相比 Emacs 多了一些不足,比如:
Escape
命令配置快捷键,但不少 UI 还是硬编码为键盘的 Esc
键。所以为了退出浮窗,经常要 C-g
、Esc
交替使用一些约定先说明,特别是非 Emacs 党:
C-F
等同于 C-S-f
,无换档字符仍使用 C-S-key
表示←
、→
、↑
、↓
,
用于分隔前缀键,表示先按 ,
前的键,松开,再按下一个键最终使用的前缀键有下面三个:
C-x
Esc
M-g
首先,最基本又最重要的键肯定就是 Find Action,简直就是 Emacs 中的 Run Command,必须绑定为 M-x
。别的快捷键记不住不要紧,只要记住这个还是能做到无鼠标操作,特别是还附带快捷键提示,如果有的话。不过还是得对命令的关键字有点概念,表格中的关键字项就是表示通过该关键字在 Find Action 或者 Keymaps 设置搜索到相应功能;自定义有 *
表示快捷键是我自定义的,非 Emacs Keymaps 的默认配置。
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
M-x |
执行命令 | find Action | * |
C-g |
取消 | escape | |
C-M-s |
打开设置 | settings |
光标移动类的快捷键,这里指的是在单一文本的内的光标移动,又包括语法无关的移动和语法相关的移动,语法无关的移动指的就是光标的上下左右移动等等,这一块与 Emacs 基本一致。另外 C-l
虽然不是移动光标,但也是放在这里。
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
C-f |
向前移动一个字符 | right | |
C-b |
向后移动一个字符 | left | |
C-n |
向上移动一行 | up | |
C-p |
向下移动一行 | down | |
M-f |
向前移动一个单词 | next word | |
M-b |
向后移动一个单词 | previous word | |
C-a |
移动到行头 | line start | |
C-e |
移动到行尾 | line end | |
C-v |
下一页 | page down | |
M-v |
上一页 | page up | |
M-< |
移动到文本头 | text start | |
M-> |
移动到文本尾 | text end | |
C-l |
将光标位置滚动到屏幕中央 | scroll to center | |
C-Pgup |
移动到当前页的起始处 | Page Top | |
C-PgDn |
移动到当前页的结束处 | page end |
语法相关的移动:
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
C-[ |
移动到当前 Block 起始大括号处 | Code block start | |
C-] |
移动到当前 BLock 结束大括号处 | Code Block End | |
C-M-a |
移动到前一个方法 | previous method | |
C-M-e |
移动到后一个方法 | next method |
Android Studio 将 M-g
作为跳转到行数,我改其作为前置键,同时作为跳转高亮错误的前置键。
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
M-g g |
作为跳转到行数 | line | * |
M-g n |
下一个高亮问题 | next highlighted error | * |
M-g p |
上一个高亮问题 | next highlighted error | * |
与光标相关的还有文本选择,我保留 C-space
来加入选择模式,但这个快捷键也常被操作系统用来切换输入法,
在 Linux 下我把输入切换配置为 Win-space,其他系统我也建议想办法把 C-space 留给 Android Studio。
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
C-space |
切换选择模式 | sticky selection | |
C-x, h |
全选 | select all |
特别是 Android Studio 不像 emacs 可以用 C-F
等进行选择。只能用传统的 S-→
。所以保留 C-space 还是有必要的,下面是例外:
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
C-{ |
选择到当前 Block 起始大括号处 | Code block start | |
C-} |
选择到当前 Block 结束大括号处 | Code block end | |
C-S-PgUp |
选择到当前页的起始处 | page start | |
C-S-PgDn |
选择到当前页的结束处 | page end | |
C-S-Home |
选择到当前文本的起始处 | text start | |
C-S-End |
选择到当前文本的结束处 | text end |
导航,在不同文件中切换。常用的导航我用一段式快捷键。一定要善用前三个,对编码效率绝对是很大的提高,起码不会让切换文件的速度脱慢你的思路。
C-M-G
是 C-M-g
的高级版,直接从实例名跳转到其类中。
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
C-M-f |
上一个位置 | forward | * |
C-M-b |
下一个位置 | back | * |
C-M-g |
跳转到定义处 | declaration | |
C-M-G |
跳转到类型 | type declaration | * |
C-M-u |
跳转到父类方法 | super method | * |
M-←/→ |
左右切换标签 | select tab |
如果对简洁有要求或者屏幕太小(比如我),可以将 Android Studio 的标签关掉,具体参考:Configuring Behavior of the Editor Tabs,那么这时 M-←/→
就不会起作用了。
另外 M-num
都被 Android Studio 绑定到切换功能窗体,很实用但不一一罗列了,比较常用的是 M-6
打开 Android Monitor、M-7
打开 Structure,至于 Project 有更好的快捷键打开。
C-num
用于跳转书签。C-M-num
则用于设置书签,书签是全局的。
Select in… 可谓的鼠标杀手中的 MVP,多少鼠标操作就是为了在其他窗体中操作当前文件。大部分情况下它都是比 M-num
更好的选择。默认的 Alt+F1
与系统冲突,我修改为 Esc,S-i
,好记,不过需要前缀键确实难为了这个命令。
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
Esc,S-i |
在其它窗体中选择,比如在项目窗体定位当前文件 | select in.. | * |
S-Esc |
隐藏工具窗口,配合上个命令使用更佳。 | Hide Active ToolWindow | |
C-S-Esc |
隐藏所有工具窗口 | Hide All ToolWindow | * |
看快捷键说明, Android Studio 有 kill ring 的概念,比如 M-w
、C-w
、M-d
、M-backspace
都是操作 kill ring,但是居然没有 yank pop,所以 M-y
只能绑定为不太实用 paste from history…,话说你把 kill ring 藏到哪了?
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
C-x,C-f |
打开文件 | file | |
C-x,b |
切换 Buffer | switcher | |
C-x,k |
关闭当前文件 | close | |
C-_ |
撤销 | undo | |
M-_ |
重做 | redo | |
C-w |
剪切 | kill selected | |
M-w |
复制 | save to kill ring | |
C-y |
粘帖 | paste | |
M-y |
粘帖历史选择 | paste from history | |
M-S-↑/↓ |
移动当前行 | move line up/down | |
C-S-↑/↓ |
移动当前语句/代码块 | move statement up/down | |
M-; |
注释当前行 | line comment | |
M-: |
注释块 | block comment | * |
C-= |
展开 | expand | |
C-M-= |
全部展开 | expand all | * |
C-- |
收缩 | collapse | |
C-M--) |
全部收缩 | collapse all | * |
Android Studio 对分割窗格的支持,基本可以做到和 Emacs 一致,除了 C-x, 0
,在 Android Studio 中它的行为与 C-x, k
一致。
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
C-x, 1 |
关闭其他窗格 | unsplit | |
C-x, 2 |
平行分割当前窗格 | split | |
C-x, 3 |
竖直分割当前窗格 | split | |
C-x, 0 |
关闭当前窗格 | unsplit | |
C-x, o |
切换不同窗格 | goto next spliteer |
C-D
是 Dash 插件的默认快捷键,用于在 Dash/Velocity/Zeal 中搜索
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
C-s |
文本内搜索/下一个匹配 | find next | |
C-r |
下一个匹配 | find previous | |
M-% |
替换 | replace | |
C-S |
全局搜索 | find in path | * |
C-R |
全局替换 | replace in path | |
M-S |
查找使用 | find usage | |
C-D |
Dash 中搜索 | Search in Dash | * |
Android Studio 支持宏功能,默认的宏操作都没有配置快捷键,不过没有 C-u
宏的实用性大打折扣
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
C-x, ( |
开始宏录制 | start macro | |
C-x, ) |
停止宏录制 | stop macro | |
C-x, e |
运行宏 | play last macro |
重构的 Extract 都被绑定为 C-M-key
,本来也是不错的选择,可惜太多冲突,我改为 Esc,key
,取首字母相同来助记。前缀键我在 Esc
和 C-c
间犹豫了下,显然 C-c
效率更好,但我觉得重构的话,在操作前有个停顿思考下也不是坏事。所以最终选择了 Esc
。
C-M
的默认功能与 C-[
、C-]
重叠了,所以不如绑定为显示方法的参数信息,毕竟 Android Studio 的代码补全不支持显示方法参数,所以这个功能也是很有必要的。
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
M-/ |
代码补全,按两次能显示更多选项 | completion | |
C-M-/ |
代码补全,智能类型 | completion | |
M-enter |
显示建议行为,类是 quick fix | show intention actions | |
C-q |
快速文档 | quick document | |
C-I |
快速显示定义 | quick definition | |
C-P |
显示当前表达式返回类型 | expression type | |
C-M |
显示方法参数信息 | parameter info | * |
C-x, f |
格式化 | reformat code | * |
C-x, r |
重命名 | rename | * |
C-x, i |
优化 imports | optimize imports | * |
C-x, j |
插入在线模板,代码补全也支持补全模板 | insert live template | * |
C-x, g |
生成代码 | generate | * |
C-x, s |
生成包围代码 | surround with | * |
Esc, f |
提取为字段 | field | * |
Esc, c |
提取为常量 | constant | * |
Esc, m |
提取为方法 | method | * |
Esc, p |
提取为参数 | parameter | * |
Esc, v |
提取为变量 | variable | * |
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
S-F10 |
运行当前配置 | run | |
S-F9 |
调试当前配置 | debug | |
C-S-F9 |
运行当前 Activity | run context configuraton | |
M-S-F10 |
弹出运行选择菜单 | run | |
M-S-F9 |
弹出调试选择菜单 | debug |
大部分 Vcs 相关的快捷键默认配置都和上面的配置冲突了,考虑到用于跳转的 M-g
前缀键只用了 3 个,我的 VCS 只用 git
一个,所以把 git 相关操作用 M-g
前缀键重新编排下。因为很少用到,可能不太合理,一些 git 的基本操作我都是都是直接在 Shell 里输入。
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
M-g, + |
add 当前文件 | add to vcs | * |
M-g, C-c |
commit | commit | * |
M-g, C-z |
revert | revert | * |
M-g, C-f |
fetch | fetch | * |
M-g, C-u |
push | push | * |
M-g, C-a |
annotate | annotate | * |
M-g, C-p |
pull | pull | * |
M-g, C-d |
比较文件,在历史记录窗体可直接对比 | compare file/show diff | * |
M-g, C-D |
弹出比较文件浮窗 | compare with | * |
M-g, C-h |
显示当前文件历史 | show history | * |
M-g, C-H |
显示当前选择区域历史 | show history | * |
某些情况鼠标还是比键盘更高效
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
Button2 |
矩形选择 | ||
M-S-Button1 |
放置多个光标 |
live-plugin 是一个强大的插件,能给 Android Studio 提供类似 elisp 的运行环境及解析器,通过 groovy 脚本在运行时更改 IDE 的行为,所谓的 InternalReprogrammability。
我将其运行当前插件的快捷键更改为 Emacs 中执行 elisp 表达式的快捷键。其他的我还没有深入使用,只是写了个脚本用来实现单词首字母大写功能,并将其绑定到 M-c
,见 capitalizeWord。原理主要是 registerAction
来增加自定义 action,自定义的 action 能绑定快捷键,也能通过 find action 来搜索十分方便。
快捷键 | 功能 | 关键字 | 自定义 |
---|---|---|---|
C-x, C-e |
运行当前插件 | run current plugin | * |
C-x, C-t |
测试当前插件 | test current plugin | * |
M-c |
单词首字母大写 | captilazie word | * |
这只是我常用的或偶尔用到的快捷键的罗列,不是完整的 Android Studio 快捷键说明,还有很多 Android Studio 的基本功能没有涉及到,记下来只是为了备忘和分享。这份快捷键会持续变更,可在 douo_keymaps 查看最新的配置。