
Android编码规范这份东西看起来是给团队定的条条框框实际上写的是团队协作的底层逻辑。我经手过不少项目的代码审计也帮几个团队整理过内部规范说句实在话一个项目烂不烂跟参与者的水平高低有关系但跟有没有一套能落地执行的编码规范关系更大。很多时候代码写得飞起的人恰恰是埋雷埋得最深的人而一份修订版的编码规范往往就是在踩过足够多的坑之后某位老兄实在忍无可忍拿出来的东西。这份修订版我不是凭空拍脑袋定的背后有几个真实背景一是团队从几个人扩大到十几个不能再靠口头约定二是Kotlin占比从三成提到了九成很多Java时代的旧规矩已经不适用了三是从单模块App改成了多模块组件化包结构和依赖关系彻底变了一遍。所以这次修订不是小修小补而是把之前那些约定俗成、靠自觉、review时再改的模糊地带全部落到纸面上变成可检查、可量化的硬约束。说实话写规范的过程比我想象中更有意思。本来以为是技术文档写着写着发现是在写团队管理写着写着又发现是在写系统设计。接下来我把这份修订版的核心内容和落地过程中的心得完整拆开给需要定规范或者正在重构代码风格的朋友做个参考。1. 为什么编码规范值得专门做一次修订先说个真实场景。某天我接到一个维护任务改一个三年前写的业务模块打开工程看到包名有bean、utils、activity、fragment、adapter这种按层划分的结构点进去之后发现activity包里躺着三十多个类除了Activity还有Dialog、PopupWindow、自定义View甚至还有个工具类。命名上什么MainActivity2、TestActivity、OldLoginActivity这种名字全出现了。那个下午我基本没在写代码全在猜这些类到底是干嘛的。这就是没有规范的代价代码可读性直接决定一个人的开发效率这不是审美问题是实打实的经济账。很多刚带团队的Leader觉得编码规范这种东西太虚不如多花时间搞业务。但真到了一定规模你会发现代码库本身就是团队最大的资产而编码规范是维护这个资产的基础设施。只要超过两个人协作开发规范就不是可有可无而是刚需。修订版规范要解决的绝不仅仅是代码风格统不统一的问题而是要建立一套能让新人快速上手、让老人不敢乱来、让Code Review有据可依的体系。这次修订的时机也很有意思。正好赶上了团队从Java全面转向Kotlin的过渡期Java时代的规范比如要求成员变量加m前缀、静态常量加s前缀在Kotlin里已经完全用不上了。还有一些旧规范模棱两可比如布局文件命名要有语义什么叫有语义每个字段名、方法名、XML Id到底该怎么起旧规范没说清楚等于没说。所以修订版把大量模糊表述改成了可执行的规则同时增加了新工具链相关的约定和自动化检查手段。2. 命名与代码结构最基础也最容易扯皮的规则2.1 类命名后缀必须表意准确命名是编码规范里最不起眼但影响最深远的模块。类命名的核心规则其实就一句话看到类名就知道这个类是什么角色。修订版里对所有常见的组件后缀做了强约束Activity必须以Activity结尾Fragment必须以Fragment结尾ViewModel必须以ViewModel结尾Adapter必须以Adapter结尾工具类必须以Utils或者Helper结尾。这个规则看似简单但执行起来阻力不小有的人觉得LoginPage比LoginActivity简洁有的人觉得UserLogic比UserViewModel更高大上这种个性表达在代码规范里属于最没有必要保留的部分。Kotlin里还多了伴生对象的命名问题。很多人在伴生对象里放常量、放工厂方法但类名本身却不体现这些职责。修订版对此做了明确规定伴生对象中的公开常量放顶部工厂方法统一命名私有辅助函数下沉到文件底部这样一来扫描代码的时候不需要来回翻跳。class UserProfileViewModel : ViewModel() { companion object { const val DEFAULT_PAGE_SIZE 20 const val DEFAULT_CACHE_TIMEOUT 60L fun create(userRepository: UserRepository): UserProfileViewModel { return UserProfileViewModel(userRepository) } } }2.2 变量与方法命名追求读起来像一句话变量命名上修订版重点解决了两个历史顽疾。一个是集合变量没有复数标识比如有个字段叫user结果类型是ListUser每次用的时候都要想一下这到底是单数还是复数。现在统一要求集合类变量必须用复数形式users、orders、categoryList都可以但绝不能再用user代表一个列表。另一个是布尔变量没有语义前缀现在统一要求必须用is、has、can、should开头比如isVisible、hasError、canScroll。方法命名方面要求动词开头、动宾结构get、set、init、load、handle、build这类前缀随便用但禁止出现含糊不清的doSomething、handleThing。一个方法只做一件事这个方法名就应该是这件事的直接描述如果怎么都想不出一个准确的名字通常意味着这个方法的职责拆得不够细这是命名反过来倒逼设计的好例子。2.3 资源文件命名带前缀是底线资源文件的混乱程度在任何团队都是重灾区。Revision版定了几条铁律Drawable资源必须带ic_、bg_、selector_这类前缀布局文件必须带activity_、fragment_、item_、view_前缀资源ID必须带类型缩写TextView用tv_Button用btnImageView用iv_EditText用et_RecyclerView用rv_。这套东西能起作用的关键在于它让资源文件在项目里具备了一定的可预测性。看到一个id/tv_input_name不用去翻布局文件也知道这是一个显示输入名称文本的TextView看到一个drawable/bg_btn_primary_rounded直接就知道这是主按钮的圆角背景。可预测性意味着搜索成本下降对于动辄几百个文件的res目录来说这种秩序能省下大量时间。2.4 包结构垂直切分优先包结构这块修订版做了一个比较大的调整从按层分包改成按功能模块分包。旧结构是标准的MVC分包法activity包、fragment包、adapter包、bean包按层把同类文件堆在一起。这种结构在项目小的时候问题不大但项目一大改一个登录功能要在好几个包之间跳来跳去。新结构推荐按功能模块垂直切分登录相关的Activity、ViewModel、Repository、Adapter全部放在login包里个人中心相关的都放在profile包里。因为一个人改需求的时候90%的时间是在同一个功能包内工作不会说改登录功能还需要同时打开订单模块的Activity。改动的局部性直接提升了上手成本也降了。com.example.app ├── base // 基类、通用组件 ├── common // 常量、工具、通用UI组件 ├── data // 数据层数据库、网络接口、数据仓库 ├── di // 依赖注入配置 ├── feature │ ├── login │ │ ├── LoginActivity.kt │ │ ├── LoginViewModel.kt │ │ └── LoginRepository.kt │ └── profile │ ├── ProfileActivity.kt │ └── ProfileViewModel.kt └── widget // 自定义View3. 分层架构与依赖边界修订的重灾区3.1 从能用就行到约束边界编码规范如果只停留在命名和排版层面那只是皮毛。真正决定一个项目寿命的是代码之间的依赖关系和边界是否清晰。修订版非常明确地引入了分层架构要求强制划分出表现层、业务层、数据层并规定了层与层之间的依赖方向。表现层只允许依赖业务层的接口不允许直接触碰数据层数据层只负责数据的获取和持久化不允许反向依赖业务层业务层只对上层暴露精简的用例接口屏蔽数据层细节。这里要特别解释一下为什么依赖方向能成为编码规范的一部分。说白了就是为了控制问题的爆炸半径和系统的可测试性。如果不限制依赖方向表现层随手删一个数据库字段、随手改一个网络请求数据层就成一个公共厕所没有人能说清楚谁在用这些数据。建立了边界之后数据层改接口要评估影响面表现层换数据源只需要换业务层实现系统才能通过单元测试来验证。// 表现层只依赖业务层接口 class LoginViewModel(private val loginUseCase: LoginUseCase) : ViewModel() { fun login(username: String, password: String) { viewModelScope.launch { val result loginUseCase.execute(username, password) _uiState.value result } } } // 业务层定义用例接口并实现核心逻辑 interface LoginUseCase { suspend fun execute(username: String, password: String): UiState } class DefaultLoginUseCase( private val userRepository: UserRepository ) : LoginUseCase { override suspend fun execute(username: String, password: String): UiState { return userRepository.login(username, password).toUiState() } } // 数据层只负责对接网络和存储 class UserRepositoryImpl( private val api: LoginApi, private val localStore: LocalStore ) : UserRepository { override suspend fun login(username: String, password: String): UserEntity { return api.login(LoginRequest(username, password)) } }3.2 模块化之下的依赖规则除了分层约束修订版还针对组件化改造增加了模块间的依赖规则。以前的单模块工程没有模块依赖的概念所有代码都在一个module里看起来简单实际上代码耦合到了惊人的程度。改成多模块后最怕的就是模块间乱引用。修订版对跨模块引用做了严格限制模块A只能依赖模块B对外暴露的接口不能直接依赖B的内部实现类更不能出现模块A依赖模块C、模块C又反过来依赖模块A的循环依赖。有人觉得这是架构师该操心的事跟普通开发没啥关系。但实际上如果普通开发在写代码时没有边界意识随手一个public class随手一个internal改成了public模块化改造迟早会变成一团乱麻。这套规则落到规范里等于把架构师的约束前置到了编码阶段让每个人都在架构边界内干活而不是靠事后review来救火。依赖注入方面也有明确要求对象创建和依赖装配尽量交给依赖注入框架不在业务代码里直接写XXXManager.getInstance()避免引入全局状态和单例泛滥。没有依赖注入框架的项目也要求通过构造器显式传参禁止在类内部直接new一个依赖对象。这样做的好处是每个类的依赖一目了然Mock起来简单替换实现也简单。3.3 UI组件编码约束UI层的代码往往是最容易腐化的地方因为改起来快、测试少、肉眼可见效果好就行。修订版对Activity和Fragment的代码结构做了硬性约束Activity只负责窗口和生命周期调度Fragment只负责组合视图和数据展示具体业务逻辑严禁堆在Activity里。那些鬼斧神工的onCreate几百行代码、onClick里写网路请求的写法全部列入代码评审的黑名单。class LoginActivity : BaseActivity() { private lateinit var viewModel: LoginViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) initViewModel() initView() subscribeUi() } private fun initViewModel() { viewModel ViewModelProvider(this).get(LoginViewModel::class.java) } private fun initView() { // 只做视图初始化不做业务判断 } private fun subscribeUi() { // 用观察者模式订阅VM的状态 } }在视图逻辑这一层还特别提到了Adapter的写法。以前的工程里经常出现Adapter里写点击跳转、写网络请求、持有Activity引用的写法基本上是把Adapter当成了万能类。修订版规定Adapter只负责将数据渲染成ViewHolder点击事件通过回调接口上抛由外层统一处理ViewHolder尽量用ViewBinding或者DataBinding减少findViewById的样板代码。4. 线程、网络与资源管理最容易爆雷的三块4.1 线程纪律主线程不是垃圾桶线程规范在编码规范里属于老生常谈但执行的程度往往决定一个App的用户体验。修订版把线程约束提升到了红线级别规则很简单也极端明确主线程严禁执行任何IO操作包括网络请求、磁盘读写、数据库访问、SharedPreferences提交。所有耗时操作必须发生在子线程所有UI更新必须回到主线程。Kotlin协程已经成为主流异步方案修订版对协程的使用也给出了具体约定viewModelScope是页面生命周期内的标配Dispatchers.IO用于磁盘和网络操作Dispatchers.Default用于密集计算Dispatchers.Main用来做结果回调。特别强调的是禁止直接调用GlobalScope这玩意在项目里留下的烂摊子已经够多了线程生命周期失控后要么任务悄悄丢失要么回调时页面已经关闭导致空指针崩溃。class OrderListViewModel( private val orderRepository: OrderRepository ) : ViewModel() { fun loadOrders() { viewModelScope.launch { _loading.value true try { val orders withContext(Dispatchers.IO) { orderRepository.fetchOrders() } _orders.value orders } catch (e: Exception) { _error.value e.toUiMessage() } finally { _loading.value false } } } }线程纪律这块还有一种常见反模式就是用Thread.sleep做延时任务、用Thread子类做网络请求、用AsyncTask处理数据。修订版对这些Java时代的遗留写法下了逐客令明确要求统一走协程或者统一封装的线程池。理由很简单Java原生线程在生命周期管理和异常处理上天生残缺协程用结构化并发、可取消、异常可控调度切换成本低已经是团队唯一的异步方案规范再允许旧写法就会造成风格分裂和维护噩梦。4.2 网络层规范统一封装而非各自为战网络请求这块很多项目的乱象在于每个开发者都自己封装一套网络工具。有人用OkHttp有人用Retrofit有人直接HttpURLConnection裸奔导致超时设置不统一、错误处理不统一、日志格式不统一联调时互相甩锅。修订版强制要求全项目统一使用一套网络封装任何业务模块禁止自己new OkHttpClient。统一的网络层必须做好三件事统一的超时设置、统一的拦截器链包括日志拦截、鉴权拦截、公共参数拦截、统一的错误码映射。object RetrofitClient { private val okHttpClient: OkHttpClient by lazy { OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS) .addInterceptor(AuthInterceptor()) .addInterceptor(LoggingInterceptor()) .addInterceptor(CommonParamsInterceptor()) .build() } fun T create(serviceClass: ClassT): T { return Retrofit.Builder() .baseUrl(BuildConfig.API_BASE_URL) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build() .create(serviceClass) } }网络规范还包括日志脱敏。请求和响应的日志里如果包含密码、电话号码、身份证号在测试环境可能无所谓一旦生产环境日志进入Bug管理平台就是妥妥的合规隐患。修订版强制要求日志拦截器对敏感字段做掩码处理不输出完整数据只输出摘要和耗时信息。错误处理方面统一要求业务代码捕获异常后不能只printStackTrace或e.printStackTrace()了事必须转换成用户可理解的中文文案并上报到错误监控平台。以前很多线上问题查不出来就因为异常被吞了连个日志都没留下。4.3 资源管理与内存泄漏防线内存泄漏是Android的老话题但大多数泄漏不是因为某个神秘的系统bug而是因为代码不规范。修订版把最容易引发泄漏的几个场景单独拎了出来。第一是Activity上下文的不当持有。自定义View、Adapter、Presenter中经常有人为了方便直接持有Activity引用这个Activity退出后引用还留在后台任务里泄漏随之而来。修订版规定长生命周期组件一律使用applicationContext短生命周期组件如果有需要也要在合适的时机主动释放。第二是静态集合的滥用。有人用static List存了一大堆Activity或者Fragment引用美其名曰方便管理实际上这是天然的泄漏源。修订版明确规定禁止用静态变量持有Activity、Fragment、View、Dialog这些有生命周期对象特殊情况必须通过WeakReference包装。第三是Handler和Runnable的清理。延时任务在Activity销毁后还在排队如果Runnable里持有了Activity引用泄漏就这么产生了。修订版规定凡是使用Handler.postDelayed或者协程延迟任务的必须在onDestroy中取消不能指望系统自动清理。class MainActivity : AppCompatActivity() { private val handler Handler(Looper.getMainLooper()) private val bannerRunnable Runnable { // 滚动Banner的逻辑 } override fun onResume() { super.onResume() handler.postDelayed(bannerRunnable, 3_000L) } override fun onPause() { super.onPause() handler.removeCallbacks(bannerRunnable) } override fun onDestroy() { super.onDestroy() handler.removeCallbacksAndMessages(null) } }资源管理还包括字符串硬编码的禁止。修订版要求所有文案必须写在资源文件禁止Activity里直接写中文串不能为了省事把颜色值直接写在布局文件所有颜色进colors.xml所有尺寸进dimens.xml所有字符串进strings.xml。这些规则看着繁琐但好处是改主题的时候只需改一套资源文件而不是全工程搜索硬编码颜色值逐一替换。5. 让规范真正执行下去工具、评审与卡口5.1 自动化检查别把规范全压在Code Review上很多团队把编码规范停留在文档层面执行完全靠Code Review时人工检查这种方式的覆盖率和一致性是相当有限的。修订版落地时有一个核心思路能用工具自动检查的绝不用人的自觉。代码格式化交给工具命名规范交给静态检查资源引用问题交给资源检查器人只负责检查机器检查不了的设计问题。Android工程里Kotlin的标准格式化工具是ktlint它可以统一缩进、空格、换行、import顺序直接在CI上跑跑不过就Failed没有商量余地。静态检查工具用detekt它可以自定义规则检查类长度、方法复杂度、Magic Number、未使用变量等。// build.gradle.kts 配置示例 detekt { config files(config/detekt/detekt.yml) buildUponDefaultConfig true parallel true } // 自定义规则示例禁止使用GlobalScope class NoGlobalScopeCheck : DetektVisitor() { override fun visitUserTypeReference(type: KtUserType?) { if (type?.text GlobalScope) { report(CodeSmell( Entity.from(type), 使用 GlobalScope 会导致协程生命周期失控请改用 viewModelScope 或 lifecycleScope )) } super.visitUserTypeReference(type) } }Lint这块Android自带的Lint其实已经能发现大量问题未使用资源、硬编码字符串、Toast上下文泄漏、旧版本API废弃警告。修订版要求开启Lint的abortOnError一旦出现Error级别问题直接中断构建而不是放过去让它在代码里沉积。这套机制跑起来之后规范的习惯会自然内化成肌肉记忆新人写出的代码不用等着老员工手把手教机器当场就给出了反馈。5.2 Code Review清单从随便看看到逐项对照Code Review不能走形式所以修订版附带了一份可勾选的Review Checklistreviewer必须逐项确认而不是通读一遍凭感觉打勾。这份清单包括逻辑正确性、边界条件、线程安全、资源释放、空指针风险、异常处理、可测试性、是否遵守分层依赖、是否有硬编码。很多人排斥这种清单式的Review觉得死板。但实践下来清单式Review的实际效果比凭感觉好很多因为它把Review者的注意力集中到了最容易出问题的点上避免出现通读了一遍代码觉得没啥问题然后上线就崩的场面。同时清单也是新人review其他人的入场券照着清单学也能学出个五六分。5.3 历史债务不搞一刀切用基线机制逐步清零推行新规范最怕的是历史代码一堆违规直接全量开跑检查报错上千条根本没法推进。修订版在处理历史债务上采取了一个比较务实的策略基线机制。第一次跑检测工具时把已有的问题全部记录成一条白名单基线后续检查只盯新增问题历史问题不进本次review的阻塞项但每个月设置一个清理指标逐步把存量问题消化掉。这个策略在工程管理上非常有效它承认了历史债务的存在同时也明确了新增问题零容忍的底线。相对的一次性强制重构的做法风险极高容易引发大规模合并冲突还会导致线上回归问题对于存量项目来说相当不划算。5.4 规范文档本身也要活起来写好的规范文档最怕被丢进Wiki吃灰。修订版把规范文档迁到了代码仓库里放在docs/目录下和代码一起提交、一起审查、一起发布。谁改规范谁发PR谁review整个变更过程有迹可循。很快这个文档本身也沉淀了不少从实际review中总结出来的新规则形成一个正向循环。与其让规范在Wiki里静态存放不如让它跟着仓库一起演进这才是规范的活水来源。6. 修订规范时绕不开的几个经典争论6.1 空指针防御还是!!到底能不能用空指针问题是Kotlin开发者永远的话题。修订版对这个问题的立场非常明确禁止滥用!!非空断言运算符业务代码里除非是绝对不可能为空的场景比如自己刚赋值的局部变量否则都不允许使用!!统一用安全调用?.和空合并运算符?:。// 禁止的写法 val userName user!!.name // 推荐的写法 val userName user?.name ?: 争论很快出现了有人说有些场景用!!更简洁而且空指针就应该让程序早点崩溃暴露问题避免静默地走错逻辑。这个观点有一定道理但放在团队协作里!!在Code Review阶段很难看出是否绝对安全一旦出错就是线上闪退代价远高于多写一行代码。修订版选择了一个折中方案!!仅允许在已经用条件判空保护、但Kotlin编译器无法智能推断的场景使用并且必须加注释说明为什么这里必然非空否则一律拦截。这样既保留了少数必要场景的逃生门也守住了空指针安全的底线。6.2 方法到底该多长大函数拆解的分寸方法长度限制是另一个争议点。有人坚持一个方法不能超过50行有人觉得只要逻辑清晰100行也OK。修订版的结论是不设硬性行数但要求一个方法必须有单一职责。如果一个方法里有for循环、有if分支、有网络请求、有UI更新、还有日志上报哪怕只有30行它也严重超纲反之一个方法做了完整的map转换80行但一条逻辑主线下来并不算坏味道。实际上方法长度从来不是绝对指标关键是方法和方法之间的边界是否清晰。修订版里对拆解给出的参考信号是缩进层级超过三层、局部变量超过六个、尝试给方法命名时要用到和字满足任意一条都建议拆解。这三个信号简单实用比死记硬背行数更有助于判断。6.3 命名之争功能性前缀还是业务性名词命名规则里最容易引发争论的是资源和ID的命名。有人觉得tv_、btn_这种类型前缀是历史包袱布局文件里拿到ID一看类型没用不如直接用语义名称如title_text、confirm_button更直观。修订版里仍然坚持了类型前缀 语义后缀的写法理由是团队里还有大量Java代码和旧布局文件统一风格比风格是否更现代更重要。而且tv_这个前缀本身承担了搜索和筛选功能在布局文件里看到一个ID就能判断对应的控件类型省去频繁切换查看xml的时间。6.4 注释到底该怎么写不写是什么写为什么注释的争论在于Kotlin很多代码本身已经能表达意图再写一堆解释性注释反而是噪音。修订版给出的策略分三层第一层是尽量用命名表达意图命名到位就是最好的注释第二层是业务逻辑复杂的地方写注释说明这段逻辑的输入输出和依赖条件第三层是遇到明显看起来不合理的代码但实际有特殊原因时必须用注释说明为什么这么写避免后来者好心把代码改成看似正确实则错误的样子。我见过最典型的场景某个业务模块的金额计算里有一段看起来多余的判断下一个人接手时觉得是垃圾代码直接删了结果线上支付金额全错了。这类因为上下文丢失导致的事故靠的就是一段为什么的注释来兜底。修订版明确要求凡是遇到反直觉的代码必须写注释解释背后的原因这一条几乎成了Review时最受重视的检查项。6.5 Kotlin文件组织顶层函数和扩展函数的放置Kotlin支持顶层函数和扩展函数用法方便但也容易被滥用。修订版对这两类函数的放置位置做了规定通用扩展函数放在common包下按主题拆分文件比如StringExt.kt、ViewExt.kt、DateExt.kt禁止在业务包底下随意创建扩展函数文件。顶层函数的可见性默认internal禁止随便做成public暴露给其他模块原因是顶层函数本质上就是全局静态方法一旦成为公开API改动会传导到所有调用方可见性约束是必要的护栏。// 文件ViewExt.kt package com.example.common.extension fun View.visible() { visibility View.VISIBLE } fun View.gone() { visibility View.GONE } fun View.invisible() { visibility View.INVISIBLE }7. 修订版跑了一段时间之后的一些实践体会规范不能凭空捏造一定要从项目里长出来凡是解决过真实痛苦的规则才有人愿意遵守。我第一次起草规范时把网上所有大厂规范抄了个遍条目繁多且脱离实际根本执行不下去。后来做减法保留那些在Code Review中出现频率最高、引发线上事故最多的痛点规范一下子就管用多了。比如资源文件乱命名引发的打包冲突比如线程滥用导致的ANR比如Context泄漏引发的线上OOM这些真实事故才是规范最扎实的立足点。Code Review里被挑战规则本身也很正常这种时刻容易把气氛搞得很僵。我自己应对的办法是规则背后必须要有理由如果是单纯的风格偏好就不轻易写进规范如果是能引发事故、影响效率的硬规则就坚持到底同时在review里解释原因而不是甩一句规范就是这样规定的。几次下来团队的认可度会明显提升。对于正在准备自己团队编码规范的朋友我最后提三点建议。第一别追求一步到位先解决80%的高频问题剩下的边跑边补第二每条规则都要配上正面例子和反面例子让新人一看就懂第三规范一定要和自动化工具绑定靠人盯不长久只有工具会让规范真正落地。编码规范修订版写出来只是一个开始让团队愿意照着做、能从中受益才是这份文档真正的价值所在。