APP定位过于频繁,我用反射+动态代理揪出元凶

7,174 阅读9分钟

背景

定位现在是很多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);
    }
}

以上这个代理的作用就是取代LocationManagermService成员, 而实际的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_FETCHERSLocationService对应的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)

扩展阅读