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

嘟囔發表於2019-01-27

一、引言

​ DoraemonKit是滴滴開源的研發助手元件,目前支援iOS和Android兩個平臺。通過接入DoraemonKit元件,可以方便支援如下所示的多種除錯工具:

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

​ 本文是DoraemonKit之Android版本技術實現系列文章的第一篇,主要介紹各個視覺工具的技術實現細節。

二、技術實現

2.1 取色器

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

方案對比

​ 取色器工具可以通過顏色吸管獲取螢幕任意位置的畫素值,所以實現的關鍵就是如何獲取畫素點。獲取畫素點的第一步是獲取螢幕截圖,獲取螢幕截圖在Android平臺主要有以下幾種方式:

  • 通過View的getDrawingCache方法
  • 通過讀取系統FrameBuffer
  • 通過MediaProjectionManager類

​ 對比三種實現方式,方式一隻能獲取當前Window內DocorView的內容,不能獲取狀態列或者脫離應用本身,且開啟DrawingCache會增加應用記憶體佔用;方式二中FrameBuffer不能直接讀取,需要獲得系統Root許可權,且相容性差;方式三可脫離應用本身獲取應用外截圖,截圖取自系統Binder不佔用應用記憶體,只需請求錄屏許可權。

getDrawingCache函式 讀取系統FrameBuffer MediaProjectionManager類
實現複雜度 簡單 複雜 較簡單
需要許可權 Root許可權 錄屏許可權
適用性 只能擷取應用內 應用內外都支援 應用內外都支援
效能影響

​ 通過對比,DoraemonKit選擇方式三作為取色器的實現方案。

請求錄屏許可權
private boolean requestCaptureScreen() {
    if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) {
        return false;
    }
    MediaProjectionManager mediaProjectionManager = (MediaProjectionManager) getContext().getSystemService(Context.MEDIA_PROJECTION_SERVICE);
    if (mediaProjectionManager == null) {
        return false;
    }
    startActivityForResult(mediaProjectionManager.createScreenCaptureIntent(), RequestCode.CAPTURE_SCREEN);
    return true;
}

@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (requestCode == RequestCode.CAPTURE_SCREEN && resultCode == Activity.RESULT_OK) {
        showColorPicker(data);
        ...
    } else {
        ...
    }
}
複製程式碼

​ 通過createScreenCaptureIntent()方法可以獲取請求系統錄屏許可權的Intent,然後呼叫startActivityForResult,系統會自動彈出許可權授予彈窗。如果授予許可權則在onActivityResult中得到系統回撥成功,且返回錄屏的resultData。

建立ImageReader
public void init(Context context, Bundle bundle) {
    mMediaProjectionManager = (MediaProjectionManager) context.getSystemService(Context.MEDIA_PROJECTION_SERVICE);
    if (mMediaProjectionManager != null) {
        Intent intent = new Intent();
        intent.putExtras(bundle);
        mMediaProjection = mMediaProjectionManager.getMediaProjection(Activity.RESULT_OK, intent);
    }
    int width = UIUtils.getWidthPixels(context);
    int height = UIUtils.getRealHeightPixels(context);
    int dpi = UIUtils.getDensityDpi(context);
    mImageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2);
    mMediaProjection.createVirtualDisplay("ScreenCapture",
            width, height, dpi,
            DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
            mImageReader.getSurface(), null, null);
}
複製程式碼

​ 通過onActivityResult中返回的resultData就可以建立系統錄屏服務MediaProjection,然後建立ImageReader並與MediaProjection的Surface進行繫結,之後就可以通過ImageReader獲取螢幕截圖了。

獲取螢幕截圖和畫素點
public void capture() {
    if (isCapturing) {
        return;
    }
    isCapturing = true;
    Image image = mImageReader.acquireLatestImage();
    if (image == null) {
        return;
    }
    int width = image.getWidth();
    int height = image.getHeight();
    Image.Plane[] planes = image.getPlanes();
    ByteBuffer buffer = planes[0].getBuffer();
    int pixelStride = planes[0].getPixelStride();
    int rowStride = planes[0].getRowStride();
    int rowPaddingStride = rowStride - pixelStride * width;
    int rowPadding = rowPaddingStride / pixelStride;
    Bitmap recordBitmap = Bitmap.createBitmap(width + rowPadding , height, Bitmap.Config.ARGB_8888);
    recordBitmap.copyPixelsFromBuffer(buffer);
    mBitmap = Bitmap.createBitmap(recordBitmap, 0, 0, width, height);
    image.close();
    isCapturing = false;
}
複製程式碼

​ 呼叫ImageReader的acquireLatestImage可以獲取當前螢幕的截圖,然後將Image物件轉為Bitmap物件就可以方便地進行顯示和獲取畫素點了。

public static int getPixel(Bitmap bitmap, int x, int y) {
    if (bitmap == null) {
        return -1;
    }
    if (x < 0 || x > bitmap.getWidth()) {
        return -1;
    }
    if (y < 0 || y > bitmap.getHeight()) {
        return -1;
    }
    return bitmap.getPixel(x, y);
}
複製程式碼

​ 根據浮標的座標在Bitmap上就可以很方便地獲取畫素點的值了。

2.2 控制元件檢查

​ 控制元件檢查的功能是通過浮標選取目標View,然後獲取目標View的相關資訊,所以如何獲取這個View的引用就是實現這一功能的關鍵。

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

監聽前臺Activity的進入
app.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
                @Override
            public void onActivityResumed(Activity activity) {
                ...
                for (ActivityLifecycleListener listener : sListeners) {
                    listener.onActivityResumed(activity);
                }
            }
}
複製程式碼

​ 通過註冊監聽可以在Activity進入Resumed狀態時獲得通知,這樣在浮標移動時DoraemonKit就可以持有最前臺的Activity。

遍歷ViewTree獲取目標View
private View traverseViews(View view, int x, int y) {
    int[] location = new int[2];
    view.getLocationInWindow(location);
    int left = location[0];
    int top = location[1];
    int right = left + view.getWidth();
    int bottom = top + view.getHeight();
    if (view instanceof ViewGroup) {
        int childCount = ((ViewGroup) view).getChildCount();
        if (childCount != 0) {
            for (int index = childCount - 1; index >= 0; index--) {
                View v = traverseViews(((ViewGroup) view).getChildAt(index), x, y);
                if (v != null) {
                    return v;
                }
            }
        }
        if (left < x &&  x < right && top < y &&  y < bottom) {
            return view;
        } else {
            return null;
        }
    } else {
        LogHelper.d(TAG, "class: " + view.getClass() + ", left: " + left
                + ", right: " + right + ", top: " + top + ", bottom: " + bottom);
        if (left < x &&  x < right && top < y &&  y < bottom) {
            return view;
        } else {
            return null;
        }
    }
}
複製程式碼

​ 因為View是以Tree的結構組織的,所以通過遍歷當前Activity的ViewTree就可以獲取到目標View。以DocorView作為根,遞迴呼叫同時判斷浮標座標是否在View的範圍即可以得到目標View。因為View可能存在覆蓋關係,所以需要使用深度優先遍歷才能獲得最頂端的View。

2.3 對齊標尺

​ 對齊標尺的實現比較簡單,只需要根據浮標座標繪製水平標尺線和豎直標尺線即可,實現效果如下圖。

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

2.4 佈局邊界

2.4.1 佈局邊界

​ DoraemonKit最開始實現佈局邊界,是通過遍歷ViewTree在懸浮Window上繪製邊框線的方式,但這種方式有一個問題就是如果Activity包含多個層級的時候,所有層級View的邊框都會被繪製在最頂層,導致顯示十分混亂,在複雜介面上基本是不可用的。

​ 在調研了幾種方式之後,DoraemonKit使用了替換View的Background的方式。View的Background是Drawable型別的,而LayerDrawable這種Drawable是可以包含一組Drawable的,所以取出View的原始Background後與繪製邊框的Drawable放進同一個LayerDrawable中,就可以實現帶邊框的背景。

private void traverseChild(View view) {
    if (view instanceof ViewGroup) {
        replaceDrawable(view);
        int childCount = ((ViewGroup) view).getChildCount();
        if (childCount != 0) {
            for (int index = 0; index < childCount; index++) {
                traverseChild(((ViewGroup) view).getChildAt(index));
            }
        }
    } else {
        replaceDrawable(view);
    }
}

private void replaceDrawable(View view) {
    LayerDrawable newDrawable;
    if (view.getBackground() != null) {
        Drawable oldDrawable = view.getBackground();
        if (oldDrawable instanceof LayerDrawable) {
            for (int i = 0; i < ((LayerDrawable) oldDrawable).getNumberOfLayers(); i++) {
                if (((LayerDrawable) oldDrawable).getDrawable(i) instanceof ViewBorderDrawable) {
                    // already replace
                    return;
                }
            }
            newDrawable = new LayerDrawable(new Drawable[] {
                    oldDrawable,
                    new ViewBorderDrawable(view)
            });
        } else {
            newDrawable = new LayerDrawable(new Drawable[] {
                    oldDrawable,
                    new ViewBorderDrawable(view)
            });
        }
    } else {
        newDrawable = new LayerDrawable(new Drawable[] {
                new ViewBorderDrawable(view)
        });
    }
    view.setBackground(newDrawable);
}
複製程式碼

​ 這種方式的好處就是實現簡單,且相容性很好,不會出現顯示異常,包括多個層級的覆蓋也能正常顯示,能實時地伴隨元件隱藏和顯示,但也存在一定的侵入性,會對View的繪製造成一定的開銷。

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

2.4.2 佈局層級

