Android项目开发规范整理

原则、规则、建议

Posted by lx8421bcd on June 29, 2020

最近有新同事要入职,需要整理一份我司Android组的开发规范供新同事快速熟悉工作环境。整理过程中我就发现事情没那么简单,其实可以把开发过程中遇到的问题、解决经验整理出来,声明一些基本开发原则的同时,也可以作为一份避坑指南。

本文长期更新……

命名规范

package命名

一般采用公司域名倒写+应用名称;单词全小写
举例:微信 - com.tencent.mm;网易云音乐:com.netease.cloudmusic

如果应用本身名称与公司名重合,可以以“android”结尾表明应用为公司产品的android客户端,但无需刻意追求3段式包名
举例:知乎 - com.zhihu.android

应用包名通常作为appid,一旦打包发布则无法修改(除非更换应用),命名时务必慎重

Resources资源命名

Google仅允许小写字母+下划线的方式命名并且已经编入AS命名检查,所以命名风格无需赘述

布局文件

<组件类型_组件名称> 命名,组件名称若有多个单词,之间用下划线分开。如果布局文件仅仅是用于include到其它布局文件中,或者仅用于inflate到代码中作特殊用途View(比如列表的header、footer等),可采取 <layout_组件名称> 的方式命名

组件类型 组件类型前缀 示例
Activity activity_ activity_main、activity_home_recommend
Fragment fragment_ fragment_home、fragment_game_list
dialog dialog_ dialog_image_upload_select
PopupWindow ppw_ ppw_filter
item of ListView or RecyclerView item_ item_live_room、item_user_list
其他特殊用途布局片段 layout_(非必须) layout_recommend_list_header

由于特殊布局片段用途的多样性,全部统一加layout_前缀就像大杂烩,等于没加;因此原则上不禁止无前缀特殊布局命名,但本着规范项目方便检索的原则,应尽量避免无规则命名。

布局id命名

一般以 <View缩写_用途描述> 命名,采用小驼峰或者下划线命名均可,但应保持项目内风格统一。除个别常见缩写外View缩写一般为View单词首字母组合,下表为常见View缩写

控件名称 缩写 示例
TextView tv tvNickname; tvContent; tvTitle
Button btn btnSubmit; btnCancel; btnSettings
ImageView iv ivBackground; ivAvatar
RadioButton rb rbAlipay; rbWXPay
CheckBox cb cbAccept
ViewPager vp vpGoodsImages

通常情况下,使用View缩写作为前缀+用途描述作为控件ID,但应以方便理解为基本原则,不要因为追求单纯的样式一致而反过来导致理解困难和命名蹩脚。

偶尔遇到仅需要用ID而不关注View具体类型的情况时,可以采取描述用途的方式命名。
示例:分割线需要id用于与处理与其他布局的关系,id:divider

当控件的用途描述就是控件本身名称的时候,id可以直接用控件名命名。
示例:页面内有且仅有一个LoadingView,id:loading_view;下拉刷新布局PullRefreshLayout,id: refresh_layout

drawables/colors

一般以 <类型_用途描述> 命名。对于一个selector对应的不同状态的资源/图片,采取 <类型_用途描述_状态> 命名,下表为常见类型及前缀。

资源类型 前缀 示例
图标(icon) ic_ ic_settings.png、ic_follow.png、ic_arrow_right.png
背景(background) bg_ bg_select_dialog.xml、bg_home_header.png
选择器(selector) sel_ sel_btn_recommend.xml、sel_tab_title_color.xml
展示用图片(image) img_ img_loading.png、img_net_error.png
占位图(placeholder) holder_ holder_image_default.png、holder_avatar.png

原则上尽量加类型前缀方便区分检索,但避免无意义前缀,比如什么图前面都加 img_ ,跟没加有什么区别……

动画资源(anim/animator)

通用动画资源采取 <动画描述> 的方式命名
示例:fade_in.xml、 slide_left_in.xml

专用动画资源采取 <作用对象_用途描述/动画描述> 的方式命名
示例:logo_fade_in.xml,goods_item_add_cart.xml

