阅读 2915

开源组件DoraemonKit之Android版本技术实现(二)

一、引言

​ DoraemonKit是滴滴开源的研发助手组件,目前支持iOS和Android两个平台。通过接入DoraemonKit组件,可以方便支持如下所示的多种调试工具:

device-2019-01-27-231951

​ 本文是DoraemonKit之Android版本技术实现系列文章的第二篇,主要介绍各个常用工具的技术实现细节。

二、技术实现

2.1 app基本信息

​ 很多时候,我们在开发或者调试的过程中需要查看一些手机或者app相关的参数,这些参数类似手机型号、操作系统版本和应用包名等。正因为有这样的需要,DoraemonKit提供了汇总的app基本信息展示功能。

device-2019-01-30-140714

如何获取信息

​ 信息主要分两大类,一类是手机信息,一类是App信息。手机信息主要通过Build类获取,App信息主要通过Context及其相关类获取。

通过Build类获取信息

​ 下面是Build类可以获取到的常用信息:

字段 含义 示例
Build.BRAND 品牌 Meizu
Build.MANUFACTURER 厂商 Meizu
Build.DEVICE 型号 mx3
Build.VERSION.SDK_INT SDK版本 19
Build.CPU_ABI CPU ABI armeabi-v7a

​ Build类主要是通过读取/system/build.prop文件中的配置,比如Build.MANUFACTURER就是其中ro.product.manufacturer对应的值,Build类中的值是系统预先读取在内存中的,也可以不通过Build类直接读取build.prop文件。

通过Context类获取信息

​ Context类是Android系统中最重要的类,是App和系统之间的纽带,通过App的Context可以获取App相关的信息,如App的包名:

String packageName = context.getPackageName();
复制代码

​ 获取应用图标:

Drawable icon = context.getResources().getDrawable(context.getApplicationInfo().icon);
复制代码

​ 获取应用名:

String label = context.getString(context.getApplicationInfo().labelRes);
复制代码

​ 判断权限:

if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED
        || ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
    LogHelper.d(TAG, "No Location Permission");
    return;
}
复制代码

2.2 文件浏览

​ 在开发和调试过程中经常需要查看一些App自有目录的文件内容,虽然Android系统通常会提供文件浏览器的系统应用,但是因为App自有目录中大多数属于私有目录,所以如果App可以集成一个自己的文件浏览功能就可以很方便地查看私有目录中的文件,比如sharedprefs配置。

device-2019-02-24-222854

​ 通过context获取私有目录的文件信息:

fileInfos.add(new FileInfo(context.getFilesDir().getParentFile()));
fileInfos.add(new FileInfo(context.getExternalCacheDir()));
fileInfos.add(new FileInfo(context.getExternalFilesDir(null)));
复制代码

​ 然后就可以根据File信息展示当前文件夹的信息,同时也可以拿到子文件的信息,填充列表的Adapter就可以展示如上图所示的文件浏览器。

​ 哆啦A梦目前支持图片查看和文本查看,默认的查看方式是文本查看,判断文件种类的方式是根据文件后缀。

public static String getSuffix(File file) {
    if (file == null || !file.exists()) {
        return "";
    }
    return file.getName()
            .substring(file.getName().lastIndexOf(".") + 1)
            .toLowerCase(Locale.getDefault());
}
复制代码

​ 哆啦A梦也支持分享到第三方应用查看,是通过FileProvider对外分享的,只有通过FileProvider才能将私有目录中的文件分享出去:

Intent intent = new Intent(Intent.ACTION_VIEW);
Uri uri;
uri = FileProvider.getUriForFile(context, context.getPackageName() + ".debugfileprovider", file);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setDataAndType(uri, type);
if (intent.resolveActivity(context.getPackageManager()) == null) {
    intent.setDataAndType(uri, DATA_TYPE_ALL);
}
context.startActivity(intent);
复制代码

​ 同时在FileProvider的path中需要声明root-path,这样才能包含所有的私有目录。

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <root-path name="name" path="" />
</paths>
复制代码

2.3 位置模拟

​ 位置模拟是地图类应用十分常用的调试功能,哆啦A梦在实现位置模拟功能时主要尝试了两种方案。

​ 第一个方案是Android系统提供的LocationManager类下面TestProvider相关API,这个方案的实现非常容易,只需要调用相关的系统API:

mLocationManager.addTestProvider(name,
        provider.requiresNetwork(),
        provider.requiresSatellite(),
        provider.requiresCell(),
        provider.hasMonetaryCost(),
        provider.supportsAltitude(),
        provider.supportsSpeed(),
        provider.supportsBearing(),
        provider.getPowerRequirement(),
        provider.getAccuracy());
mLocationManager.setTestProviderEnabled(name, true);
mLocationManager.setTestProviderStatus(name, LocationProvider.AVAILABLE, null, System.currentTimeMillis());

复制代码

​ 然后向provider中设置需要模拟的Location就可以实现系统全局模拟GPS,它mock的不仅限于应用本身,也可以影响到其他应用,所以很多位置模拟软件都是使用这个方案实现的。