​ 佈局層級功能可以很方便地檢視當前頁面的Layout層級,是一個3D視覺化的效果,可以多個角度旋轉檢視,這個功能是依賴jakewharton的scalpel專案實現的,這個專案的核心只有一個原始碼檔案ScalpelFrameLayout,通過把希望檢視層級的頁面根View加到這個Layout中就可以實現佈局層級功能。這個Layout的實現原理是重寫了Layout的draw方法,通過Camera進行了3D變換,重新進行了排布繪製。

@Override public void draw(@SuppressWarnings("NullableProblems") Canvas canvas) {
    if (!enabled) {
        super.draw(canvas);
        return;
    }

    getLocationInWindow(location);
    float x = location[0];
    float y = location[1];

    int saveCount = canvas.save();

    float cx = getWidth() / 2f;
    float cy = getHeight() / 2f;

    camera.save();
    camera.rotate(rotationX, rotationY, 0);
    camera.getMatrix(matrix);
    camera.restore();

    matrix.preTranslate(-cx, -cy);
    matrix.postTranslate(cx, cy);
    canvas.concat(matrix);
    canvas.scale(zoom, zoom, cx, cy);

    if (!layeredViewQueue.isEmpty()) {
        throw new AssertionError("View queue is not empty.");
    }

    // We don't want to be rendered so seed the queue with our children.
    for (int i = 0, count = getChildCount(); i < count; i++) {
        LayeredView layeredView = layeredViewPool.obtain();
        layeredView.set(getChildAt(i), 0);
        layeredViewQueue.add(layeredView);
    }

    while (!layeredViewQueue.isEmpty()) {
        LayeredView layeredView = layeredViewQueue.removeFirst();
        View view = layeredView.view;
        int layer = layeredView.layer;

        // Restore the object to the pool for use later.
        layeredView.clear();
        layeredViewPool.restore(layeredView);

        // Hide any visible children.
        if (view instanceof ViewGroup) {
            ViewGroup viewGroup = (ViewGroup) view;
            visibilities.clear();
            for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) {
                View child = viewGroup.getChildAt(i);
                //noinspection ConstantConditions
                if (child.getVisibility() == VISIBLE) {
                    visibilities.set(i);
                    child.setVisibility(INVISIBLE);
                }
            }
        }

        int viewSaveCount = canvas.save();

        // Scale the layer index translation by the rotation amount.
        float translateShowX = rotationY / ROTATION_MAX;
        float translateShowY = rotationX / ROTATION_MAX;
        float tx = layer * spacing * density * translateShowX;
        float ty = layer * spacing * density * translateShowY;
        canvas.translate(tx, -ty);

        view.getLocationInWindow(location);
        canvas.translate(location[0] - x, location[1] - y);

        viewBoundsRect.set(0, 0, view.getWidth(), view.getHeight());
        canvas.drawRect(viewBoundsRect, viewBorderPaint);

        if (drawViews) {
            if (!(view instanceof SurfaceView)) {
                view.draw(canvas);
            }
        }

        if (drawIds) {
            int id = view.getId();
            if (id != NO_ID) {
                canvas.drawText(nameForId(id), textOffset, textSize, viewBorderPaint);
            }
        }

        canvas.restoreToCount(viewSaveCount);

        // Restore any hidden children and queue them for later drawing.
        if (view instanceof ViewGroup) {
            ViewGroup viewGroup = (ViewGroup) view;
            for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) {
                if (visibilities.get(i)) {
                    View child = viewGroup.getChildAt(i);
                    //noinspection ConstantConditions
                    child.setVisibility(VISIBLE);
                    LayeredView childLayeredView = layeredViewPool.obtain();
                    childLayeredView.set(child, layer + 1);
                    layeredViewQueue.add(childLayeredView);
                }
            }
        }
    }

    canvas.restoreToCount(saveCount);
}
複製程式碼

​ 佈局層級在新增SurfaceView等特殊繪製的View時可能出現繪製問題,出現黑白屏閃爍問題,需要遮蔽這些特殊View的繪製。

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

三、總結

​ 取色器元件的實現主要通過系統錄屏api,從截圖Bitmap中取得畫素點。

​ 控制元件檢查功能通過遍歷ViewTree實現,需要註冊全域性Activity的生命週期監聽。

​ 對齊標尺功能直接通過浮標的螢幕座標繪製水平和垂直標尺。

​ 佈局邊界功能通過替換View的Background實現,由包含原始Background的LayerDrawable替換原有Background。

​ 佈局層級主要是使用開源專案scalpel實現,對原有ViewTree進行3D變換,重新進行繪製。

​ 通過這篇文章主要是希望大家能夠對DoraemonKit視覺工具的技術實現有一個瞭解,如果有好的想法也可以參與到DoraemonKit開源專案的建設中來,在專案頁面提交Issues或者提交Pull Requests,相信DoraemonKit專案在大家的努力下會越來越完善。

​ DoraemonKit專案地址:github.com/didi/Doraem…,覺得不錯的話就給專案點個star吧。

四、交流群

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

相關文章