Android開發:APP定位過於頻繁,我用這種方式「揪出元兇」

像程序那樣思考 發佈 2020-01-06T18:16:54+00:00

作者:Android-until背景定位現在是很多APP最基本也不可或缺的能力之一,尤其是對打車、外賣之類的應用來說。但對定位的調用可不能沒有節制,稍有不慎可能導致設備耗電過快,最終導致用戶卸載應用。

作者:Android-until

背景

定位現在是很多APP最基本也不可或缺的能力之一,尤其是對打車、外賣之類的應用來說。但對定位的調用可不能沒有節制,稍有不慎可能導致設備耗電過快,最終導致用戶卸載應用。

筆者所在項目是一個在後台運行的APP,且需要時不時在後台獲取一下當前位置,再加上項目里會引入很多合作第三方的庫,這些庫內部同樣也會有調用定位的行為,因此經常會收到測試的反饋說我們的應用由於定位過於頻繁導致耗電過快。

排查這個問題的時候,筆者首先排除了我們業務邏輯的問題,因為項目中的各個功能模塊在定位時調用的是統一封裝後的定位模塊接口,該模塊中由對相應的接口做了一些調用頻率的統計和監控並列印了相關的log語句, 而問題log中跟定位相關的log語句列印頻率跟次數都是在非常合理的範圍內。

這時我才意識到頻繁定位的罪魁禍首並不在我們內部,而是第三方庫搞的鬼。

那麼問題來了,引入的第三方庫那麼多,我怎麼知道誰的定位調用頻率不合理呢?

雖然我在項目中的公共定位模塊中打了log,但問題是第三方庫可調不到我們內部的接口。

那麼我們能不能到更底層的地方去埋點統計呢?

AOP

AOP,即面向切面編程,已經不是什麼新鮮玩意了。

就我個人的理解,AOP就是把我們的代碼抽象為層次結構,然後通過非侵入式的方法在某兩個層之間插入一些通用的邏輯,常常被用於統計埋點、日誌輸出、權限攔截等等,詳情可搜索相關的文章,這裡不具體展開講AOP了。

要從應用的層級來統計某個方法的調用,很顯然AOP非常適合。而AOP在Android的典型應用就是AspectJ了,所以我決定用AspectJ試試,不過哪裡才是最合適的插入點呢?我決定去SDK源碼里尋找答案。

策略探索

首先我們來看看定位接口一般是怎麼調用的:

LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
//單次定位
locationManager.requestSingleUpdate(provider, new MyLocationLisenter(), getLooper());
//連續定位
locationManager.requestSingleUpdate(provider,minTime, minDistance, new MyLocationLisenter());
當然不止這兩個接口,還有好幾個重載接口,但是通過查看LocationManager的源碼,我們可以發現最後都會調到這個方法:

//LocationManager.java
private void requestLocationUpdates(LocationRequest request, LocationListener listener, Looper looper, PendingIntent intent) {

    String packageName = mContext.getPackageName();

    // wrap the listener class
    ListenerTransport transport = wrapListener(listener, looper);

    try {
        mService.requestLocationUpdates(request, transport, intent, packageName);
    } catch (RemoteException e) {
        throw e.rethrowFromSystemServer();
    }
}

看起來這裡是一個比較合適的插入點,但是如果你通過AspectJ的註解在這個方法被調用的時候列印log(AspectJ的具體用法不是本文重點,這裡不講解), 編譯運行下來後會發現根本沒有打出你要的log。

通過了解AspectJ的工作機制,我們就可以知道為什麼這個方法行不通了:

…在class文件生成後至dex文件生成前,遍歷並匹配所有符合AspectJ文件中聲明的切點,然後將事先聲明好的代碼在切點前後織入

LocationManager是android.jar里的類,並不參與編譯(android.jar位於android設備內)。這也宣告AspectJ的方案無法滿足需求。

另闢蹊徑

軟的不行只能來硬的了,我決定祭出反射+動態代理殺招,不過還前提還是要找到一個合適的插入點。

通過閱讀上面LocationManager的源碼可以發現定位的操作最後是委託給了mService這個成員對象的的requestLocationUpdates方法執行的。