styles(styles.xml内)

style名称用大驼峰命名法,命名方式为 <特殊标识/作用对象/用途描述>

<style name="TitlePrimary">
    <item name="android:textSize">@dimen/base_font_l</item>
    <item name="android:textColor">@color/base_title_color</item>
</style>
.....
<style name="SettingItem">
  	<item name="android:layout_width">match_parent</item>
  	<item name="android:layout_height">@dimen/setting_item_height</item>
    <item name="android:textSize">@dimen/base_font_m</item>
    <item name="android:textColor">@color/base_text_color</item>
</style>
values(strings.xml、dimens.xml、colors.xml等)

通用默认资源采取 <默认资源前缀标识_资源描述/用途描述> 方式命名,对于专用资源,直接以<模块_用途描述> 方式命名。示例:

<!-- dimens.xml -->
<dimen name="base_divider_size">1px</dimen> 
<dimen name="base_navigation_bar_height">44dp</dimen> 
<dimen name="base_margin_both">12dp</dimen>

<dimen name="setting_item_height">36dp</dimen>

<!-- strings.xml -->
<string name="base_confirm">确认</string>
<string name="base_cancel">取消</string>

<string name="loading_network_err">网络异常</string>

<!-- colors.xml -->
<string name="base_black">#000000</string>
<string name="base_white">#FFFFFF</string>
<string name="base_divider_color">#B0B0B0</string>

通用资源前缀 base_default_common_等均可,但项目内应统一使用一种。

建议:
dimens.xml, colors.xml这种文件的初衷就是用于存放可复用的,方便替换的色值与尺寸的,应与UI设计协商出通用的“尺寸表”、”调色板“,并编写到配置中。开发中尽量避免在这些文件中存放特例,否则将会在这两个配置文件中产生大量不必要的重复,且随着版本迭代,会产生大量废弃配置值,影响代码维护。

<!-- 推荐 -->
<string name="title_primary_color">#000000</string>
<string name="text_normal_color">#FFFFFF</string>

<dimen name="title_primary_size">16sp</dimen> 
<dimen name="text_normal_size">12sp</dimen>

<!-- 避免 -->
<string name="comment_text_color_enabled">#B0B0B0</string>
<string name="comment_text_color_disabled">#B0B0B0</string>

如果一个控件的尺寸和颜色配置只有它本身使用,则没有必要在配置文件中声明。

代码命名规范

参见下方编码规范


编码规范

Java代码规范

与通常Java代码相同,不赘述。此处建议参考:

Google Java Style Guide

阿里巴巴Java开发手册

Google的代码风格指引更偏向于代码本身的编写风格,而阿里的开发手册则在风格的基础上偏向于开发经验整理,二者应结合起来看。

kotlin代码规范

请参考:

kotlin官方编码规范

C/C++代码规范

请参考:

Google C++ Style Guide

华为C语言编程规范


开发规范

以下的条款是在开发中必须遵守的,否则可能会导致应用卡顿、崩溃、ANR等严重问题。
“禁止”表示绝大多数情况下不允许这么做,只有极为特殊的情况才可以
“避免”表示这么做虽然不会立刻产生错误,但此方法是官方不推荐或经开发实践验证存在安全隐患的,实践中也应避免使用

禁止在Application级生命周期对象中直接/间接持有Context引用

Application级生命周期的对象(Application、单例)长期持有Context引用使Activity、Fragment、Dialog等组件无法被回收是导致应用内存泄漏最常见的原因,在开发工具类、组件时应从设计上避免。
原则:

  • 能不使用Context就不使用Context
  • 能使用ApplicationContext解决的事情,不要使用ActivityContext
  • 能将Context作为局部变量用完就丢就不要缓存到成员域
  • 如必须在单例对象成员域中缓存context,必须写明注册/解除注册机制(类EventBus模式)

建议: 由于Google不推荐持有static的ApplicationContext,在非Activity、Fragment环境下需要使用ApplicationContext时,可以使用反射获取。此方法位于浅灰名单,不会受到Android P以后的SDK政策影响

