前面几节讲的是"怎么写",这一节讲"怎么保证团队写出来的代码跟你一样好"。这题的面试形态很特别——它不考语法也不考 API,考的是你有没有一套能落地的质量保障机制。答得浅的人说"团队 review 很严格",答得深的人会讲清"哪些交给机器、哪些必须留给人,以及告警怎么管"。
先把结论放在前面:Kotlin 代码质量保障分三层,越靠下越应该自动化,越靠上越需要人的判断。①工具层:ktlint(格式)、detekt(潜在缺陷)、Kotlin 编译器警告——机器管风格与确定性缺陷;②约定层:团队规范文档 + PR 模板 + 关键检查项——半强制;③设计层:架构边界、抽象选择、命名语义——只能靠人。核心原则是:把机器能查的规则化,人的 review 时间才腾得出来看设计。这一节的判断力就体现在"知道哪条规则该放进哪一层"。
机制背后的执行路径
先说清三个工具各自负责什么,避免职责重叠导致告警重复:
| 工具 | 负责 | 典型规则 | |---|---|---| | ktlint | 代码格式 | 缩进、换行、import 顺序、尾随空格 | | detekt | 潜在缺陷与复杂度 | NestedBlockDepth、LongMethod、MagicNumber、UnsafeCallOnNullableType | | 编译器警告 | 语言级风险 | 未使用变量、可疑的 when 不完整、废弃 API |
关键是不重叠。如果 ktlint 管缩进、detekt 也管缩进,同一个问题会报两次,告警立刻变得无法阅读——这是很多团队上工具失败的直接原因。
然后是 detekt 的规则分级。error 级别应当让构建失败(真正会出 bug 的),warning 级别只报告,info 级别可选。举几个在 Android 项目里真正有用的规则:
// detekt 配置片段
buildUponDefaultConfig = true
config.setFrom("$dir/config/detekt.yml")
complexity:
active: true
NestedBlockDepth:
active: true
threshold: 3 # 缩进超过 3 层即报
LongMethod:
active: true
threshold: 40 # 单函数超 40 行即报
LongParameterList:
active: true
functionThreshold: 6
TooManyFunctions:
active: true
thresholdInClasses: 12
exceptions:
TooGenericExceptionCaught:
active: true # 禁 catch(Exception) 后不处理
SwallowedException:
active: true # 禁吞异常
PrintStackTrace:
active: true
style:
MagicNumber:
active: true
ignoreNumbers: ['-1', '0', '1', '2']
ReturnCount:
active: true
max: 6 # 卫语句会提高 return 数,阈值要放宽
UnusedPrivateMember:
active: true
注意 ReturnCount 这一条——第 115 篇刚讲了卫语句会天然增加 return 数量。规则阈值要匹配你想推行的编码风格,否则团队会被迫关掉规则。这是"判断力"的具体体现。
真实工程场景的推演
场景一:上线前把告警清零的节奏。真实做法是分三步:①先跑一次全量报告,把现有告警分类——改(真 bug)/ 配(规则不适配)/ 延(历史债);②把"改"里高危的先修;③对"配"的调低级别或关掉,对"延"的用 baseline 文件记录当前值,新增告警才算失败。这个 baseline 机制是让老项目也能上 CI 的关键。
场景二:baseline 的正确用法。detekt 支持 baseline.xml:
# 首次:生成基线
./gradlew detektBaseline # 生成 config/detekt/baseline.xml
# CI:新增告警即失败(baseline 里没有的会被拦下)
./gradlew detekt -PdetektBaseline=config/detekt/baseline.xml
这解决了"存量代码有问题、想上 CI 又不能一次改完"的困境,是工程落地的关键一步。
场景三:PR 模板里的检查项。工具管不到的判断,需要写进模板让人工确认。有效的 PR 模板项是可回答的问题,不是笼统提醒:
## 变更说明
- [ ] 本次改动的影响范围(模块 / 页面 / 接口)
- [ ] 是否涉及数据库或 SP 结构变更(是 → 补充迁移方案)
- [ ] 是否新增了第三方依赖(是 → 补充体积与合规评估)
## 自检
- [ ] 协程:取消是否正确传递?有无 GlobalScope?
- [ ] 空安全:新增的 Java 互操作是否做了归一化?是否用了 !!
- [ ] 状态:UI 状态是否用 sealed?StateFlow 与事件是否分离?
- [ ] 生命周期:是否用 viewLifecycleOwner?收集是否包 repeatOnLifecycle?
- [ ] 性能:是否在主线程做 IO?列表是否给 key?
场景四:告警治理的真实教训。团队常见失败模式是"上工具 → 告警几百条 → 全局压制 → 工具形同虚设"。避免的关键是从第一天就分级 + baseline,而不是"先全开看看"。
最常见的坑是
只上工具不立规范,告警堆积后被整体压制形同虚设。 表现是 @Suppress 铺满代码、detekt 变成"摆设"。修法:①分级(error 才阻断构建);②对历史债用 baseline;③每条抑制都要写注释说明理由,review 时抽查。
其次是规则与团队风格冲突(典型是 ReturnCount 与卫语句、LongParameterList 与数据类)。修法:调阈值或关掉该条,而不是让成员到处加 @Suppress——后者是在对抗工具,最终会失去约束力。
还有一个更隐蔽的坑:把设计层问题塞进静态检查。试图用规则限制"不能滥用继承"这类判断,结果规则要么太宽形同虚设、要么太窄误报。修法:设计层靠架构文档 + PR 模板里的判断题 + 少数人的 review 质量,不靠工具。
现场手写这一段就够了
// 1) 自定义 detekt 规则:表达团队特有的约定
class NoGlobalScopeRule(config: Config) : Rule(config) {
@Configuration("是否禁止 GlobalScope")
@Configuration("允许的包名前缀白名单")
private val whitelist: String = ""
override fun visitKtFile(file: KtFile) {
file.collectDescendantsOfType<KtDotQualifiedExpression>().forEach {
expr ->
val txt = expr.text
if (txt.startsWith("GlobalScope") && !txt.startsWith(whitelist)) {
report(CodeSmell(issue = "禁止 GlobalScope:任务没有归属,页面退出后仍会运行",
entity = expr, priority = Priority.HIGH))
}
}
}
}
# config/detekt.yml
complexity:
NestedBlockDepth:
active: true
threshold: 3
LongMethod:
active: true
threshold: 40
exceptions:
SwallowedException:
active: true
TooGenericExceptionCaught:
active: true
PrintStackTrace:
active: true
style:
MagicNumber:
active: true
ignoreNumbers: ['-1','0','1','2']
ReturnCount:
active: true
max: 6 # 卫语句风格需要放宽
UnusedPrivateMember:
active: true
// 2) 自我约束的写法:让规则意图体现在类型上
class CoroutineScopeHolder {
// 名字即约束:它必须被 cancel
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun scope(): CoroutineScope = scope
fun close() = scope.cancel()
}
// 3) 机器查不到、需要人判断的部分:写成单测与文档
// 单测是"可执行的规范"
@Test fun `event stream must not replay`() = runTest {
val vm = LoginViewModel()
vm.events.emit(ShowError("旧错误")) // 订阅前发射
val received = mutableListOf<LoginEvent>()
val job = launch(UnconfinedTestDispatcher()) {
vm.events.collect {
received += it } }
advanceUntilIdle()
assertTrue(received.isEmpty()) // 断言:不应收到旧事件
job.cancel()
}
关键行解读:自定义 detekt 规则能把"禁止 GlobalScope"这类团队约定变成机器检查;detekt.yml 的阈值要与编码风格匹配(ReturnCount 放宽是卫语句风格的必要让步);把 scope 包装成 Closeable 形态,让"必须取消"变成类型与命名上的提示;可执行的单测是最可靠的"规范文档"。
面试追问四连
"detekt 和 ktlint 怎么分工?" 答:ktlint 管格式(缩进、换行、import 顺序),detekt 管潜在缺陷与复杂度(嵌套深度、方法长度、吞异常、魔法数字)。原则是不重叠——同一问题报两次会让告警失去可读性。
"老项目代码有问题,怎么上 CI?" 答:先生成 baseline 记录存量告警,CI 只拦截"新增"告警;再分批清理存量(按优先级:安全 > 正确性 > 风格)。工具的价值是防止劣化,不必强求一次清零。
"哪些规则不能交给静态检查?" 答:设计层判断——抽象是否合理、模块边界是否恰当、命名是否表达业务语义、错误处理策略是否符合业务预期。这类问题需要人理解意图,静态检查只能拦住语法层。
"怎么防止 review 变成挑格式?" 答:格式与确定性缺陷统一交给工具,PR 模板只保留需要判断的问题(影响范围、架构边界、风险预案),让人把时间花在设计而非空格上。
落地建议
1. 落地顺序:先 ktlint(纯格式、零争议)→ 再开 detekt 的确定性规则(吞异常、未使用成员)→ 最后开复杂度规则。每步都要分级 + baseline。 2. 写一份规则与理由对照表(为什么开、为什么调阈值、哪些关掉),让后来人能理解每个决策,而不是只能看到一份"看起来很严"的配置。 3. PR 模板只保留可回答的判断题(影响范围、数据迁移、协程/空安全/生命周期三类高危项),格式类不写进模板。 4. 自动化预防:把每条 @Suppress 都要求带注释说明理由,用脚本定期抽查无理由的抑制;CI 上跑 ktlint 检查、detekt 检查、baseline 差异对比,三者任一失败即阻断合并。
给正在准备面试的你 把这题画成"三层质量保障金字塔"。最底层"工具层"(ktlint 格式 / detekt 缺陷 / 编译器警告),标"确定性规则,机器管,CI 阻断";中间层"约定层"(团队规范文档 / PR 模板 / baseline 基线),标"半强制,靠流程与抽查";最上层"设计层"(架构边界 / 抽象选择 / 命名语义),标"只能靠人,工具无效"。图右侧竖排写一条箭头标"人力投入方向"——越往上人花的时间越多,工具的意义是把下两层的人力解放到上层。
再补一个高分追问答案:"怎么让代码评审真正有质量,而不是走过场?" 四条实操:①限制 PR 体积(超过 400 行建议拆分),大 PR 的评审质量会急剧下降;②模板只问需要判断的问题,格式类交给工具,评审者不必逐行抠空格;③分级评审——核心模块(支付、登录、数据层)由指定人评审,普通模块同组互评;④评审意见分类——blocker(必须改)/ suggestion(建议)/ nit(吹毛求疵)分开标注,作者可以只回应 blocker,避免讨论被细节淹没。答出"怎么让人把时间花在设计上",就是这题的核心。
复习时别孤立刷题:卫语句与早返回——上一节讲单条编码纪律,本节讲如何把这类纪律从"个人习惯"变成"团队可执行的标准",是个人能力到工程化的跨越。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:卫语句与早返回:可读性重构实战
下一篇预告:Kotlin-阶段面试通关地图:高频考点串联复盘
有任何问题欢迎在评论区留言交流。