這個mService是個不錯的切入點,那麼現在思路就很清晰了,首先實現一個mService的代理類,然後在我們感興趣的方法(requestLocationUpdates)被調用時,執行自己的一些埋點邏輯(例如打log或者上傳到伺服器等)。

首先實現代理類:

public class ILocationManagerProxy implements InvocationHandler {
    private Object mLocationManager;

    public ILocationManagerProxy(Object locationManager) {
        this.mLocationManager = locationManager;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if (TextUtils.equals("requestLocationUpdates", method.getName())) {
            //獲取當前函數調用棧
            StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
            if (stackTrace == null || stackTrace.length < 3) {
                return null;
            }
            StackTraceElement log = stackTrace[2];
            String invoker = null;
            boolean foundLocationManager = false;
            for (int i = 0; i < stackTrace.length; i++) {
                StackTraceElement e = stackTrace[i];
                if (TextUtils.equals(e.getClassName(), "android.location.LocationManager")) {
                    foundLocationManager = true;
                    continue;
                }
                //找到LocationManager外層的調用者 
                if (foundLocationManager && !TextUtils.equals(e.getClassName(), "android.location.LocationManager")) {
                    invoker = e.getClassName() + "." + e.getMethodName();
                    //此處可將定位接口的調用者信息根據自己的需求進行記錄,這裡我將調用類、函數名、以及參數列印出來
                    Log.d("LocationTest", "invoker is " + invoker + "(" + args + ")");
                    break;
                }
            }
        }
        return method.invoke(mLocationManager, args);
    }
}

以上這個代理的作用就是取代LocationManager的mService成員, 而實際的ILocationManager將被這個代理包裝。

這樣我就能對實際ILocationManager的方法進行插樁,比如可以打log,或將調用信息記錄在本地磁碟等。值得一提的是, 由於我只關心requestLocationUpdates, 所以對這個方法進行了過濾,當然你也可以根據需要制定自己的過濾規則。

代理類實現好了之後,接下來我們就要開始真正的hook操作了,因此我們實現如下方法:

public static void hookLocationManager(LocationManager locationManager) {
    try {
        Object iLocationManager = null;
        Class<?> locationManagerClazsz = Class.forName("android.location.LocationManager");
        //獲取LocationManager的mService成員
        iLocationManager = getField(locationManagerClazsz, locationManager, "mService");
        Class<?> iLocationManagerClazz = Class.forName("android.location.ILocationManager");

        //創建代理類
        Object proxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(),
                new Class<?>[]{iLocationManagerClazz}, new ILocationManagerProxy(iLocationManager));

        //在這裡移花接木,用代理類替換掉原始的ILocationManager
        setField(locationManagerClazsz, locationManager, "mService", proxy);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

簡單幾行代碼就可以完成hook操作了,使用方法也很簡單,只需要將LocationManager實例傳進這個方法就可以了。現在回想一下我們是怎麼獲取LocationManager實例的:

LocationManager locationManager = 
    (LocationManager)context.getSystemService(Context.LOCATION_SERVICE);

咱們一般當然是想hook應用全局的定位接口調用了,聰明的你也許想到了在Application初始化的時候去執行hook操作。

也就是

public class App extends Application {
    @Override
    public void onCreate() {
        LocationManager locationManager = (LocationManager)getSystemService(Context.LOCATION_SERVICE);
        HookHelper.hookLocationManager(locationManager);
        super.onCreate();
    }
}

可是這樣真的能保證全局的LocationManager都能被hook到嗎?

實測後你會發現還是有漏網之魚的,例如如果你通過Activity的context獲取到的LocationManager實例就不會被hook到,因為他跟Application中獲取到的LocationManager完全不是同一個實例,想知道具體原因的話可參閱這裡。

所以如果要hook到所有的LocationManager實例的話,我們還得去看看LocationManager到底是怎麼被創建的。

//ContextImpl.java
@Override
public Object getSystemService(String name) {
    return SystemServiceRegistry.getSystemService(this, name);
}
我們再到SystemServiceRegistry一探究竟

//SystemServiceRegistry.java
final class SystemServiceRegistry {
    private static final String TAG = "SystemServiceRegistry";
    ...
    static {
    ...
    //註冊ServiceFetcher, ServiceFetcher就是用於創建LocationManager的工廠類
    registerService(Context.LOCATION_SERVICE, LocationManager.class,
                new CachedServiceFetcher<LocationManager>() {
            @Override
            public LocationManager createService(ContextImpl ctx) throws ServiceNotFoundException {
                IBinder b = ServiceManager.getServiceOrThrow(Context.LOCATION_SERVICE);
                return new LocationManager(ctx, ILocationManager.Stub.asInterface(b));
            }});
    ...
    }

    //所有ServiceFetcher與服務名稱的映射
    private static final HashMap<String, ServiceFetcher<?>> SYSTEM_SERVICE_FETCHERS =
            new HashMap<String, ServiceFetcher<?>>();

    public static Object getSystemService(ContextImpl ctx, String name) {
        ServiceFetcher<?> fetcher = SYSTEM_SERVICE_FETCHERS.get(name);
        return fetcher != null ? fetcher.getService(ctx) : null;
    }

    static abstract interface ServiceFetcher<T> {
       T getService(ContextImpl ctx);
    }

}

到這裡,我們也就知道真正創建LocationManager實例的地方是在CachedServiceFetcher.createService,那問題就簡單了,我在LocationManager被創建的地方調用hookLocationManager,這下不就沒有漏網之魚了。

但是要達到這個目的,我們得把LocationService對應的CachedServiceFetcher也hook了。

大體思路是將SYSTEM_SERVICE_FETCHERS中LocationService對應的CachedServiceFetcher替換為我們實現的代理類LMCachedServiceFetcherProxy,在代理方法中調用hookLocationManager。

代碼如下:

public class LMCachedServiceFetcherProxy implements InvocationHandler {

    private Object mLMCachedServiceFetcher;

    public LMCachedServiceFetcherProxy(Object LMCachedServiceFetcher) {
        this.mLMCachedServiceFetcher = LMCachedServiceFetcher;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        //為什麼攔截getService,而不是createService?
        if(TextUtils.equals(method.getName(), "getService")){
            Object result = method.invoke(mLMCachedServiceFetcher, args);
            if(result instanceof LocationManager){
                //在這裡hook LocationManager
                HookHelper.hookLocationManager((LocationManager)result);
            }
            return result;
        }
        return method.invoke(mLMCachedServiceFetcher, args);
    }
}


//HookHelper.java
public static void hookSystemServiceRegistry(){
    try {
        Object systemServiceFetchers  = null;
        Class<?> locationManagerClazsz = Class.forName("android.app.SystemServiceRegistry");
        //獲取SystemServiceRegistry的SYSTEM_SERVICE_FETCHERS成員
        systemServiceFetchers = getField(locationManagerClazsz, null, "SYSTEM_SERVICE_FETCHERS");
        if(systemServiceFetchers instanceof HashMap){
            HashMap fetchersMap = (HashMap) systemServiceFetchers;
            Object locationServiceFetcher = fetchersMap.get(Context.LOCATION_SERVICE);
            Class<?> serviceFetcheClazz = Class.forName("android.app.SystemServiceRegistry$ServiceFetcher");
            //創建代理類
            Object proxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(),
                        new Class<?>[] { serviceFetcheClazz }, new LMCachedServiceFetcherProxy(locationServiceFetcher));
            //用代理類替換掉原來的ServiceFetcher
            if(fetchersMap.put(Context.LOCATION_SERVICE, proxy) == locationServiceFetcher){
                Log.d("LocationTest", "hook success! ");
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

也許你發現了,上面我們明明說的創建LocationManager實例的地方是在CachedServiceFetcher.createService,可是這裡我在getService調用時才去hook LocationManager,這是因為createService的調用時機太早,甚至比Application的初始化還早,所以我們只能從getService下手。

經過上面的分析我們知道每次你調用context.getSystemService的時候,CachedServiceFetcher.getService都會調用,但是createService並不會每次都調用,原因是CachedServiceFetcher內部實現了緩存機制,確保了每個context只能創建一個LocationManager實例。

那這又衍生另一個問題,即同一個LocationManager可能會被hook多次。這個問題也好解決,我們記錄每個被hook過的LocationManager實例就行了,HookHelper的最終代碼如下:

public class HookHelper {
    public static final String TAG = "LocationHook";

    private static final Set<Object> hooked = new HashSet<>();

    public static void hookSystemServiceRegistry(){
        try {
            Object systemServiceFetchers  = null;
            Class<?> locationManagerClazsz = Class.forName("android.app.SystemServiceRegistry");
            //獲取SystemServiceRegistry的SYSTEM_SERVICE_FETCHERS成員
            systemServiceFetchers = getField(locationManagerClazsz, null, "SYSTEM_SERVICE_FETCHERS");
            if(systemServiceFetchers instanceof HashMap){
                HashMap fetchersMap = (HashMap) systemServiceFetchers;
                Object locationServiceFetcher = fetchersMap.get(Context.LOCATION_SERVICE);
                Class<?> serviceFetcheClazz = Class.forName("android.app.SystemServiceRegistry$ServiceFetcher");
                //創建代理類
                Object proxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(),
                            new Class<?>[] { serviceFetcheClazz }, new LMCachedServiceFetcherProxy(locationServiceFetcher));
                //用代理類替換掉原來的ServiceFetcher
                if(fetchersMap.put(Context.LOCATION_SERVICE, proxy) == locationServiceFetcher){
                    Log.d("LocationTest", "hook success! ");
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void hookLocationManager(LocationManager locationManager) {
        try {
            Object iLocationManager = null;
            Class<?> locationManagerClazsz = Class.forName("android.location.LocationManager");
            //獲取LocationManager的mService成員
            iLocationManager = getField(locationManagerClazsz, locationManager, "mService");

            if(hooked.contains(iLocationManager)){
                return;//這個實例已經hook過啦
            }

            Class<?> iLocationManagerClazz = Class.forName("android.location.ILocationManager");

            //創建代理類
            Object proxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(),
                    new Class<?>[]{iLocationManagerClazz}, new ILocationManagerProxy(iLocationManager));

            //在這裡移花接木,用代理類替換掉原始的ILocationManager
            setField(locationManagerClazsz, locationManager, "mService", proxy);
            //記錄已經hook過的實例
            hooked.add(proxy);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static Object getField(Class clazz, Object target, String name) throws Exception {
        Field field = clazz.getDeclaredField(name);
        field.setAccessible(true);
        return field.get(target);
    }

    public static void setField(Class clazz, Object target, String name, Object value) throws Exception {
        Field field = clazz.getDeclaredField(name);
        field.setAccessible(true);
        field.set(target, value);
    }
}

總結

通過反射+動態代理,我們創建了一個LocationManager的鉤子,然後在定位相關的方法執行時做一些埋點邏輯。筆者的初衷是能夠從應用的層面,監測和統計各個模塊對定位的請求情況,經過實測,以上實現能夠完美得達到我的需求。

筆者具體的監測策略如下:

每次requestLocationUpdates被調用時列印出調用方的類名,方法名,以及傳入requestLocationUpdates的參數值(參數中比較重要的信息有此次定位採用的Provider,連續定位的時間間隔、距離)

這裡筆者雖然只是hook了定位服務,但這種思路也許可以適用於其他的系統服務,比如AlarmManager等,但實際操作起來肯定不太一樣了,具體的細節還是需要去看源碼了。如果大家有不錯的想法,歡迎交流學習。

注意事項

本文的實現基於Android P源碼, 其他平台可能需要做額外的適配(總體思路是一樣的)

既然用了反射, 肯定是有一定性能上的損耗了, 所以應用到生產環境上的話得好好斟酌一下。

眾所周知,Android P開始禁用非官方API,受影響的API被分為淺灰名單(light greylist)、深黑名單(dark greylist)、黑名單 (blacklist)。當使用以上實現hook LocationManager時,會發現系統列印以下log,說明這個接口已經在淺灰名單了,還是能正常運行,不過未來的Android版本可不敢保證了。

W/idqlocationtes: Accessing hidden field Landroid/location/LocationManager;->mService:Landroid/location/ILocationManager;
 (light greylist, reflection)

在這裡我也分享一份私貨,自己收錄整理的Android學習PDF+架構視頻+面試文檔+源碼筆記,還有高級架構技術進階腦圖、Android開發面試專題資料,高級進階架構資料幫助大家學習提升進階,也節省大家在網上搜索資料的時間來學習,也可以分享給身邊好友一起學習

如果你有需要的話,可以點讚+評論+轉發關注我,然後私信我【進階】我發給你

關鍵字: