一種Android應用耗電定位方案

語言: CN / TW / HK

背景

通常來說,app耗電相比於其他的效能問題(Crash,Anr)等,會受到比較少的關注,耗電通常是一個app隱藏的效能問題,同時又由於手機效能不同,使用時長不同,使用習慣不同,“耗電問題”從誕生以來,都被業內譽為偽命題,因為耗電量通常不具備較為“標準化”的衡量(我們常說的耗電量 = 模組功率 × 模組耗時),但是模組功率不同手機相差較大,同時不同廠商的定製化原因,導致了耗電的更加無法被有效衡量,但是應用耗電是客觀的事實,因此google官方提出了耗電監測工具Battery Historian,希望能以客觀的角度衡量耗電。但是實際耗電是關係到定製化的(比如不同app有不同的使用場景,同一個app也有使用場景不同從而導致耗電不同)所以業內也有像meta公司(facabook)的Battery-metrics一樣採用了自定義化的標準去衡量自己的應用。本文從官方耗電計算、自定義耗電檢測兩個出發,從而實現一種app耗電的定位的方案。

耗電計算

在Android系統中,android官方要求了裝置製造商必須在 /frameworks/base/core/res/res/xml/power_profile.xml 中提供元件的電源配置檔案,以此宣告自身各個元件的功耗(文件

功耗檔案獲取

通常,power_profile.xml位於/system/framework/framework-res.apk中,這是一個android裝置的系統apk,我們可以通過

adb pull /system/framework/framework-res.apk ./

獲取當前系統的framework-res apk,這步不需要root即可進行,接著我們可以通過反編譯工具,apktool或者jadx都可以,對該apk進行反編譯處理,我們所需要的功耗檔案就在 /res/xml/power_profile.xml 中。

系統功耗計算

我們得到的功耗檔案後,系統是怎麼計算功耗的呢?其實就在BatteryStatsHelper中,大部分都是通過使用時長* 功耗(功耗檔案對應項)得到每一個模組的耗電,而在我們系統中,每個參與電量計算的模組都繼承於PowerCalculator這個基類,同時會重寫calculatorApp方法進行自定義的模組耗時計算,我們可以在BatteryStatsHelper的refreshStats方法中看到參與計算的模組。

``` refreshStats 中

if (mPowerCalculators == null) {

mPowerCalculators = new ArrayList<>();



// Power calculators are applied in the order of registration

mPowerCalculators.add(new CpuPowerCalculator(mPowerProfile));

mPowerCalculators.add(new MemoryPowerCalculator(mPowerProfile));

mPowerCalculators.add(new WakelockPowerCalculator(mPowerProfile));

if (!mWifiOnly) {

    mPowerCalculators.add(new MobileRadioPowerCalculator(mPowerProfile));

}

mPowerCalculators.add(new WifiPowerCalculator(mPowerProfile));

mPowerCalculators.add(new BluetoothPowerCalculator(mPowerProfile));

mPowerCalculators.add(new SensorPowerCalculator(

        mContext.getSystemService(SensorManager.class)));

mPowerCalculators.add(new GnssPowerCalculator(mPowerProfile));

mPowerCalculators.add(new CameraPowerCalculator(mPowerProfile));

mPowerCalculators.add(new FlashlightPowerCalculator(mPowerProfile));

mPowerCalculators.add(new MediaPowerCalculator(mPowerProfile));

mPowerCalculators.add(new PhonePowerCalculator(mPowerProfile));

mPowerCalculators.add(new ScreenPowerCalculator(mPowerProfile));

mPowerCalculators.add(new AmbientDisplayPowerCalculator(mPowerProfile));

mPowerCalculators.add(new SystemServicePowerCalculator(mPowerProfile));

mPowerCalculators.add(new IdlePowerCalculator(mPowerProfile));

mPowerCalculators.add(new CustomMeasuredPowerCalculator(mPowerProfile));



mPowerCalculators.add(new UserPowerCalculator());

} ```

我們從上面可以看到cpu,wifi,gps等等都參與耗電的模組計算,同時我們的廠商可以基於此,去定製自己的耗電檢視,通常可以在應用資訊-電量可以看到,以我的vivo為例子

耗電檢測

當然,上面的資訊是原生android提供的資訊,對於手機廠商來說,是可以在此基礎上增加多種耗電檢測手段,因此處於一個“大雜燴”的現象。在Android P 及以上版本,谷歌官方推出了 Android Vitals 專案監控後臺耗電,目前還在推進過程中,高耗電Android應用提醒的標準,比如

對於應用開發者來說,目前檢測自己的應用是否耗電,有以下幾個方案

方案1 電流儀測試法

通過外部電流裝置,測試當前應用的耗電,同時由於我們可以獲取power_profile.xml 檔案,因此可以通過電流儀解析各個模組的對應耗電。

該方案優點是計算準確,缺點是硬體裝置投入高且無法定位出是哪個具體程式碼原因導致的電量消耗。

方案2 Battery Historian

Battery Historian,是谷歌官方提供給應用的耗電檢測工具,只需簡單的操作配置後,我們能夠得到當前執行時應用各模組耗電資訊。

覆蓋範圍包括了所有耗電模組資訊(包括了cpu,wifi,gps等),該方案優點是實施較為簡單,也能得到較為精確的耗電資料,也能得到相關的耗電詳情,缺點是無法定位出是程式碼中哪部分引起的耗電。

方案3 插樁法

我們可以通過插樁的方式,在相關的耗電api中進行位元組碼插樁,通過呼叫者的頻次,呼叫時間進行一定的收集整理,最終可以得到相關的耗電資料,同時因為對程式碼進行了插樁,我們也能夠獲取相關呼叫者的資料,便於之後的程式碼分析,該方案優點是能夠精確定位出程式碼呼叫級別的問題,缺點是耗電量化相對於前兩個方案來說不那麼精確,同時對於插樁api的選擇也要有一定的瞭解,對於資料的整合需要開發。

方案選擇

通過對現有方案的調研,我們最終使用了方案3 插樁法,因為耗電有一方面是客觀原因,比如對於貨運司機app來說,定位資料的獲取是伴隨著整個app應用週期的,因此耗電量也肯定集中在這個部分。選擇插樁法能讓我們快速定位出某些不合理的耗電呼叫,從而達到在不影響業務前提下進行優化。

既然選擇了方案3,那麼我們需要明確一下插樁的api選擇,由於現在行業內並沒有相關的開源,較為相關的是以Battery-metrics為代表的定製化檢測工具,Battery-metrics依靠插樁的方式,統計了多個部分的耗電時長與使用頻率,但是雖然資料處理這部分開源了,但是對於插樁這部分卻沒有開源,因此對於插樁api的選擇,我們參考了 Android Vitals 監控後臺耗電的規則

同時補充了我們收集的常見耗電api資料進行補充,且我們最終的耗電模組一定是系統耗電模組的子集,這裡選取的是bluetooth,cpu,location,sensor,wakelock來分析,同時還要一個特別的alarm模組也需要加入,因為alarm屬於雜項耗電的一種,部分廠商也會對alarm進行監控(alarm過多也會提示應用頻繁,降低耗電等),好了,目標明確,我們進行開發。

耗電監控實現

這裡分為兩個部分,一部分是耗電 api 的選擇,一部分是ASM插樁實現

耗電api選擇

BlueTooth

藍芽部分中,掃描部分是主要的耗電存在,屬於梯度耗電計算,功耗模組中也有wifi.scan去記錄藍芽的掃描功耗,通常我們可以通過以下方式開啟掃描

``` val bluetooth = this.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager

val scanSettings: ScanSettings = ScanSettings.Builder()

.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // 設定連續掃描

.build()

val scanner = bluetooth.adapter.bluetoothLeScanner

val callback = object : ScanCallback() {

override fun onBatchScanResults(results: MutableList<ScanResult>?) {

    super.onBatchScanResults(results)

}



override fun onScanFailed(errorCode: Int) {

    super.onScanFailed(errorCode)

}



override fun onScanResult(callbackType: Int, result: ScanResult?) {

    super.onScanResult(callbackType, result)

}

}

scanner.startScan(null, scanSettings, callback) ```

其中值得注意的是,ScanSettings中可以配置ScanMode,這裡的ScanMode的設定不同對耗電也有不同的影響

  • SCAN_MODE_LOW_POWER : 低功耗模式,預設此模式,如果應用不在前臺,則強制此模式
  • SCAN_MODE_BALANCED :平衡模式,一定頻率下返回結果
  • SCAN_MODE_LOW_LATENCY :高功耗模式,建議應用在前臺才使用此模式
  • SCAN_MODE_OPPORTUNISTIC:這種模式下, 只會監聽其他APP的掃描結果回撥

同時我們可以通過

scanner.stopScan(callback)

關閉本次藍芽掃描,到這裡,我們就明白了,我們主要關注的插樁api是startScan(開啟掃描)stopScan(停止掃描),並記錄耗電時間與當次掃描模式,以便後續按需進行優化。

cpu

cpu的使用時長我們可以通過讀取/proc/self/stat檔案獲取,得到的資料

24010 (cat) R 24007 24010 24007 34817 24010 4210688 493 0 0 0 1 0 0 0 20 0 1 0 42056617 2184900608 898 18446744073709551615 392793329664 392793777696 549292849424 0 0 0 0 0 1073775864 0 0 0 17 1 0 0 0 0 0 392793800160 392793810520 393204342784 549292851860 549292851880 549292851880 549292855272 0

上面的資料我們需要第14項-17項,分別是使用者態執行時間,核心態執行時間,使用者態下等待子程序執行的時間(子程序執行時間),核心態下等待子程序執行的時間(子程序執行時間),我們可以在linux manual上看到各個項的含義。

值得注意的是,我們得到的時間是以clock ticks(cpu時鐘節拍)計算的,所以我們需要獲取cpu運行了多少秒的話,那麼就需要cpu每秒的節拍,這個可以通過

Os.sysconf(OsConstants._SC_CLK_TCK)

獲取,通過兩者相除,我們就能得到程式cpu在使用者態以及核心臺執行的時間。

定位

定位也是一個耗電巨頭,常見的定位是gps,當然獲取不到gps定位時也會切換成net網路定位,還有wifi輔助定位等等,我們一般通過requestLocationUpdates發起一次持續定位,requestSingleUpdate發起一次單次定位。雖然requestSingleUpdate會讓定位provider(比如gps)保持一定的活躍時間,但是單次定位的消耗遠遠小於requestLocationUpdates持續定位,我們來關注一下requestLocationUpdates,它有很多過載的函式,

``` fun requestLocationUpdates(

provider: String,

minTime: Long,

minDistance: Float,

listener: LocationListener)

```

我們以此函式為例子

  • provider指當前定位由誰提供,常見有gps,network
  • minTime表示經過當前時間後,會重新發起一次定位
  • minDistance表示超過當前距離後,也會重新發起一次定位
  • listener就是當前定位資訊的回撥

持續定位存在耗電主要有以下方面:1.定位時間長,比如只有requestLocationUpdates,而沒有removeUpdates,導致定位在全域性範圍使用 2.minTime配置時間過短,導致定位頻繁 3.minDistance距離過短,也會導致定位頻繁。

取消定位可以通過removeUpdates去取消,當然官方推薦是需要時就開啟requestLocationUpdates,不需要就要通過removeUpdates及時關閉,達到一個性能最優的狀態,當然實際開發中,我們會遇到其他三方的sdk,比如百度定位,高德定位等,因為是第三方,一般都會內部封裝了requestLocationUpdates的呼叫。

因此,我們需要進行插樁的api就是requestLocationUpdates與removeUpdates啦,兩次呼叫的時間間隔就是定位的耗時。

sensor

sensor 感測器也是按照梯度計算的,主要是通過時的samplingPeriodUs,與maxReportLatencyUs區分不同的梯度

``` public boolean registerListener(SensorEventListener listener, Sensor sensor,

    int samplingPeriodUs, int maxReportLatencyUs) {

int delay = getDelay(samplingPeriodUs);

return registerListenerImpl(listener, sensor, delay, null, maxReportLatencyUs, 0);

} ```

  • samplingPeriodUs兩次感測器事件的最小間隔(ms)可以理解為取樣率,就算我們指定了這個引數,實際排程也是按照系統決定,samplingPeriodUs越大耗電越少
  • maxReportLatencyUs 允許被延遲排程的最大時間,預設為0,即希望立即排程,但是實際上也是由系統決定排程。

同樣的,我們也可以通過unregisterListener取消當前的sensor監聽,還是跟定位一樣,官方建議我們按需使用。

通過對sensor的理解,我們會發現sensor一般是由廠商定製化決定的排程時間,我們設定的引數會有影響,不過實際也是按照系統排程,感測器事件產生時,會放入一個佇列(佇列大小可由廠商定製)中,當系統處於低功耗時,非wakeup的sensor就算佇列滿了,也不會退出低功耗休眠模式。相反,如果屬於wakeup的sensor,系統就會退出休眠模式在佇列滿之前處理事件,進一步加大耗電,因為會使得AP(Application Processor AP是ARM架構的處理器)處於非休眠狀態,該狀態下ap能耗至少在50mA。我們判斷sensor是否wakeup可通過

``` public boolean isWakeUpSensor() {

return (mFlags & SENSOR_FLAG_WAKE_UP_SENSOR) != 0;

} ```

因此對於sensor我們主要插樁的api是registerListener與unregisterListener,統計sensor的耗時與wakeup屬性。

wakelock

wakelock是一種鎖的機制,只要有應用持有這個鎖,CPU就無法進入休眠狀態(AP處理會處於非休眠狀態),會一直處於工作狀態。因此就算是螢幕處於熄屏狀態,我們的系統也無法進行休眠,不僅如此,一些部分廠商也會通過wakelock持有市場,會彈窗提示應用耗電過多,因為無法進入低功耗狀態,所以往往會放大其他模組的耗電量,即使使用者什麼也沒做。因此如果cpu異常的app,可以排查wakelock的使用(往往是因為這個導致非使用app耗電,比如把應用放一晚上,第二天沒電的情況,可著重排查wakelock使用)。

wakelock包括PowerManager.WakeLock與WifiManager.WifiLock,兩者都提供了acquire方法獲取一個喚醒鎖

``` val mWakeLock = pm.newWakeLock(

PowerManager.PARTIAL_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,

this.javaClass.canonicalName

)

mWakeLock.setReferenceCounted(false)

mWakeLock.acquire() ```

其中這個lock預設是引用計數的。怎麼理解呢?就是呼叫acquire方法與呼叫release方法次數一致時,才會真正把這個鎖給釋放掉,否則會一直持有該lock,因此,它是一個隱藏很深的耗電刺客,需要時刻注意。當然我們也可以取消引用計數機制,可通過setReferenceCounted(false)設定,此時呼叫一次release即可釋放掉該lock。

值得一提的是,acquire也提供了timeout釋放的策略

``` public void acquire(long timeout) {

synchronized (mToken) {

    acquireLocked();

    mHandler.postDelayed(mReleaser, timeout);

}

} ```

private final Runnable mReleaser = () -> release(RELEASE_FLAG_TIMEOUT);

本質也是通過handler進行的postDelayed然後時間到了呼叫release方法釋放。

因此我們插樁的api是acquire,setReferenceCounted以及release函式,獲取wakelock的基礎資訊以及持有時長。

alarm

alarm嚴格來說並不在我們上述的耗電計算中,屬於雜項耗電,但是alarm通常會被亂用,同時部分精確的鬧鐘(比如setAlarmClock方法)會在low-power idle mode下,也會被觸發,導致低功耗模式下也進一步耗電。同時精確鬧鐘會脫離了系統以耗電最優的週期去觸發alarm,因此耗電效率不高但是較為“守時”。(普通的alarm會被系統安排在一定的週期進行)

該圖引自分析 Android 耗電原理後,飛書是這樣做耗電治理的

因為set方法會被系統排程,所以我們本次不在此討論,我們分析精確鬧鐘的api即可,分別是

``` low-power idle 能執行

public void setAlarmClock(AlarmClockInfo info, PendingIntent operation) {

setImpl(RTC_WAKEUP, info.getTriggerTime(), WINDOW_EXACT, 0, 0, operation,

        null, null, (Handler) null, null, info);

} ```

``` low-power idle 能執行

public void setExactAndAllowWhileIdle(@AlarmType int type, long triggerAtMillis,

    PendingIntent operation) {

setImpl(type, triggerAtMillis, WINDOW_EXACT, 0, FLAG_ALLOW_WHILE_IDLE, operation,

        null, null, (Handler) null, null, null);

} ```

``` low-power idle 不執行,但是精確鬧鐘會一定程度阻礙了系統採取耗電最優的方式進行觸發

public void setExact(@AlarmType int type, long triggerAtMillis, PendingIntent operation) {

setImpl(type, triggerAtMillis, WINDOW_EXACT, 0, 0, operation, null, null, (Handler) null,

        null, null);

} ```

因為alarm的特性,很多應用都採取alarm進行任務的排程,但是更加好的做法是,如果是應用內的定時任務,官方更加推薦直接採用Handler去實現,同時如果是後臺任務,更好的做法也是採用Worker Manager去實現。但是因為歷史原因,alarm其實還是被濫用的風險還是很高的,因此我們還是要對setExact,setExactAndAllowWhileIdle,setAlarmClock去進行插樁監控

耗電統計

到最後,我們如何獲取耗電百分比呢?其實我們可以直接通過廣播去獲取當前的電量level,單位時間後再次獲取,就是我們這段時間的耗電了,可以通過

``` val intent = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))

val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1

val scale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1 ```

因為電量變化是一個粘性廣播,我們可以直接從intent的返回獲取到當前的電量數值,同時也可以通過註冊一個廣播接聽當前是否處於充電狀態

``` override fun onReceive(context: Context?, intent: Intent?) {

synchronized(this) {

    when (intent?.action) {

        Intent.ACTION_POWER_CONNECTED -> {

            receive.invoke(SystemClock.elapsedRealtime(), Intent.ACTION_POWER_CONNECTED)

        }

        Intent.ACTION_POWER_DISCONNECTED -> {

            receive.invoke(SystemClock.elapsedRealtime(), Intent.ACTION_POWER_DISCONNECTED)

        }

    }

}

} ```

ASM插樁實現

通過耗電api的選擇這一部分的介紹,我們能夠得到了具體要進行位元組碼插樁的api,其實他們插樁的思路都大體一致,我們以wifiLock舉例子。

我們首先要明確我們需要的統計資訊是什麼:

  1. 函式呼叫時的引數:我們知道耗電會有梯度計算,不同模式下耗電影響也不同,所以我們需要發出api呼叫時的引數,才能做歸類統計
  2. 呼叫時的呼叫者:只是知道耗電處是不夠的,還要知道是誰發起的呼叫,方便我們後續排查問題。

因此,我們需要呼叫函式的時候,不僅要能夠保證獲取函式原本的引數呼叫,同時也要新增呼叫者引數。我們來看一下原本的wifiLock的使用以及對應編譯後的位元組碼:

``` val wifiLock: WifiManager.WifiLock =

wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL, "mylock")

wifiLock.acquire() ```

``` L4

LINENUMBER 92 L4

ALOAD 2

ICONST_1

LDC "mylock"

INVOKEVIRTUAL android/net/wifi/WifiManager.createWifiLock (ILjava/lang/String;)Landroid/net/wifi/WifiManager$WifiLock;

ASTORE 4

ALOAD 4

LDC "wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL, "mylock")"

INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkNotNullExpressionValue (Ljava/lang/Object;Ljava/lang/String;)V

ALOAD 4

L5

LINENUMBER 91 L5

ASTORE 3 將物件wifilock存入了index為3的區域性變量表

L6

LINENUMBER 93 L6

ALOAD 3  在區域性變量表取出index為3的物件 wifilock

INVOKEVIRTUAL android/net/wifi/WifiManager$WifiLock.acquire ()V

```

可以看到,位元組碼呼叫上本來就存在著環境相關的指令,比如ALoad,雖然跟我們的acquire方法呼叫無關,但是我們不能破壞指令的結構,因此我們在不破壞運算元棧的情況下,可以採用同類替換的方式,即把屬於WifiLock的acquire方法轉化為我們自定義的acquire方法,轉化後實際呼叫如下:

``` val wifiLock: WifiManager.WifiLock =

wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL, "mylock")

// 替換後

wifiLock.acquire() ===> WifiWakeLockHook.acquire(wifiLock) ```

那麼我們轉化的時候,就應該考慮的是:acquire被呼叫時,運算元棧其實隱含了一個wifilock物件,所以才能採用INCVOKEVIRTUAL的指令呼叫,如果想要呼叫變成我們自定義的hook類的話,我們也需要把wifilock物件當作引數列表的第一個引數傳入,同時為了保持運算元棧的結果,可以把INCVOKEVIRTUAL指令改為INVOKESTATIC指令,同時我們也需要記錄當前的呼叫者類名,我們就需要通過一個LDC指令把類名資訊放入運算元棧中,通過INVOKESTATIC指令呼叫一個全新的方法,原理講解完畢,我們一步步開始實現:

自定義Hook類,即WifiWakeLock 呼叫方法的替代實現

``` @Keep

object WifiWakeLockHook {

@Synchronized

@JvmStatic

fun setReferenceCounted(wakeLock: WifiLock, value: Boolean) {

    if (wifiWakeLockRecordMap[wakeLock.hashCode()] == null) {

        wifiWakeLockRecordMap[wakeLock.hashCode()] = WakeLockData()

    }

    with(wifiWakeLockRecordMap[wakeLock.hashCode()]!!) {

        isRefCounted = value

    }



    wakeLock.setReferenceCounted(value)

}



@JvmStatic

fun acquire(wifiLock: WifiLock, acquireClass: String) {

    wifiLock.acquire()



    if (wifiWakeLockRecordMap[wifiLock.hashCode()] == null) {

        wifiWakeLockRecordMap[wifiLock.hashCode()] = WakeLockData()

    }

    with(wifiWakeLockRecordMap[wifiLock.hashCode()]!!) {

        acquireTime++

        if (startHoldTime == 0L) {

            startHoldTime = SystemClock.uptimeMillis()

        }

        holdClassName = acquireClass

    }

}



@JvmStatic

fun release(wifiLock: WifiLock, releaseClass: String) {

    wifiLock.release()

    if (wifiWakeLockRecordMap[wifiLock.hashCode()] == null) {

        throw NoRecordException()

    }

    with(wifiWakeLockRecordMap[wifiLock.hashCode()]!!) {

        heldTime = SystemClock.uptimeMillis() - startHoldTime

        releaseTime++

        releaseClassName = releaseClass

    }



}

} ```

同時我們把需要記錄的資料放在一個map中,為了不產生記憶體洩漏,我們可以直接存入物件的hashcode作為key,同時value為我們自定義的需要採集的資料。

``` class WakeLockData() {

// acquire 方法呼叫次數

var acquireTime: Int = 0



// 釋放次數

var releaseTime: Int = 0



// 最終持有喚醒的時間 = 最後release - startHoldTime

var heldTime: Long = 0L

// 開始喚醒的時間

var startHoldTime: Long = 0L





// 是否採用了引用計數

var isRefCounted = true



// 針對呼叫acquire(long timeout)卻不呼叫release 的場景

var autoReleaseByTimeOver: Long = 0L



// 自動release 次數

var autoReleaseTime: Int = 0



var holdClassName :String = ""

var releaseClassName:String = ""



// WakeLock 是否已經被釋放

fun isRelease(): Boolean {

    if (!isRefCounted) {

        if (releaseTime > 0) {

            return true

        }

    } else {

        if (acquireTime == releaseTime) {

            return true

        }

        // 如果acquire的次數 == releaseTime && 超時刪除acquire已超時

        if ((acquireTime - autoReleaseTime) == releaseTime && SystemClock.uptimeMillis() - autoReleaseByTimeOver > 0) {

            return true

        }

    }

    return false

}



override fun toString(): String {

    return "WakeLockData(acquireTime=$acquireTime, releaseTime=$releaseTime, heldTime=$heldTime, startHoldTime=$startHoldTime, isRefCounted=$isRefCounted, autoReleaseByTimeOver=$autoReleaseByTimeOver, autoReleaseTime=$autoReleaseTime, holdClassName='$holdClassName', releaseClassName='$releaseClassName')"

}

} ```

ASM Hook

進行hook之前,我們需要找到我們想要hook的函式特徵,我們想要hook的函式是acquirerelease的,即如何唯一識別一個函式,有三大法寶:

  • 函式名:MethodInsnNode中的name,本例子分別是 acquire 與 release
  • 函式簽名:MethodInsnNode中的desc,本例子函式簽名都是()V
  • 函式呼叫者:MethodInsnNode的owner,本例子android/net/wifi/WifiManager$WifiLock,WifiLock是WifiManager的內部類

通過這一步,我們能夠找到了我們想要的函式,這樣就不會因為錯誤的hook導致其他函式的改變,接著我們根據上述思想,在這個方法的指令集中進行我們的“小操作”

按照流程圖,wifilock物件我們不需要改變,接著我們希望在呼叫函式最後加上呼叫者名稱,這個加的位置是在所以應調函式的背後,比如 acquire() 函式,我們加上呼叫者名稱後就變成這樣了acquire(String 呼叫者名稱) ,上面我們能夠了解,我們可以通過MethodInsnNode的owner屬性獲取,接著我們通過LDC指令即可把這個字串打入運算元棧,最後INVOKESTATIC呼叫自定義類的hook方法即可,最後別忘了修改函式簽名(desc) ,因為我們要呼叫的函式指令已經變成了自定義類的函式指令

由於我們需要hook的api都是INVOKEVIRYTUAL指令,所以我們可以採用上述的思想,形成一個工具類

```

ASM tree api

public class HookHelper {

static public void replaceNode(MethodInsnNode node, ClassNode klass, MethodNode method, String owner) {

    LdcInsnNode ldc = new LdcInsnNode(klass.name);

    method.instructions.insertBefore(node, ldc);

    node.setOpcode(Opcodes.INVOKESTATIC);

    int anchorIndex = node.desc.indexOf(")");

    String subDesc = node.desc.substring(anchorIndex);

    String origin = node.desc.substring(1, anchorIndex);

    node.desc = "(L" + node.owner + ";" + origin + "Ljava/lang/String;" + subDesc;

    node.owner = owner;

    System.out.println("replaceNode result is " + node.desc);

}

} ```

  • node 當前方法的某一條指令
  • klass 當前呼叫class
  • method 當前方法
  • owner 為我們需要變更後的hookclass的類名

資料層

通過以上步驟,我們能夠拿到所需的所有資料了,這裡再做一個統一的管理

同時各個資料的暴露方式可通過介面的方式提供給呼叫層,當資料更新時,呼叫者只需關心自己感興趣的部分即可

當然,我們預設也可以有一個debug環境下可使用的除錯頁面,方便及時檢視自己想要的資料。

後續補充方案

我們詳細介紹了耗電定位的做法,把複雜的耗電量計算轉換為單位時間內,耗電時長計算與實際耗電 api 模組呼叫次數去計算, 當然這並不是終點,通過插樁我們可以得到各個模組的使用時間與使用次數,在後續計劃中,我們可以通過對power_profile的解析,拿到不同手機的的模組資料,通過耗電量 = (各個模組呼叫時間 * 各個單位模組功耗)就可以低成本去量化耗電量這個指標,提供給更多的業務使用。

該方案優點如下:

| 優點 | | ---------------------------------- | | 基於aop方案位元組碼插樁asm實現,對程式碼無侵入 | | 自動初始化 | | 可根據呼叫粒度(比如呼叫次數)按需dump呼叫 | | 可記錄呼叫者class,方便後續排查問題,精準定位“bad sdk” |

總結

通過上面,我們能夠了解到自定義耗電檢測方案的原理與實現,當然具體需要採集的資料以及比對我們可自定義處理。