​ 但是这个方案的缺点也很明显,第一点是需要在开发者模式设置页中开启模拟定位权限,这个缺点还比较容易接受。第二点是很多地图SDK存在反作弊机制,会判断获取的Location是否来自TestProvider,Android系统本身就提供了判断方法:

location.isFromMockProvider();

复制代码

​ 通过测试发现常用的地图SDK中,腾讯地图和百度地图不能使用TestProvider模拟定位,高德地图和Google地图可以模拟定位,这就导致这个方案在很多时候都不能生效,而且因为我们的SDK是关注于应用本身的调试功能的,所以不需要具备影响其他应用的能力。

​ 第二个方案是通过Hook系统Binder服务的方式,动态代理Location Service。

public class LocationHookHandler implements InvocationHandler {
    private static final String TAG = "LocationHookHandler";

    private Object mOriginService;

    @SuppressWarnings("unchecked")
    @SuppressLint("PrivateApi")
    public LocationHookHandler(IBinder binder) {
        try {
            Class iLocationManager$Stub = Class.forName("android.location.ILocationManager$Stub");
            Method asInterface = iLocationManager$Stub.getDeclaredMethod("asInterface", IBinder.class);
            this.mOriginService = asInterface.invoke(null, binder);
        } catch (Exception e) {
            LogHelper.e(TAG, e.toString());
        }
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        switch (method.getName()) {
            case "requestLocationUpdates":
                ...
                break;
            case "getLastLocation":
            	...
                return lastLocation;
            case "getLastKnownLocation":
            	...
                return lastKnownLocation;
            default:
                break;
        }
        return method.invoke(this.mOriginService, args);
    }
}

复制代码

​ 上面的代码就是Location服务的代理类,通过替换原有requestLocationUpdates、getLastLocation和getLastKnownLocation接口的实现就可以实现模拟定位,返回我们想要模拟的位置,主要利用的就是InvocationHandler动态代理机制。

​ Android对系统服务主要是通过ServiceManager去管理的,且服务的实例是保存在静态全局变量中的。

public final class ServiceManager {
    private static HashMap<String, IBinder> sCache = new HashMap<String, IBinder>();

	...
	
    public static IBinder getService(String name) {
        try {
            IBinder service = sCache.get(name);
            if (service != null) {
                return service;
            } else {
                return Binder.allowBlocking(getIServiceManager().getService(name));
            }
        } catch (RemoteException e) {
            Log.e(TAG, "error in getService", e);
        }
        return null;
    }
    
