ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android编码规范修订版:一线实践总结的代码质量与工程化要点

Android编码规范修订版:一线实践总结的代码质量与工程化要点

我已阅读你的全部要求,现在直接进入正文撰写。以下是基于项目标题《Android编码规范(修订版)》生成的完整、可发布的博文内容。

1. 修订这份规范的初衷与核心痛点

2018年我在某项目组接手一个已经有二十万行代码的Android工程时,被当时的代码状态惊到了:一个Activity写了八千行,里面既有网络请求、又有数据库操作、还混着自定义View绘制逻辑,变量命名从a1到a10依次递增,资源文件里有三个同名布局在背后默默被覆盖。那段时间每次改需求都要先花半天时间梳理调用链,稍不留神就会踩到自己人挖的坑。

这份《Android编码规范(修订版)》不是我坐在办公室里凭空想出来的,它是从那段混乱的时期一点点被“揍”出来的经验汇总。换句话说,如果你团队里只有一两个人开发、项目就一两万行,你不需要这份规范;但只要项目撑过了三个里程碑、有两位以上同学在上面协作、开始有人因为“不知道某个改动会影响谁”而失眠,这份规范就能救你的命。

规范的定位很明确:它不是万能的,更不是束缚人的工具。它要解决的实际痛点有四类:第一,代码可读性差导致需求迭代效率低;第二,命名随意造成的功能冲突与排查困难;第三,线程和资源管理不规范产生的崩溃与性能问题;第四,团队协作时缺乏统一标准带来的Code Review成本高。这四类问题每一个都直接对应具体的规范条目,不是为了“规范而规范”。

我的建议是,这份规范不要一次全量推行,否则团队反弹会很大。最好分三步走:先把命名、代码风格、类职责边界这些“立竿见影”的条目落了,再推线程、异步、异常这些相对复杂的约定,最后把资源管理、依赖版本收敛、提交信息模板这些工程化内容固化到CI流程里。每一步跑两周左右,让大家在真实需求里适应新约束,而不是被一堆规则砸蒙。

2. 代码风格与基础可读性约定

2.1 命名规范脱离“你以为”的陷阱

命名是代码的“门面”,绝大多数可读性问题都能追溯到命名上。我见过最长的一个变量名是handleUserLoginSuccessResponseDataListMap,这哥们硬是把整个数据类型的描述都塞进了变量名里,里面其实只是一个HashMap。命名的核心目标是让读代码的人不假思索就能理解意图,而不是靠注释来解释。

命名方面我特别想强调一套实用的分层原则。包名按功能模块拆,比如ui、data、domain、common,不要出现utils2这种加班味十足的包名。类名用名词短语描述职责,比如UserProfileManager、ImageLoaderEngine,避免Util、Helper这种什么都往里装的大筐类。方法名必须是动词词组,get、set、save、remove、load、init,一看就知道要干什么。对于测试方法,我们采用方法名_场景_期望结果的三段式。

两个折磨过我们的具体细节:第一,布尔变量命名,一定用is、has、can、should开头,比如isVisible、hasData。不要用flag、status这些模棱两可的词。第二,常量全部大写加下划线,MAX_RETRY_COUNT、DEFAULT_TIMEOUT_MS,这个没有任何妥协余地,哪怕这个常量只在某个方法内部使用,也必须大写。

2.2 代码格式化与魔法值的彻底消灭

代码格式化的价值不在于美观,而在于降低团队成员的阅读切换成本。我们直接使用官方推荐的Kotlin代码风格作为基线,没有做额外定制。这一条必须明确写进规范里:一律使用标准格式化工具,不讨论、不妥协。理由很现实,每次Code Review都在争论“这里该不该换行”而不是“这个逻辑有没有问题”,那是在浪费大家的时间。

魔法值的处理要更严格。0、1、-1这些数字第一次出现时你觉得“这不显然吗”,两周后你自己都得跑一遍代码才知道那个0是TYPE_NONE还是SCROLL_DIRECTION_UP。我们的硬性规定是:业务相关的数字必须定义为常量或枚举,状态码、时间戳单位、可见性标志位、请求类型数字这些全部要有名字。举一个我们实际犯过的错:某页面上有个if (position == 2)的判断,当时大家都不知道2是什么,后来查提交记录发现是“第二个Tab”,而这个Tab被人删掉之后这个判断就默默变成了死代码。

注释方面只保留“为什么这么做”的注释,比如某个看似多余的判空是因为后端会返回异常值、某种写法是为了规避旧系统的一个Bug,这些必须写。而“这段代码是干嘛的”这种解释性注释,在一段命名良好的代码里根本不需要存在。好的代码注释密度大概在3%到5%左右,规范里明确要求“不要给废话写注释”,看到// 设置标题这种注释相当于在浪费读者时间。

3. Android组件与架构规范的硬性约束

3.1 Activity与Fragment的职责边界

这份规范里最核心的一条:Activity只做“页面容器”的事,负责创建视图、接收系统回调、分发用户事件,业务逻辑必须在其他类里完成。为什么把这条放在最前面?因为我见过一个Activity里同时持有网络请求、数据库游标、SharedPreferences引用、还有三个Handler的“超级上帝类”,后来这个页面每次改动升级都要回归整个模块,测试成本高到离谱。

我们规定的具体拆分方式是:Activity里不允许直接写网络请求调用、不允许直接操作数据库、不允许持有全局性业务状态。这些逻辑分别下沉到ViewModel(页面状态)、Repository(数据仓库)和Presenter(可选的控制层)。Fragment同样如此,很多同行以为“Fragment比较轻量就可以随便写”,结果每个Fragment三五屏起步,完全失去了组件化的意义。Fragment应该只提供视图片段和交互反馈,所有业务流转经ViewModel下发。

对于页面间通信,我们有条规定很实用:Activity之间传参必须走统一的IntentBuilder模式,Bundle的key都集中在一个契约类里。以前项目里传参是靠大家“约定”,上线后经常出现类型不匹配直接崩溃,最惨的一次是某个key的值从String改成了Long,但另一个模块还在用getStringExtra。

3.2 Service、广播与后台任务的纪律

Service这块踩过的坑值得单列。规范第一条:除非用户主动触发且必须在前台执行的任务,否则一律走前台服务,并且用startForeground()绑定通知。原因很简单,后台启动限制越来越严格,应用随时可能被系统“请出后台”。若某个操作不需要通知用户,就不应该做成Service,要么用协程在合适的生命周期里执行,要么交给短期任务框架。

广播分为静态注册和动态注册两类,我们的规范是:涉及系统级事件的用静态注册并在清单文件中声明,页面内部事件的用动态注册且必须在onStop或onDestroy中解除。曾经某个模块忘了在onDestroy里解绑广播,导致页面退出后还能收到广播、触发一个已经为空的View操作,这个问题极难复现,最后是通过反复开关页面压测才抓到的。

后台任务的另一个纪律是“无回调不启程”。所有耗时操作都必须提供成功和失败两条回调路径,不能在回调里做空实现。这是为了让调用者明确知道“操作有没有做完”,否则后台任务变成一个没人关心的黑洞,出了Bug完全无从查起。

4. 线程、异步与并发控制规范

4.1 主线安全与线程模型的强制约束

在Android平台上,UI操作的铁律只有一条:所有涉及View的操作必须发生主线程。这条原则本身并不难理解,真正可怕的是隐式违反它。比如在某个网络回调里直接更新TextView,换一个人在这个回调里加个循环就卡了UI;再比如用Handler处理Message时不检查线程,消息里带的是工作线程数据,直接刷到View上就会出现随机Crash。

规范里写清楚三个明确规则:耗时的IO操作、网络请求、数据库读写、文件操作必须在子线程执行;主线程里只做UI布局、绘制和轻量内存计算;涉及到跨线程切换时必须通过规范化的线程调度器,而不是随手new Thread。我们实现了一个简单的调度工具,内部封装了主线程Handler,同时在构造时强制要求传入线程名,方便问题排查。

对于线程池,规范要求所有线程池必须用有名字的ThreadFactory。一堆名为pool-2-thread-1的线程出现在崩溃日志里,你完全不知道它在处理什么业务。而一旦线程名变成ImageCompressWorker,问题定位就变成了按图索骥。

4.2 Kotlin协程的使用边界

项目切到Kotlin之后,协程成了处理异步的首选。但协程不是银弹,用不好同样状况百出。我们规范里对协程的使用划了几条硬线:子协程的异常必须通过CoroutineExceptionHandler或try/catch捕获,不允许有静默的失败出现;页面级别的协程一律挂到ViewModel的生命周期作用域中,避免Activity销毁后协程还在跑导致内存泄漏;Dispatchers.IO只用于真正的IO块,不要把一个CPU密集型的计算塞进去。

协程作用域的命名也做了规定:全局任务用CoroutineScope(SupervisorJob() + Dispatchers.Default),并且要持有在Application级别。页面任务用viewModelScope,不可在Activity里无脑GlobalScope.launch。很多新手喜欢直接用GlobalScope.launch写网络请求,结果页面都销毁了请求还在飞,回调回来的时候试图操作一个已经detach的View,瞬间变崩溃现场。

另外我们强制要求所有挂起函数都必须在函数签名的返回值或异常类型上体现“可能会失败”这个语义。也就是说,一个网络请求的挂起函数需要返回Result<T>,或显式声明抛出特定异常,不允许定义那种“看起来永不会失败”的挂起函数,因为一旦失败,调用方的错误处理就会是缺失的。

5. 资源文件、依赖与构建工程规范

5.1 资源命名的流水线式统一

Android资源文件的命名,是整个工程里最容易失控的部分。以前的项目里出现过btn_ok、button_confirm、iv_ok三种风格同时存在的情况,最后开发者都分不清到底该引用哪个。我们的资源命名规范是模块前缀加分隔符加类型加描述,比如订单模块的主按钮:order_btn_submit;用户模块的状态图标:user_icon_vip。字符串资源同理,统一加模块前缀,防止自动合并时资源覆盖。

一个很值得说的坑是资源覆盖。多个Module如果存在同名资源,构建时后合并的会覆盖前面的,这个错误在编译期基本没有提示,但运行时看到的界面会非常诡异。为了避免这个问题,我们规定每个Module必须在gradle.properties里启用资源命名空间隔离,同时在Gradle配置中开启resourcePrefix检查。这样没有加前缀的资源直接构建失败,把问题提前引爆。

布局文件的层级设计也列进了规范。布局文件优先使用ConstraintLayout减少嵌套层级,禁止出现层级超过5层的布局文件。一个复杂的页面如果嵌套超过6层,渲染性能会有明显劣化,尤其在低端机上表现特别明显。我们用Layout Inspector定期检查关键页面的视图层级,出现深层嵌套的就当场重构。

5.2 依赖版本收敛依赖与仓库规范

依赖管理是很多中小团队不太重视、但后期杀伤力很大的领域。我们在规范里声明了三个原则:所有第三方库必须显式指定版本号,禁止使用动态版本号如+;引入新库必须评估许可证和体积;同类型能力的库只保留一个,比如图片加载框架全部统一用同一种,网络框架统一同一种,不允许出现多个框架并存的情况。

重要的还有传递依赖的版本冲突。我们遇到过的最典型问题是:A库内部使用了旧版AndroidX,B库内部使用了新版AndroidX,它们都通过传递依赖带进工程,结果构建不报错、运行时一个页面崩溃。后来我们在规范里加上了一条硬规定:所有依赖必须明确指定AndroidX主版本,并且升级主版本时一定要做全量回归测试。这个操作看起来繁琐,但一次版本冲突导致的线上故障,足够让所有人老实两个月。

Gradle构建配置也有几条固定要求:compileSdk、targetSdk、minSdk三个版本号必须以变量形式集中在根项目的build.gradle里管理,子模块不允许再写死自己的一份。buildTypes里debug和release的混淆开关必须显式设置,不允许依赖默认值这种“隐式行为”。还有产物命名:应用名_版本号_构建类型_时间戳.apk,否则发版本时从几十个同名APK里挑一个正确的,简直是在开盲盒。

6. 异常处理、日志与前向兼容设计

6.1 异常捕获的“防崩溃”哲学

在Android开发里,我们处理异常的理念是双轨制:防止崩溃,同时留下证据。规范第一条:任何可能因为外部输入导致失败的位置,必须做防御性编程。这里的“外部输入”包括网络返回的JSON字段缺失、后端多传了空字符串、本地缓存文件被破坏、SharedPreferences里的数据被手工篡改等场景,全都要有过错方预案。

最典型的一个例子:JSONObject.optString("key")这个系列方法已经被我们全员强制使用,getString则被规范列为禁用。原因太实在了,getString在字段缺失时会抛异常,而大多数情况下后端一个疏漏,客户端就可能无辜崩溃。这种崩溃不是逻辑上的错,完全属于防御不足。

并发导致的数据竞争暴露面也很大。自定义View里的ArrayList如果有一个线程在读、另一个线程在写,崩溃概率是概率性的,时好时坏。我们规范要求在非主线程访问集合时必须使用CopyOnWriteArrayList或加同步锁,同时在检测到ConcurrentModificationException时不得吞掉异常,要把它转发到全局异常收集器里,哪怕是重复上报也要记录下来,方便确认修复是否真正生效。

