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

532 阅读12分钟
原文链接: mp.weixin.qq.com

码个蛋(codeegg)第 799 次推文

作者:BlackZheng

博客:https://juejin.cn/post/6844903985258692621

码妞看世界

1. 背景

定位现在是很多 APP 最基本也不可或缺的能力之一,尤其是对打车、外卖之类的应用来说。但对定位的调用可不能没有节制,稍有不慎可能导致设备耗电过快,最终导致用户卸载应用。

笔者所在项目是一个在后台运行的 APP,且需要时不时在后台获取一下当前位置,再加上项目里会引入很多合作第三方的库,这些库内部同样也会有调用定位的行为,因此经常会收到测试的反馈说我们的应用由于定位过于频繁导致耗电过快。

排查这个问题的时候,笔者首先排除了我们业务逻辑的问题,因为项目中的各个功能模块在定位时调用的是统一封装后的定位模块接口,该模块中由对相应的接口做了一些调用频率的统计和监控并打印了相关的 log 语句,而问题 log 中跟定位相关的 log 语句打印频率跟次数都是在非常合理的范围内。

这时我才意识到频繁定位的罪魁祸首并不在我们内部,而是第三方库搞的鬼。那么问题来了,引入的第三方库那么多,我怎么知道谁的定位调用频率不合理呢?虽然我在项目中的公共定位模块中打了 log,但问题是第三方库可调不到我们内部的接口。 那么我们能不能到更底层的地方去埋点统计呢?

2. AOP

AOP,即面向切面编程,已经不是什么新鲜玩意了。就我个人的理解,AOP 就是把我们的代码抽象为层次结构,然后通过非侵入式的方法在某两个层之间插入一些通用的逻辑,常常被用于统计埋点、日志输出、权限拦截等等,详情可搜索相关的文章,这里不具体展开讲 AOP 了。

要从应用的层级来统计某个方法的调用,很显然 AOP 非常适合。而 AOP 在 Android 的典型应用就是 AspectJ 了,所以我决定用 AspectJ 试试,不过哪里才是最合适的插入点呢?我决定去 SDK 源码里寻找答案。

3. 策略探

首先我们来看看定位接口一般是怎么调用的:

LocationManager locationManager = (LocationManager)     context.getSystemService(Context.LOCATION_SERVICE);//单次定位locationManager.requestSingleUpdate(provider, new MyLocationLisenter(), getLooper());//连续定位locationManager.requestSingleUpdate(provider,minTime, minDistance, new MyLocationLisenter());

当然不止这两个接口,还有好几个重载接口,但是通过查看 LocationManager 的源码,我们可以发现最后都会调到这个方法:

//LocationManager.javaprivate 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 的方案无法满足需求。

4. 另辟蹊径

软的不行只能来硬的了,我决定祭出反射+动态代理杀招,不过还前提还是要找到一个合适的插入点。

通过阅读上面 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@Overridepublic Object getSystemService(String name) {    return SystemServiceRegistry.getSystemService(this, name);}

我们再到 SystemServiceRegistry 一探究竟

//SystemServiceRegistry.javafinal 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.javapublic 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);  }}

5. 总结

通过反射+动态代理,我们创建了一个 LocationManager 的钩子,然后在定位相关的方法执行时做一些埋点逻辑。笔者的初衷是能够从应用的层面,监测和统计各个模块对定位的请求情况,经过实测,以上实现能够完美得达到我的需求。

笔者具体的监测策略如下:每次 requestLocationUpdates 被调用时打印出调用方的类名,方法名,以及传入 requestLocationUpdates 的参数值 (参数中比较重要的信息有此次定位采用的 Provider, 连续定位的时间间隔、距离)

这里笔者虽然只是 hook 了定位服务,但这种思路也许可以适用于其他的系统服务,比如 AlarmManager 等,但实际操作起来肯定不太一样了,具体的细节还是需要去看源码了。如果大家有不错的想法,欢迎交流学习。

6. 注意事项

  • 本文的实现基于 Android P 源码,其他平台可能需要做额外的适配(总体思路是一样的)

  • 既然用了反射,肯定是有一定性能上的损耗了,所以应用到生产环境上的话得好好斟酌一下。

  • 众所周知,Android P 开始禁用非官方 API,受影响的 API 被分为浅灰名单(light greylist)、深黑名单(dark greylist)、黑名单 (blacklist)。当使用以上实现 hook LocationManager 时,会发现系统打印以下 log,说明这个接口已经在浅灰名单了,还是能正常运行,不过未来的 Android 版本可不敢保证了。

相关文章:

今日问题:

你有什么解决奇怪问题的妙招,快分享出来?

专属升级社区:《这件事情,我终于想明白了》