    ...

复制代码

​ 服务实例保存在HashMap中,key是Context中定义的常量。

public static final String LOCATION_SERVICE = "location";

复制代码

​ 所以可以在应用初始化的时候提前替换掉sCache中的实例,这样后面通过context.getSystemService获取到的Service实例就是被动态代理的实例。

Class serviceManager = Class.forName("android.os.ServiceManager");
Method getService = serviceManager.getDeclaredMethod("getService", String.class);
IBinder binder = (IBinder) getService.invoke(null, Context.LOCATION_SERVICE);

ClassLoader classLoader = binder.getClass().getClassLoader();
Class[] interfaces = {IBinder.class};
BinderHookHandler handler = new BinderHookHandler(binder);
IBinder proxy = (IBinder) Proxy.newProxyInstance(classLoader, interfaces, handler);

Field sCache = serviceManager.getDeclaredField("sCache");
sCache.setAccessible(true);
Map<String, IBinder> cache = (Map<String, IBinder>) sCache.get(null);

cache.put(Context.LOCATION_SERVICE, proxy);
sCache.setAccessible(false);

复制代码

​ 替换实例的时机需要尽可能早,这样才能保证在context.getSystemService前替换掉对应实例,所以在应用初始化的时机执行替换是比较推荐的。

2.4 Crash查看

​ 哆啦A梦目前只支持捕获Java异常,后续会扩展到支持捕获jni异常,捕获Java异常主要通过设置UncaughtExceptionHandler,系统会在发生异常时通知到UncaughtExceptionHandler。

@Override
public void uncaughtException(Thread thread, Throwable ex) {
	...
}

复制代码

​ 回调会返回发生异常的thread和异常信息。

2.5 日志查看

device-2019-02-25-145354

​ 日志查看功能主要是在手机端集成Logcat的相关功能,可以过滤Log关键字,或者Log级别,核心逻辑是在手机端打印Logcat然后将获取到的Log信息进行展示。

​ 打印Logcat可以通过Runtime的exec函数实现。

Runtime.getRuntime().exec("logcat -c");
Process process = Runtime.getRuntime().exec("logcat -v time");
InputStream is = process.getInputStream();
InputStreamReader reader = new InputStreamReader(is);
BufferedReader br = new BufferedReader(reader);

String log;
while ((log = br.readLine()) != null && isRunning) {
    Message message = Message.obtain();
    message.what = MESSAGE_PUBLISH_LOG;
    message.obj = log;
    internalHandler.sendMessage(message);
}

br.close();
reader.close();
is.close();

复制代码

​ 每一条Runtime.getRuntime().exec(…)表示在命令行中执行的一条命令,就和我们再terminal中输入命令是一样的,返回值是执行命令行的Process,然后从Process中获取InputStream,后面就可以持续从Process中获取Log信息了。

​ Log信息的解析代码如下,可以获取level,packagePriority,message,date和time等多种属性,后续可以根据不同维度去做过滤和分类。

public LogInfoItem(String log) {
    orginalLog = log;
    if (log.contains("V/")) {
        level = Log.VERBOSE;
    } else if (log.contains("D/")) {
        level = Log.DEBUG;
    } else if (log.contains("I/")) {
        level = Log.INFO;
    } else if (log.contains("W/")) {
        level = Log.WARN;
    } else if (log.contains("E/")) {
        level = Log.ERROR;
    } else if (log.contains("A/")) {
        level = Log.ASSERT;
    }
    int beginIndex = log.indexOf(": ");
    if (beginIndex == -1) {
        meseage = log;
    } else {
        meseage = log.substring(beginIndex + 2);
    }
    beginIndex = log.indexOf("/");
    int endIndex = log.indexOf("/", beginIndex + 1);
    if (beginIndex != -1 && endIndex != -1) {
        packagePriority = log.substring(beginIndex + 1, endIndex - 3);
    }
    endIndex = log.indexOf(" ");
    if (endIndex != -1) {
        date = log.substring(0, endIndex);
    }
    beginIndex = endIndex;
    endIndex = log.indexOf(" ", beginIndex + 1);
    if (endIndex != -1 && beginIndex != -1) {
        time = log.substring(beginIndex, endIndex);
    }
}

复制代码

2.6 缓存清理

​ 很多时候需要恢复APP到新安装状态,可以通过系统中的应用设置页实现,但是这样需要很多步操作,所以哆啦A梦SDK集成了集成缓存的功能。

​ 基础方法是清除某个文件夹的所有内容。

private static void deleteFilesByDirectory(File directory) {
    if (directory != null && directory.exists() && directory.isDirectory()) {
        for (File item : directory.listFiles()) {
            item.delete();
        }
    }
}

复制代码

​ 删除内部缓存。

public static void cleanInternalCache(Context context) {
    deleteFilesByDirectory(context.getCacheDir());
}

复制代码

​ 删除内部文件。

public static void cleanFiles(Context context) {
    deleteFilesByDirectory(context.getFilesDir());
}

复制代码

​ 删除SharedPrefs文件。

public static void cleanSharedPreference(Context context) {
    deleteFilesByDirectory(new File(context.getFilesDir().getParent() + "/shared_prefs"));
}

复制代码

​ 删除数据库文件。

public static void cleanDatabases(Context context) {
    deleteFilesByDirectory(new File(context.getFilesDir().getParent() + "/databases"));
}

复制代码

​ 清除外部缓存。

public static void cleanExternalCache(Context context) {
    if (Environment.getExternalStorageState().equals(
            Environment.MEDIA_MOUNTED)) {
        deleteFilesByDirectory(context.getExternalCacheDir());
    }
}

复制代码

​ 因为SharedPrefs是读取到内存的,所以生效必须重启APP。

2.7 H5任意门

​ 实现非常简单,就是通过注册回调。

DoraemonKit.setWebDoorCallback(new WebDoorManager.WebDoorCallback() {
    @Override
    public void overrideUrlLoading(Context context, String url) {
        Intent intent = new Intent(App.this, WebViewActivity.class);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        intent.putExtra(WebViewActivity.KEY_URL, url);
        startActivity(intent);
    }
});

复制代码

​ 在哆啦A梦的页面输入H5地址后,哆啦A梦会回调地址和Context,通知接入App调起H5容器跳转到对应页面。

三、总结

​ App基本信息的获取主要通过Build类和Context及其相关类。

​ 文件浏览功能的关键是通过FileProvider把私有文件分享出去。

​ 位置模拟功能利用了InvocationHandler动态代理机制,代理了Location Service的接口实现。

​ Crash查看通过设置UncaughtExceptionHandler实现获取发生线程和异常。

​ 日志查看是通过命令行运行Logcat来获取Log信息并展示的。

​ 缓存清理需要删除内部缓存、内部文件、SharedPrefs、数据库文件和外部缓存。

​ H5任意门只需注册回调就可以获得透传的H5地址。

​ 通过这篇文章主要是希望大家能够对DoraemonKit常用工具的技术实现有一个了解,如果有好的想法也可以参与到DoraemonKit开源项目的建设中来,在项目页面提交Issues或者提交Pull Requests,相信DoraemonKit项目在大家的努力下会越来越完善。

​ DoraemonKit项目地址:github.com/didi/Doraem…,觉得不错的话就给项目点个star吧。

四、交流群

20190127231730

关注下面的标签,发现更多相似文章
评论