背景說明
??對于發布出去的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系統,實現查詢在線狀態、日志指令(包括開關控制、日志拉取、指令有效期控制)推送接口,且支持批量推送。
- 實現日志指令操作的后臺操作界面。
- 實現日志文件存儲、生成日志下載鏈接。
客戶端