6.2 日志分级与敏感信息红线

日志规范表面上简单,实际上非常容易失控。我们的分级约定是:ERROR只用于真正无法恢复的错误,此外任何重复业务行为都算WARN;INFO用于关键流程切换和用户行为节点;DEBUG用于开发期临时打印,上线前必须清除或降级;VERBOSE仅限极端调试场景。这个分级的基础是日志量如果失控,线上排查反而更加困难,因为有效信息被淹没在大量无效输出里。

敏感信息的处理是不可触碰的红线。任何用户手机号、身份证、密码、登录Token、完整网络地址、带有认证信息的Header,一律禁止直接打印。这在规范里是以“事故”级别来定义的,不只是代码风格问题。我们的做法是提供统一日志工具类,对需要打印的敏感字段做脱敏处理,比如手机号打码中间四位、Token只输出前后两位。另外,Log的调用最终统一走封装好的日志库,而不是开发者在各个类里手动调用原生Log,这样在Release包中可以直接通过开关把调试日志全部关掉。

我印象很深的一次线上事故:某次Debug日志没关干净,用户手机录屏后把日志信息发了出去,里面恰好带着登录票据的片段,结果被人拿去刷了接口,整整改了一个礼拜的安全逻辑。从那以后,我们不仅在规范里加了敏感信息红线,还在CI流水线上加了一步自动扫描:凡是提交代码中出现的Log.d调用且包含特定敏感关键词,直接拦截构建。

7. 提交习惯、Code Review与落地周期

7.1 Git提交信息与合并请求的规范

代码规范不能只停留在源码层面,还要管住提交和评审这两个环节,否则规范和实际代码会逐渐脱节。我们对于Git提交信息做了一套非常实用的模板:类型(模块): 描述,类型只允许feat、fix、docs、refactor、test、chore这几种,描述不要超过50个字符。比如fix(order): 修复订单页面下拉刷新后金额显示异常,这样扫一眼提交历史,就像在看一份项目的动态日报。

还有一条特别有争议但很有价值的规范:不允许提交无关联的多文件修改。举例来说,修一个订单Bug的提交里不应该混着一个表情图标资源的替换。看起来是小事,但它直接决定后续git blame时能不能准确定位某行代码是哪个任务引入的。有一次为了把一个混在提交里的资源替换找出来,我们当时花了三天回滚才在几十个commit里锁定它。

合并请求的评审我们设了明确的检查项:代码风格是否符合规范,是否存在魔法值,是否有异常处理的空实现,是否有线程安全问题,资源文件是否放在正确目录,以及是否引入了过大的依赖。评审不是走流程,我要求每个评审员至少要给出一条“真实”的改进意见,如果你完全没意见,可以勾选“无意见”,但不能假装仔细看了一遍然后说“通过”。团队里有人因为评审太松导致一个低级Bug混入线上,那个月的复盘会上大家脸都挂不住。

7.2 规范落地的节奏与自动化工具链

任何编码规范都不能靠人肉自觉来保证,必须靠工具格式化、静态检查、自动化测试三板斧让规范变成“默认行为”。我们把规范化成了三个步骤:第一步统一代码格式化配置,保证大家格式化之后代码风格一致;第二步接入静态检查规则集,里面覆盖了废弃API调用、空指针风险、资源类型引用错误、过度绘制等常见问题;第三步是配置提交前的钩子脚本,会在提交前自动运行格式化检查和单元测试。

关于落地节奏,想多啰嗦一句:规范推行最怕的就是“一口吃成胖子”。我们的经验是,首月只强制命名和代码风格,让团队慢慢适应;第二个月开始加线程和资源管理内容;第三个月才把异常处理和日志规范并入评审清单。每个阶段结束时做一次代码抽查,把符合率数据贴在墙上,要让大家看到自己的进步,而不是感觉被批评。一个月后线上崩溃率确实降了,新需求的联调时间也明显变短,这时候团队自己就会有推动力。

最后再分享一个关于规范文档维护的细节:用修订版本身来促进思考。我们每半年会对规范做一次集中修订,把过去半年出现的线上故障、评审争议、工具链变化映射回规范条目中。所以这份规范永远是最好的实践映射,而不是一个固化的文档。谁遇到新问题、谁提出新解法,都可以在下一次修订时把经验固化进去,这样一来规范就是活的,团队也就真的养成了肌肉记忆。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进