/**
 * 通过反射获取AppContext,避免直接使用static context
 */
@SuppressLint("PrivateApi")
private static Context getAppContext() {
  Application application = null;
  try {
    application = (Application) Class.forName("android.app.ActivityThread")
      .getMethod("currentApplication")
      .invoke(null, (Object[]) null);
  } catch (Exception e) {
    e.printStackTrace();
  }
  return application;
}

禁止吞掉try-catch块捕捉的异常

catch到Exception后printStackTrace()打印堆栈信息,是排查异常最基础的手段之一,如果直接图省事吞掉Exception,则会导致应用在出现异常时没有任何可以追溯的凭据。在应用发布渠道后如果遇到特定机型出bug只能根据日志收集SDK后台获取的log排查问题时,会造成灾难性后果。

举例:

public static String formatJson(String json) {
  String formatted = null;
  if (json == null || json.length() == 0) {
    return formatted;
  }
  try {
    if (json.startsWith("{")) {
      JSONObject jo = new JSONObject(json);
      formatted = jo.toString(JSON_INDENT);
    } else if (json.startsWith("[")) {
      JSONArray ja = new JSONArray(json);
      formatted = ja.toString(JSON_INDENT);
    }else {
      return json;
    }
  } catch (Exception e) { 
  } //FIXME:没有对不符合格式的json string输入作处理,且吞了异常
  return formatted;
}

应用测试过程中经常出现请求日志不打印返回数据且无任何异常,经检查发现是一同事新增了将http请求返回数据作JSON格式化方法,吞了遇到不合格式String产生的异常,遇到非JSON格式数据就会发生问题。 建议:

  • 如发生异常不影响流程执行,可仅用 e.printStackTrace() 等方法log输出异常信息
  • 如果异常影响正常流程执行,应将异常信息传递到UI管理层(Activity、Fragment等)由其决定是否提醒用户及提醒方式
  • 尽量避免在性能敏感的地方涉及到try-catch操作,哪怕仅仅是log输出,也是有成本的
  • 如可忽略异常,必须在catch块附近注释声明,且将Exception对象命名为ignored(AS规范)

禁止使用Environment.getExternalStorageDirectory()方法获取存储路径

原因如下:

  1. Android 10以后,此方法已弃用,对于targetSdkVersion>=29的应用,即使拿到SD卡读写权限,也无法拿到此路径的读写权限,除非你的应用在Google白名单中
  2. Android已经提供了外部存储路径的方法,且此方法不需要SD卡读写权限
  3. 使用此方法在用户手机根目录瞎写,于情于理都不合适

建议: 使用Android SDK中提供的内部存储和外部存储路径代替

/** 缓存路径,用于临时存放,可以随时清除 **/
// 内部缓存路径
context.getCacheDir();
// 外部缓存路径,使用前最好检查SD卡是否挂载
context.getExternalCacheDir();

/** 文件路径,用于存放长期有效的文件 **/
// 内部文件路径
context.getFilesDir();
// 外部文件路径
context.getExternalFilesDir()

禁止在自定义控件/组件中使用requestFocus()方法

焦点管理应统一交由实际开发页面的程序员控制,自定义控件初始化中如果有申请焦点方法,在需要使用键盘的设备上会导致焦点乱飞。如果自定义控件作为SDK打包提供给第三方,还会导致用户程序员难以修改。
由于大部分Android开发需求是针对手机、平板等触屏设备的,获取焦点的View样式一般不明显,因此SDK开发者普遍在这方面比较疏忽,有时为了一个小功能点的实现方便就直接在控件初始化过程中执行requestFocus()。但假如SDK被应用需要使用键盘和鼠标的设备上(如TV端),就可能导致自定义View在初始化时抢夺焦点。

举例:
某渠道商提供的播放SDK,此SDK应用到TV端后,TV端首页ViewPager切页后丢失焦点,经排查,原因是其MediaPlayerView的构造方法包含了requestFocus()方法,在ViewPager预加载下一页面时View初始化抢夺了当前页面的焦点。最后只能通过联系SDK提供方发布修复版SDK解决。
建议: 在TV端页面开发中,不要在layout XML文件中使用requestFoucs标签,将焦点申请逻辑全部交由Activity/Fragment管理,统一的焦点请求管理可以降低排查焦点跳转问题的难度。

避免使用getResources().getIdentifier()方法获取ResourceId

  • 使用getResources().getIdentifier()方法拼接resource name找资源ID找不到 - 运行时异常
  • 使用R.resource_type.resource_name找不到 - 编译期异常
    能在编译期暴露的错误,不要留到运行时

另一方面Android Studio IDE的Refactor菜单中提供了“Remove Unused Resources”方法来查找工程內没有被代码直接引用的resource资源,而使用 getResources().getIdentifier()方法生成资源名获取资源ID的资源无法被IDE扫描到,会被判定为未引用资源。在项目经过长期迭代需要清理未使用资源时会增加很多不必要的负担。

举例: 某直播项目中等级、礼物、勋章等连续组图icon资源特别多,因此项目中广泛使用了 getResources().getIdentifier()方法

resID = getResources().getIdentifier("gift_" + sendGiftCount % 30, "drawable", "package_name");
//……
resID = getResources().getIdentifier("level_" + level, "drawable", "package_name");
//……
resID = getResources().getIdentifier("medal_" + medalLevel, "drawable", "package_name");
//……

后果就是经常出现由于开发替换美术资源疏漏导致运行时因找不到资源而崩溃,其中有一次因为美术漏了一个高等级勋章资源,直接导致大客户达到显示勋章条件之后频繁崩溃,影响极坏。

建议: 对于上例这种一组图片id,建议采取数组或Map声明的方式与具体数值对应

// 资源:level_0.png, level_1.png, ...... level_30.png
// 代码中资源声明:
int levelIcons = {
  R.drawable.level_0.png,
  R.drawable.level_1.png,
  ......
  R.drawable.level_29.png,
  R.drawable.level_30.png
};
// 使用:
int levelIconRes = levelIcons[Math.min(level, 30)];

避免在高频率调用的方法內进行高消耗操作

在可能被系统或者用户高频率调用的方法內进行内存分配或高CPU占用操作是导致应用卡顿的最主要原因,典型如在List的Adapter绑定数据的回调方法內执行同步I/O,图片压缩;在自定义控件的onDraw()方法內进行内存分配等操作。
在高频率调用方法內应尽量避免进行大规模内存分配(包括new复杂对象、从磁盘加载图片进内存等)操作,因为在此方法内创建的实例作用域仅在方法内部,下一次执行此方法就会将上一次实例化对象的引用覆盖,上一次实例化的对象则进入等待GC的状态。当方法短时间内被高频率调用时就会产生大量“垃圾”,从而触发GC,GC过程本身也是要消耗资源的,此时就会产生卡顿。极端情况下甚至会出现来不及GC就触发OutOfMemoryError并导致应用崩溃。

举例:
某直播项目需要根据聊天协议下发的弹幕数据加载 全站等级、粉丝牌等级、特殊勋章、礼物icon等图片资源进行弹幕展示,其弹幕列表Adapter中,图片资源加载方式为:

Drawable levelIcon = context.getResources().getDrawable(R.drawable.level_x);
Drawable fansMedal = context.getResources().getDrawable(R.drawable.fans_medal_x);
//......

单条弹幕加载需要12~14ms,高频弹幕场景下直播间必卡顿,甚至ANR。
后使用内存缓存优化,将已加载过的图片缓存到HashMap中与数值对应,调用方式为

HashMap<Integer, Drawable> levelIconCacheMap = new HashMap<>();

// code in onBindViewHolder
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
  	if (levelIconCacheMap.get(level) == null) {
      levelIconCacheMap.put(level, context.getResources().getDrawable(R.drawable.level_x));
    }
    Drawable levelIcon = levelCacheMap.get(level);
   // ......
}

优化后,单条弹幕加载成本为1ms,快了12倍,且高频弹幕下不再出现频繁GC现象

