背景说明
对于发布出去的app大部分时候确实都能保证业务正常运行的,但由于版本的迭代,客户端和服务端业务逻辑一直在更新变化,而且运营数据的配置也会相应更改,这些变化就可能使得客户端不能按预定逻辑运行或出现异常。如果一旦有检测(用户反馈或服务端预警)到有非正常的运行现象,却没有对应用户的日志来分析,而仅通过现象来排查代码将使得问题解决变得困难。
日志作为程序运行状态和路径的记录,是跟踪和重现问题的重要依据,特别是对于线上问题的定位显得尤为重要。 因此,规范的日志打印和合理的日志获取流程有利于问题修复的效率提升。
实现目的
- 增强客户端app在发布后对可能问题的可追溯性;
- 方便在开发过程中对提测后bug的重现;
- 减少打印日志后对程序性能的影响;
- 规范日志打印,增强可读性;
日志分类
- 业务日志:记录程序运行状态和路径,是日志的主体信息。一般是在代码的关键点(比如在遇到两个分支策略使得程序运行结果有较大出入时)来打印业务日志,为后续排查问题提供程序执行路径依据。
-
异常日志:不仅包括标识程序
crash
的exception
信息日志(携带异常堆栈),而且还要包括可预见性的程序运行跟预期不符合的情况。
日志规范
- 日志的TAG定义
在类的首行使用final static String
类型定义日志TAG
,名称可以是类名或其他有意义能唯一标示的名称。如:
//错误(混淆会修改类名)
private final static String TAG = TestLog.class.getSimpleName();
private final static String TAG = "aa"; //错误
private final static String TAG = "TestLog"; //正确
- 应严格按照日志等级打印,以便提高性能以及后续日志抓取、分析的过滤。
日志级别从低到高定义:Log.v()
<Log.d()
<Log.i()
<Log.w()
<Log.e()
。
- A、程序调试阶段的调试日志(最好合并代码到 dev 之前清理)以及不需要在正式发版后打印到文件(终端)的使用
Log.v()
或Log.d()
打印,便于后续视情况终端打印还是写入日志文件或者直接屏蔽。 - B、关键业务执行路径日志、重要日志信息使用
Log.i()
打印,此时对于远程抓取的情况需要存储文件。 - C、有异常判断的条件,但此时跟预期运行有差异的情况使用
Log.w()
(比如对空指针的有前提判断,但预期不为空的情况)。 - D、catch异常使用
Log.e()
打印。
- 所有日志打印开关需要前置。如:
if (Log.LOGED) Log.d(TAG, "message"); //优化字符串拼接损耗
这里的开关前置,很多人不能理解,其实就是因为调用Log.d()
函数的传参message
很有可能是通过复杂运算拼接成的(比如,打印http接口的返回结果等),在动态的关闭/打开日志开关后,能较大程度的提升性能。[参考android的源码中,发现很多日志打印也是采用了这种开关前置的形式]。
- 异常打印信息使用
Log.e()
打印,且需要带上Throwable
异常堆栈信息。如:
try {
//do something...
} catch (Exception e) {
Log.e(TAG, e.getMessage()); //错误
Log.e(TAG, "method name()" + e.getMessage()); //错误
Log.e(TAG, "method name()", e); //正确
}
- 代码中禁止使用
System.out.println("message")
来打印日志。 - 对于容易产生不可预知异常处需打印入口和出口日志,如: http 请求网络日志、推送等。
- 需要直接打印的
bean
对象或复杂数据结构时,需打印其toString()
,并实现该方法,参数使用StringBuilder
拼凑。如:
public class TestBean {
private String key;
private int value;
private boolean success;
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
builder.append("{key:")
.append(key)
.append(", value:")
.append(value)
.append(", sucess:")
.append(success)
.append("}");
return builder.toString();
}
}
TestBean bean = new TestBean();
bean.key = "test-key";
bean.value = 100;
bean.success = true;
if (Log.LOGED) Log.d(TAG, "test: " + bean.toString());
-
for
循环中尽量不打印日志,确实有必要打印时,可以先在循环里使用StringBuilder
拼凑成可读性较好的字符串,然后在循环结束后一次性打印写入文件。 - 打印的日志信息不仅仅是简单的输出变量值,诸如“变量名=value”等格式的信息,应携带其他有可读性的语句或提炼通用词句来拼凑日志信息,以使最终的日志文件具有可读性。比如:
Log.i(TAG, "request >> url: " + task.getUrl() + ", code: " + task.hashCode()
+ ", type: " + task.getRequestType() + ", expire: " + sAcExpired);
Log.i(TAG, "response << http: " + task.getHttpName() + ", code: " + task.hashCode()
+ ", ac:" + ac + ", result:" + formatJson(content));
- 尽量不写入重复或多余日志。
日志影响
日志打印存储文件时,需要尽可能少的影响程序本身的性能,因为性能影响了程序的流畅性,而流畅性直接影响了用户体验。
最基本的流畅性保证是使用了日志方案后不会导致app的卡顿,但是流畅性不仅包括了系统没有卡顿,还要尽量保证没有CPU峰值等。
方案一:简易本地版
如果不需要考虑日志的存储、上传等与服务端连通的操作而只是在终端输出时,问题也将变得简单,我们直接使用android.util.Log
进行日志打印即可。剩下的问题就只剩下如何控制日志开关、如何打印其他辅助信息(如线程、堆栈等)了。
经常看到很多app对这种本地日志开关都会使用BuildConfig.DEBUG
这个gradle
帮我们生成的变量来作为日志开关的标志(debug
版本打开日志,release
版本关闭日志),在实际开发过程中会发现以这个变量为日志的开关标志其实有一定局限性。比如,某天xx领导拿着自己的手机给我们现场反馈刚上线的版本出现了某个偶现严重问题,当你想看下日志分析的时候,发现release版本日志被关掉了,看不了!!!尴尬啊。。。
于是琢磨着有没有什么其他手段能动态修改这个日志开关呢?想到前几年开发中经常用到SystemProperties
来存储一些全局配置信息,刚好能配合这个日志开关的使用,不管什么版本的app,需要看日志的时候,我们通过命令行修改这个配置就可以打开这个开关了,如输入:adb shell setprop ro.tech.log true
。
先简单介绍一下属性系统:
属性系统是android
的一个重要特性,它作为一个服务运行,管理系统配置和状态;所有这些配置和状态都是属性;每个属性是一个键值对(key/value pair),其类型都是字符串。这些属性可能是有些资源的使用状态、进程的执行状态、系统的特有属性等。
系统给的注释:Gives access to the system properties store. The system properties store contains a list of string key-value pairs.
对开发者来说更重要的是SystemProperties
被@hide
起来了,意思是普通应用开发不能使用属性系统。经过测试验证(在root手机和非root手机上验证过,不排除某些rom有限制),反射这个类的set()
/get()
接口仍是有效的,只是谷歌没有开放给开发者使用而已。有了这样的前提,我们就可以使用命令行设置属性打开(关闭)日志开关,app代码反射读取属性来获取开关来实现日志的动态开关了。
另外,SystemProperties
对key
的命名有一些规则限制,比较常用的是以ro
开头和persist
开头的属性:
- 如果属性名称以
ro.
开头,那么这个属性被视为只读属性。一旦设置,属性值不能改变,需要重启还原。 - 如果属性名称以
persist.
开头,当设置这个属性时,其值将写入/data/property
,且可以重复写入。
首先,定义反射属性系统的接口函数(参见文章《反射相关知识及jOOR反射库介绍》):
private static boolean getBoolean(String propName, boolean def) {
return Reflect.on("android.os.SystemProperties").call("getBoolean", propName, def).get();
}
根据属性系统key的命名规则定义日志开关key
/**
* 日志开关设置
*
* adb shell setprop ro.tech.log true
*/
private static final String LOG_ENABLE_PROP = "ro.tech.log";
定义日志开关
DLog {
//...
//LOGED为静态的final变量,程序启动时读取开关
public final static boolean LOGED = BuildConfig.DEBUG || getBoolean(LOG_ENABLE_PROP, false);
//...
}
打印日志,读取DLog.LOGED
属性(在启动app前,通过命令行输入修改属性指令:adb shell setprop ro.tech.log true
)
if (DLog.LOGED) DLog.i(TAG, "MainActivity is oncreated!!");
其他辅助信息,如是否使用默认tag打印、是否打印线程信息等设置均可采用该方式实现,具体参见GHDemo-DLog.java的实现。
方案二:服务端预警版
使用场景
- 服务端检测http业务接口调用异常(调用量偏高或偏低、接口参数有误等),获取客户端网络日志分析http调用逻辑的可能问题。(典型用例:消息提醒需求的红点接口调用量大问题)。
- 线上用户反馈但难以复现的严重问题,通过获取用户反馈问题的描述及用户日志分析解决此类问题。(典型用例:排查用户反馈偷跑流量问题)
- app灰度期间借助第三方的崩溃统计平台(友盟、bugly等)统计崩溃情况,客户端的业务日志能方便重现崩溃的操作路径有利于找到crash的本质原因。
方案描述
服务端借助push系统主动发出日志开关或日志拉取的透传指令,客户端app收到推送后,解析该日志指令并执行相应处理(包括操作日志开关、压缩并上传已有日志文件),在服务端收到日志文件后提供日志文件存储下载服务,开发人员下载对应用户日志分析问题。
该方案完全由服务端的指令触发自动完成,整个流程不需要用户实际参与,相比要求用户配合开发人员获取日志的方式大大缩短了解决问题的时间和整体流程。
方案实现
该方案实现有服务端和客户端的工作量,因此需要两端协助完成,具体时序流程如下图所示:
时序图中包含了手动拉取和自动批量拉取两种场景:
- 手动拉取:用户或预警反馈问题并携带客户端imei,开发人员通过后台日志管理平台以imei为唯一标识触发单条日志指令。
- 自动批量拉取:大数据生成报表时,获取存在可能异常用户样本列表,通过业务接口自动获取样本列表日志。
具体细节功能
服务端
- 定义日志指令参数格式。
- 对接push系统,实现查询在线状态、日志指令(包括开关控制、日志拉取、指令有效期控制)推送接口,且支持批量推送。
- 实现日志指令操作的后台操作界面。
- 实现日志文件存储、生成日志下载链接。
客户端