作者归档:贺 利华

关于贺 利华

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

【开发日志-00】给 Flutter Plugin 加一个自己需要的特性

【开发日志-00】给 Flutter Plugin 加一个自己需要的特性

开个坑,公开记录一下填坑的过程,如果烂尾了,也给自己打个脸。往年每次年底做总结的时候,会惨兮兮地发现这一年都白瞎了,啥进步没有,感觉又枉费这美好人间,腆个逼脸在本上写下明年一定要参与一个什么什么开源项目,然后本子都不知道到哪儿去了,到了又一年年底都忘了当时是不是立了啥具体的 Flag,只是依稀记得,好像自己又打了一次自己的脸,立了 Flag 但是没有立住。

虽说这个公开记录在互联网上多如牛毛,咱们这个犄角旮旯也照不进来啥阳光,估计也没啥人能看到,但是至少不会像本子一样最后找不到了(当然如果本子好好保留时长回顾也是很好的),姑且咱们假设这次可能有用吧,谁知道呢,或者到了明年这个时候,我们发现这个小破站也无法访问了,或者压根儿就没人在意,我自己也不把这次打脸当回事呢,是吧。


从 19 年 6 月开始,我就开始做 Flutter 开发的相关工作了,到现在也已经 2 年半时间了。由于我们做的项目是在线直播相亲业务,很多直播间类型都是强交互的,经常调试的时候会同时连接多台设备,分别调试不同的角色在直播间的行为,例如主播端、连麦用户端、观众端。开发调试的过程中,经常改了一些 UI 布局和逻辑代码后,想着利用 Flutter Hot Reload 或 Hot Restart 的特性来快速调整验证,但是当我想在特定的某一台设备上执行 Flutter Hot Reload 的动作时,却总是找不到合适的方法来快速实施,最终我只能通过 IDEA 底部的 Tool Window Run 或者 Tool Window Debug 中侧边栏的重载按钮在每台连接的设备上都执行一遍,然后通过日志输出中的 Launching lib/main.dart on PDPT00 in debug mode... 的设备名称来区分当前是在那台设备上执行的。这让我觉得一点都不酷,也不知道众多 Flutter 开发者平时是否有更好的其他小妙招之类的,兴许这个插件的开发人员们平时很少会有连接多台设备同时调试的需求吧,既然我有这个需求,而且一直都蛮强烈的,那么我就自己尝试开个坑自己填吧。

由于我自己常用的 IDE 是 JetBrains 的 Intellij IDEA,所以我自己首选的先是解决我自己的问题,从 Flutter Plugin for IntelliJ 入手吧。

我直接打开了 Github,搜索 Flutter Plugin for IntelliJ 找到了官方的仓库 https://github.com/flutter/flutter-intellij 顺手到了自己的仓库下,然后先 clone 下来。在等待仓库 clone 的过程中,我就开始阅读这个项目的 README 了,出人意料的是,我完全没有想到这么多人每天都在用的插件的官方 repo 的 README 中竟然没有关于如何编译和开发这项目的任何文档,最终我是通过这个 repo 的 issue tracker 中的 contributing guidelines 文档才找到了相关的指引文档。但是坦白讲,折腾经验还算丰富的我,按照文档一步步执行下来,最终还是败下阵来了——Gradle Build失败了。按照失败的各种错误提示,各种 Google + Stackoverflow + 官方的 issue tracker 还是没能解决我的问题,索性那么我们就把这个当个长期坑来看吧,先学习一下如何开发 IntelliJ 的插件,然后再看如何改这个 Flutter Plugin for IntelliJ 吧。


刚好再重看了一遍这个 repo 中的 README 中的 Getting Started with IntelliJ 文档,刚好看到了其中关于 Running and debugging 的内容,如下图:

所以,我把我刚刚说的无法快速在某台指定设备上执行 Flutter Hot Reload 行为的胡话收回,说明我原来用的姿势不对,确实没有发现这么简单和常用的功能(当然实际上我肯定是用过的,只是这个操作区域跟我时常需要关注的 IDE 底部的 Console 离得太远了,所以慢慢地就忽略了这个入口了,更多的时候是直接使用了 Tool Window Run/Debug 中顶部的 Flutter Hot Reload 和 Flutter Hot Restart 按钮来实现的,对了 Flutter Hot Restart 只在底部的 Tool Window Run/Debug 中才有哦),由于平时经常需要关注 Console 中输出的日志信息,所以养成了关注底部窗口的相关,操作也一贯是在这个窗口上执行的,但是这个 Window 中只显示了一个 main.dart 的标题,如下图:

如果能在这个标题上加上一个当前执行的设备的名称,是否会更直观一些呢,例如显示 main.dart on V2057A 或者 main.dart@V2057A 这样是不是就更好了呢。嗯哼,这就是我想做的事情了。

坑已开,等着填。我要开始学习了,IntelliJ Platform SDK 会是我们的第一站,我会一点点记录学习过程中所得的。

macOS 下 Unity 执行子进程调用时无法读取到系统环境变量问题修复备忘

发现问题

由于我们的游戏工程中集成了多个渠道的 Android 版本的 SDK,而不同的 SDK 依赖的一些库多少有些区别,而且不同的 SDK 通常都有着各种不同的需求,最终我们的处理方式是针对不同的 SDK 做不同的打包预处理,这样就可以在打不同的渠道包的时候,按照不同的 SDK 的需求做相应的处理,确保不同的 SDK 的渠道包里头包含的库文件和资源文件不同。

这样我们就在打包执行先执行了一个 Gradle 的 build 脚本,通过 Gradle 的脚本中不同的任务来完成针对不同 SDK 的 Android 工程的编译以及资源文件的删除和拷贝等等。

但是在我们的 Jenkins 打包服务器上打包出来的 APK 文件安装到手机上,启动游戏之后总是提示丢失了类文件,直接就闪退了,这说明打包的过程中出现了问题。一步步排查,最后发现在打包预处理的 Gradle 脚本执行的时候抛出了异常,异常的提示信息也非常的明确,就是 Gradle 在执行 Android 工程的 build 时缺少 ANDROID_HOME 系统环境变量或者在 Gradle 工程的 local.properties 中缺少了 sdk.dir 的设置项,导致 Gradle 在编译 Android 工程时无法正确地引用 Android SDK 进行编译,最终导致 Unity 打包出来的 APK 中缺少了类最终造成了启动就闪退的问题。

解决方案

那么最简单的方法当然是通过添加 local.properties 文件来解决这个问题,可是我看了一眼 Android Studio 生成的 local.properties 文件的内容:

## This file is automatically generated by Android Studio.
# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
#
# This file must *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
#Thu Mar 22 17:12:39 CST 2018
ndk.dir=/Users/helihua/Library/Android/sdk/ndk-bundle
sdk.dir=/Users/helihua/Library/Android/sdk

这货原本就是 Android Studio 创建 Android 工程时或者说是打开工程时根据当前的系统变量自行生成或者设置的,而且明确告知别把这个文件放到 git 等版本管理控制中去,也就是说别把你本地的一个配置给推送到服务器上去,防止别人从服务器上拉取下来你这个本地配置,这样就可能导致团队中其他的小伙伴在编译的过程中出错。

那么我们自然也不能选择这种方式了,而且 Android Studio 这货非常省心地帮你把这个文件都给放到了工程的 .gitignore 中去了,如下:

*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.externalNativeBuild

看,人家都不给你犯错误的机会呢,所以说我们还是找别的方法吧。

由于我们的项目中执行 Gradle 脚本是通过 C# 提供的执行子进程的方式来完成的,那么找不到 ANDROID_HOME 这个环境变量的进程实际上就是我们执行预处理的那个 C# 进程了。那么有没有可能单独针对这个进程设置系统变量呢,一查文档还真有,那么就可以简单的这么处理了:

gradleProcess.StartInfo.EnvironmentVariables["ANDROID_HOME"] = EditorSetup.AndroidSdkRoot;

这样一来问题自然就得到解决了,EditorSetup.AndroidSdkRoot 这个变量返回的是我们在 Unity Editor 中设置的 Android SDK 的路径,当然具体要不要用这个变量就看自己的选择了,通常来说如果我们想要正常打包 Android 的包的话,这个变量肯定是正确的 Android SDK 的路径了。

延伸思考

这个问题越想越让人觉得奇怪,你说我作为一个前 Android 开发者,怎么可能会没有配置 macOS 的系统环境变量呢,电脑上 ~/.profile 文件中早都配置好了 ANDROID_HOME 等等环境变量的啊。那么为什么现在才暴露出来呢?

  1. Android Studio 等 IDE 在 macOS 上不只是会读取 macOS 的环境变量,还会尝试去读取 ~/.profile 和 ~/.bash_rc 等等文件中设置的环境变量,所以 Android Studio 等 IDE 在启动的时候就能正确检测到我们已经在 ~/.profile 等文件中配置好的 ANDROID_HOME 变量了;
  2. Unity Editor 并不会尝试去读取 ~/.profile 等文件中设置的环境变量,而只是从其父进程 launchd 中继承了已设置的环境变量,而这些环境变量中并没有 ~/.profile 中我们通过 export 设置的各种环境变量,平时我们执行很多 Gradle 命令的时候,通常都是直接从终端中执行的,而终端程序除了会从 launchd 进程中继承已有的环境变量还会加载 ~/.profile 等文件中的环境变量的,所以导致了实际上这个问题是一直存在的只是没有暴露出来。