建议: 如需在Adapter使用诸如OnClickListener等监听回调,可将回调对象定义在Adapter成员域,在填写数据时通过View.setTag()方法传入所需参数,以此减少对象创建频率,优化列表控件在快速滑动等场景下的性能。当然也可以定义在ViewHolder中。

// code in a RecyclerViewAdapter

private View.OnClickListener onAvatarClickListener = v -> {
  	SomeObject data = (SomeObject) v.getTag();
  	// do something
};

@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    ViewHolder holder = new ViewHolder(inflateItemView(R.layout.item_list));
  	holder.ivAvatar.setOnClickListener(onAvatarClickListener);
    return holder;
}

@Override
public void onBindViewHolder(ViewHolder holder, int position) {
  	SomeObject data = someObjectList.get(position);
		holder.ivAvatar.setTag(data);
    // .......
}


经验及建议

以下条目不是强制的,不遵守也不会产生什么严重后果。但以本人经历的团队开发实践,遵循以下条目可以相当程度上降低应用开发及维护成本,提升代码质量。

应用内Package划分方式

  • 不推荐使用Android早期的PBL分包方式(package by layer: ui - dao - model等)
  • 业务无关代码按功能模块划分(net - 网络、db - 数据库、utils - 工具库 等 )
  • 业务相关代码按业务模块划分,及PBF模式(package by feature: live - 直播、settings - 设置、user - 用户相关 等) PBL模式的package划分更适合针对单一业务,代码量不大时很适合,但随着应用版本迭代PBL模式分出的package内容将会剧增,activity、fragment、dialog这些package下动辄100+个文件,找东西全靠搜索,删一个功能需要动好几处地方,这package分的有什么意义?
    按开发的需求分package在这块就比较有优势,要删直接整个package删掉即可。另一方面PBF划分的package拥有更强更方便的权限控制能力,删除public关键字即可让代码仅能在包内使用。

单个文件代码应控制在2000行以内,最大不应超过4000行

一般建议规定:

  • 单个文件代码量小于1500行 — 正常范围
  • 单个文件代码量大于1500行小于2000行 — 可以接受,但应注意代码拆分和组件设计
  • 单个文件代码量大于2000行小于4000行 — 必须提供合理解释
  • 单个文件代码量大于4000行 — 不可接受 由于客户端开发的特点以及Android的特殊性,Activity、Fragment这类Android组件代码量非常容易暴涨,如果使用早期Activity、Fragment包办一切的设计的话,稍微复杂点的页面单个文件代码量破千行是很轻松的。但是在改进架构方案普及之后,Android组件Java文件代码量超过千行就不是那么容易了,除非像直播间这种典型的高UI密度、高交互复杂度的页面。
    2000行基本是90%正常组件的极限,如果一个Activity/Fragment的代码超过2000行,大概率存在设计问题或者代码组织问题,比如可以将部分方法抽出作工具类,部分功能单独写成一个组件调用等。如果是kotlin则更不应该出现2000+行的源文件。
    代码超过2000行,基本就会出现维护困难。再多下去,甚至编辑代码本身都会造成IDE卡顿,影响开发效率。

举例1:
某直播应用早期赶工未注重代码设计,直播间LiveActivity掌管了连接弹幕服务soket管理和协议解析、直播间信息获取、线路管理、播放器播放控制、送礼动画、转屏控制、全屏模式弹幕收发、直播数据统计等一系列功能,代码量超过14000行,光成员变量声明就将近1000行。编辑该文件时会有很明显的卡顿,大量的功能需要依赖局部搜索来查找,维护十分困难。因直播间属于核心模块测试工作量极大,非轻易可以大改,一直没有契机重构,只能看着代码量在此基础上不断上涨……
举例2:
某商城应用,迭代了半年,大概5~6个版本之后,MainActivity将近2800行,原因是除了二级页面Fragment管理、检查更新、新功能通知、未读消息检查等一堆需求全部在此处实现。后利用应用改版需求契机重构,将检查更新、新功能通知等功能抽出写成组件或自定义控件供MainActivity调用,MainActivity代码量从2800多行下降至不足1000行。

