開源元件DoraemonKit之Android版本技術實現(二)

嘟囔發表於2019-02-25

一、引言

​ 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

相關文章