最终问题的根源找到了,是因为 Unity Editor 这个进程中的环境变量中缺失了 ANDROID_HOME 这个变量,那么最终需要做的就是让 Unity Editor 这样的程序的环境变量中有 ANDROID_HOME 这个变量,在杨威同学和 Google 的帮助下,最终确定的方法就是通过创建一个交给 launchctl 加载的 plist 文件,在 plist 文件中声明我们需要执行的命令 launchctl setenv 和对应的参数,让系统在启动的时候自行通过 launchctl setenv 命令将 ANDROID_HOME 等环境变量设置到 launchd 这个牛逼的父进程中去。

我们可以先看一下 macOS 系统上进程的层级关系:

我们可以看到所有进程实际上都是由 kernel_task -> launchd 这样一路 fork 出来的,看看进程 ID 也能明白得差不多了。废话少说,怎么做呢?

好吧,我参考了这篇文章

大体的流程就是这样的:

  1. 创建一个用于 launchctl 在系统启动的时候自动加载的 plist 文件,在该 plist 文件中调用 launchctl setenv 命令设置参数;
  2. 将 plist 文件放到 ~/Library/LaunchAgents 目录下去,然后重启系统即可,当然如果你现在不想重启系统,可以执行 launchctl load -w ~/Library/LaunchAgents/[com.laputa.SetAndroidEnviroment.plist] 命令(替换方括号中的 plist 文件名)来主动加载该 plist 文件,加载成功后,重新启动 Unity Editor,在 plist 文件中设置的环境变量在 Unity Editor 进程中就生效了。

把我的 plist 文件贴上来:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
  <plist version="1.0">
  <dict>
    <key>Label</key>
    <string>setenv.Android</string>
    <key>ProgramArguments</key>
    <array>
      <string>/bin/launchctl</string>
      <string>setenv</string>
      <string>ANDROID_HOME</string>
      <string>/Users/helihua/Library/Android/sdk</string>
      <string>ANDROID_NDK_HOME</string>
      <string>/Users/helihua/Library/Android/sdk/ndk-bundle</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
  </dict>
</plist>

注意事项:plist 文件如果向上文提到的那个链接中的文章中放在 /Library/LaunchDaemons 目录下的话,重启之后貌似就不会生效,而放到 ~/Library/LaunchAgents 目录下在重启之后是可以生效的,我是在 macOS High Sierra 10.13.3 上测试的。

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

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

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

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

Running dex as a separate process.

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

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

BUILD FAILED

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

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

org.gradle.jvmargs=-Xmx2048m

理解 Git 工作流

理解 Git 工作流

英文原文:Understanding the Git Workflow

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

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

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

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

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

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

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

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

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

重新认识版本控制

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

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

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

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

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

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

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

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

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

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

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

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

工作流

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

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

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

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

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

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

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

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

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

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

指南和示例

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

短期的改动

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

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

git checkout -b private_feature_branch
touch file1.txt
git add file1.txt
git commit -am "WIP"

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

git checkout master
git merge --squash private_feature_branch
git commit -v

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

更大的改动

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

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

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

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

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

git rebase --interactive master

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

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

pick ccd6e62 Work on back button
pick 1c83feb Bug fixes
pick f9d0c33 Start work on toolbar

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

pick ccd6e62 Work on back button
squash 1c83feb Bug fixes
pick f9d0c33 Start work on toolbar

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

功能分支废了

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

git checkout master
git checkout -b cleaned_up_branch
git merge --squash private_feature_branch
git reset

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

总结

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

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

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

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

译后碎碎念

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

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

如何写好 Git 提交记录

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

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

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

$ git log --oneline -5 --author cbeams --before "Fri Mar 26 2009"