谨慎编写utils,慎重引入第三方库,重量级的库导入项目应经过项目组讨论并编写readme

原则:业务无关的工具类代码应该统一管理并提供readme,在非必要情况下,尽量选择使用当前codebase中已有的API完成需求开发,重量级SDK(图片缓存框架、播放器、http基础框架等)应经过技术选型小组讨论后再集成

DRY原则众所周知,在个人长期开发中很容易做到,但任何一个团队都必然会经历人员流动,很难保证在每个时间节点开发团队里每个人都是熟悉项目的。如果开发团队不重视项目中业务无关代码的拆分,codebase的维护和文档建设,那必然会出现重复代码;如果引入第三方库的权限很低,还可能会出现重复引入同功能SDK。重复代码不仅仅会导致应用体积无意义增长,也会带来维护困难。

举例1:
某直播项目经过2年版本迭代,项目内有3个播放器SDK,2个下载SDK,2个图片管理SDK,2个网络库(httpclient, okhttp),针对播放器的某一需求要实现3遍,最后build出来的apk起码有5MB浪费在无意义的同功能SDK上,并且因为两个网络库的cookie同步问题,会偶现难以查证的掉登录异常。后期不得不专门花时间统一SDK。

举例2: Android开发组新入职一员工,入职第一天就开始做需求,领导大夸其适应能力强上手快,等codereview的时候才发现端倪,此兄完全是在其负责需求创建的package下开辟了一个小天地,全盘导入其以前使用的各种utils、基类,为了方便直接复制以前的代码,甚至没有研究过当前项目是否组件化、是否应该组件化就集成了一个组件化路由框架,一下子提交了几十个文件修改。最后开发组不得不开会讨论,大量的代码被打回重写,白白浪费开发时间。

建议:
对项目组:任何一个项目从零开始时就要注重业务无关基础库的建设,包括代码整理、文档编写,业务无关的codebase经过沉淀之后逐步成为项目组的开发SDK,对于新入职的同事应花时间安排其熟悉codebase。
对个人:平日开发时就要注重业务无关代码的拆分和整理,新入职时要关注公司已有的代码库和开发规范,遇到需求可以引入utils或SDK解决时,应先咨询老同事或组长是否有同类SDK已经引入,避免重复建设。

不要在UI无关的地方操作UI

Android UI一方面由于移动端迭代速度很快,UI设计变化也会很快;另一方面由于Activity和Fragment上帝级别的存在,涉及到UI展示的代码往往需要持有activity或者fragment的引用,UI无关的组件试图操纵UI往往会导致其与UI代码强耦合,产生“坏味道”的代码。

有些同学追求统一处理着了魔,什么都想统一处理,很典型的例子就是http请求的异常处理,笔者见过有人在网络数据解析层的基类里就写了解析失败弹Toast的逻辑,用户未登录弹AlertDialog的逻辑。作者想的很好,这样全应用的未登录事件就统一处理了呀,多简洁。但网络解析层怎么知道你上一个接口失败与否?现在UI上有没有AlertDialog?这位兄弟很明显没有考虑过。

后来就会偶现用户用着突然弹了4~5个dialog提示他未登录(后端判断cookie过期),或者莫名其妙的冒出来个Toast提示“数据错误”,然后用户找客服,客服找开发,开发也不知道是哪个接口报的错。最后我接手网络层重构后删除了这段逻辑,重构了错误信息传递机制,才彻底解决这个问题。

建议:
在进行应用开发和组件设计时,应明确“各司其职”的原则,不要追求“大而全、包办一切”的设计。组件处理好其本职,保持对外接口一致。需要UI展示的异常信息,应通过回调或者广播提供给UI层。
错误行为:网络解析模块检测到代表未登录的error_code,弹出dialog提示用户未登录
正确行为:网络解析层检测到未登录的error_code,发送用户退出登录的广播事件,通知相关组件,并将异常通过onError()回传