e5f4b49 Re-adding ConfigurationPostProcessorTests after its brief removal in r814. @Ignore-ing the testCglibClassesAreLoadedJustInTimeForEnhancement() method as it turns out this was one of the culprits in the recent build breakage. The classloader hacking causes subtle downstream effects, breaking unrelated tests. The test method is still useful, but should only be run on a manual basis to ensure CGLIB is not prematurely classloaded, and should not be run as part of the automated build.
2db0f12 fixed two build-breaking issues: + reverted ClassMetadataReadingVisitor to revision 794 + eliminated ConfigurationPostProcessorTests until further investigation determines why it causes downstream tests to fail (such as the seemingly unrelated ClassPathXmlApplicationContextTests)
147709f Tweaks to package-info.java files
22b25e0 Consolidated Util and MutableAnnotationUtils classes into existing AsmUtils
7f96f57 polishing

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

$ git log --oneline -5 --author pwebb --before "Sat Aug 30 2014"

5ba3db6 Fix failing CompositePropertySourceTests
84564a0 Rework @PropertySource early parsing logic
e142fd1 Add tests for ImportSelector meta-data
887815f Update docbook dependency and generate epub
ac8326d Polish mockito usage

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

记住: 多次 强调过

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

举个例子:

Summarize changes in around 50 characters or less

More detailed explanatory text, if necessary. Wrap it to about 72
characters or so. In some contexts, the first line is treated as the
subject of the commit and the rest of the text as the body. The
blank line separating the summary from the body is critical (unless
you omit the body entirely); various tools like `log`, `shortlog`
and `rebase` can get confused if you run the two together.

Explain the problem that this commit is solving. Focus on why you
are making this change as opposed to how (the code explains that).
Are there side effects or other unintuitive consequences of this
change? Here's the place to explain them.

Further paragraphs come after blank lines.

 - Bullet points are okay, too

 - Typically a hyphen or asterisk is used for the bullet, preceded
   by a single space, with blank lines in between, but conventions
   vary here

If you use an issue tracker, put references to them at the bottom,
like this:

Resolves: #123
See also: #456, #789

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

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

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

翻译一下:

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

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

Fix typo in introduction to user guide

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

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

 $ git commit -m "Fix typo in introduction to user guide"

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

 Derezz the master control program

MCP turned out to be evil and had become intent on world domination.
This commit throws Tron's disc into MCP (causing its deresolution)
and turns it back into a chess game.

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

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

$ git log
commit 42e769bdf4894310333942ffc5a15151222a87be
Author: Kevin Flynn <[email protected]>
Date:   Fri Jan 01 00:00:00 1982 -0200

 Derezz the master control program

 MCP turned out to be evil and had become intent on world domination.
 This commit throws Tron's disc into MCP (causing its deresolution)
 and turns it back into a chess game.

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

$ git log --oneline
42e769 Derezz the master control program

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

$ git shortlog
Kevin Flynn (1):
      Derezz the master control program

Alan Bradley (1):
      Introduce security program "Tron"

Ed Dillinger (3):
      Rename chess program to "MCP"
      Modify chess program
      Upgrade chess program

Walter Gibbs (1):
      Introduce protoype chess program

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

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

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

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

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

标题字符过长的警告

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

截断并使用省略号

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

3. 标题首字母大写

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

  • Accelerate to 88 miles per hour

而不是这样:

  • accelerate to 88 miles per hour

4. 标题行末不使用句号

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

我们可以这样:

  • Open the pod bay doors

但是不要这样:

  • Open the pod bay doors.

5. 在标题中使用祈使句

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

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

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

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

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

Merge branch 'myfeature'

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

Revert "Add the thing with the stuff"

This reverts commit cc87791524aedd593cff5a74532befe7ab69ce9d.

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

Merge pull request #123 from someuser/somebranch

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

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

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

  • Fixed bug with Y
  • Changing behavior of X

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

  • More fixes for broken stuff
  • Sweet new API methods

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

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

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

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

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

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

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

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

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

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

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

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

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

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

commit eb0b56b19017ab5c16c745e6da39c53126924ed6
Author: Pieter Wuille <[email protected]>
Date:   Fri Aug 1 22:57:55 2014 +0200

   Simplify serialize.h's exception handling

   Remove the 'state' and 'exceptmask' from serialize.h's stream
   implementations, as well as related methods.

   As exceptmask always included 'failbit', and setstate was always
   called with bits = failbit, all it did was immediately raise an
   exception. Get rid of those variables, and replace the setstate
   with direct exception throwing (which also removes some dead
   code).

   As a result, good() is never reached after a failure (there are
   only 2 calls, one of which is in tests), and can just be replaced
   by !eof().

   fail(), clear(n) and exceptions() are just never called. Delete
   them.

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

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

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

小帖士

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

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

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

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

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

好好读读 《Pro Git》 这本书

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