一、背景

APM 的全称叫做 Application Performance Monitor,属于应用性能监控部分。其中有一项比较重要的指标参数,叫做页面可视耗时,本文将介绍一套耗时检测方案。

二、方案

1、Activity页面加载时间

public class BaseActivity extends Activity {

    public boolean isNeedLoadingTimeDetect() {
        return true;
    }

    public int getLoadingTimeDetectType() {
        return LoadingDetector.AREA_DETECT;
    }

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if (isNeedLoadingTimeDetect()) {
            LoadingDetector.getInstance().startWatch(this, getLoadingTimeDetectType());
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        if(isNeedLoadingTimeDetect()) {
            LoadingDetector.getInstance().destroyWatch(this, getLoadingTimeDetectType());
        }
    }
}

首先启动一个定时线程池服务ScheduledThreadPoolExecutor,每隔60ms去做屏幕decorview的检测,判断view是否是加载页面完成的状态,检测处理是在线程池,为了不影响UI线程的处理。

private void checkPageVisible(Activity activity, ViewTimeEntry entry, int detectType) {
        if (entry == null) {
            return;
        }
        ScheduledThreadPoolExecutor scheduledExecutorService = new ScheduledThreadPoolExecutor(2);
        scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                boolean isVisible;
                isVisible = detectType == AREA_DETECT ? checkAreaVisible(activity) : checkPixelsPageVisible(activity);
                if (isVisible) {
                    scheduledExecutorService.shutdown();
                    if (entry != null) {
                        entry.setEndTime(System.currentTimeMillis());
                        showToastOnMainThread(activity, entry);
                    }
                } else {
                    long currentTime = System.currentTimeMillis();
                    if (currentTime - entry.getStartTime() >= 10000) {
                        //兜底操作
                        scheduledExecutorService.shutdown();
                        entry.setEndTime(currentTime);
                        showToastOnMainThread(activity, entry);
                    }
                }
            }
        }, 60, 60, TimeUnit.MILLISECONDS);
        entry.setScheduledExecutorServic(scheduledExecutorService);
    }

如果大于10s还没有加载完成页面,默认关闭线程池结束,作为一个兜底方案。

针对Activity页面加载时间检测算法,可分为两种方案,

public static final int AREA_DETECT = 1;
    public static final int PIXELS_DETECT = 2;

一种是面积可见区域算法,一种是像素计算法。
a、面积可见区域算法
第一步,首先获取decorview下的所有子view,如果是viewgroup,就继续遍历所有的子view,知道得到所有view的集合。
第二步,计算所有子view的可视范围内的面积(如果超过了屏幕范围了则截取屏幕范围内的部分),去除以phone的屏幕面积,比值如果大于0.6f则当作页面加载完成。
其中需要注意,子view包含的有:textview, editview,imageview,自定义view等等。

针对这些view我们增加更细的规则:

  • View 在全部或者部分在屏幕范围内,且 Visibility 必须为 View.VISIBLE
  • 只针对 View 进行计算,ViewGroup 不在计算范围之列,且不是 ViewStub
  • 如果是 ImageView,必须是加载图片完成后才能当作有效的view
  • 如果是 TextView,必须要有文字

遍历所有子view如下:

public static List<View> getAllViews(Activity activity) {
        List<View> viewList = new ArrayList<>();
        ViewGroup decorView = (ViewGroup) activity.getWindow().getDecorView();
        for (int i = 0; i < decorView.getChildCount(); i++) {
            if (decorView.getChildAt(i).getVisibility() != View.VISIBLE) {
                continue;
            }
            if (decorView.getChildAt(i) instanceof ViewGroup) {
                viewList.addAll(getAllViews((ViewGroup) decorView.getChildAt(i)));
            } else if (!(decorView.getChildAt(i) instanceof ViewStub)) {
                viewList.add(decorView.getChildAt(i));
            }
        }
        return viewList;
    }

    private static List<View> getAllViews(ViewGroup viewGroup) {
        List<View> viewList = new ArrayList<>();
        for (int i = 0; i < viewGroup.getChildCount(); i++) {
            if (viewGroup.getChildAt(i).getVisibility() != View.VISIBLE) {
                continue;
            }
            if (viewGroup.getChildAt(i) instanceof ViewGroup) {
                viewList.addAll(getAllViews((ViewGroup) viewGroup.getChildAt(i)));
            } else if (!(viewGroup.getChildAt(i) instanceof ViewStub)) {
                viewList.add(viewGroup.getChildAt(i));
            }
        }
        return viewList;
    }

处理Imageview遇到了问题,如果图片没有加载出来,不能算是有效view的面积进行计算。解决方案:

public class ViewTag {
    /**
     * 当前状态是无效的View,但是仅仅表示当前状态,有可能变成有效,例如 ImageView
     */
    public static final String APM_VIEW_VALID = "valid_view";
    /**
     * 当前状态是有效的View
     */
    public static final String APM_VIEW_INVALID = "invalid_view";
    /**
     * 需要完全忽略的无用 View,这个 View 完全是计算的噪点,例如鱼骨图
     */
    public static final String APM_VIEW_IGNORE = "ignore_view";
}
public static void loadImage(Context context, ImageView imageView, String url) {
        imageView.setTag(ViewTag.APM_VIEW_INVALID);
        Glide.with(context).load(url).apply(new RequestOptions().diskCacheStrategy(DiskCacheStrategy.NONE)).into(new SimpleTarget<Drawable>() {
            @Override
            public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
                imageView.setImageDrawable(resource);
                imageView.setTag(ViewTag.APM_VIEW_VALID);
            }
        });
    }
private static List<View> getAllViews(ViewGroup viewGroup) {
        List<View> viewList = new ArrayList<>();
        for (int i = 0; i < viewGroup.getChildCount(); i++) {
            if (viewGroup.getChildAt(i) instanceof ViewGroup) {
                viewList.addAll(getAllViews((ViewGroup) viewGroup.getChildAt(i)));
            } else if (!(viewGroup.getChildAt(i) instanceof ViewStub)) {
                viewList.add(viewGroup.getChildAt(i));
            }
        }
        return viewList;
    }

在图片加载框架中处理,如果图片加载完成,设置一个有效标志tag,遍历所有的子view时,如果是imageview且tag标志为有效view的标志才能算入到有效view的面积当中去。
如果是项目添加了骨架图,也是可以通过这种设置tag有效标志的方法来进行处理

计算面积的方法如下:

public static float calculateVisibleArea(Activity activity, List<View> viewList) {
        int screenWidth = activity.getWindowManager().getDefaultDisplay().getWidth();
        int screenHeight = activity.getWindowManager().getDefaultDisplay().getHeight();
        float phoneArea = screenHeight * screenWidth;
        float area = 0;
        for (int i = 0; i < viewList.size(); i++) {
            View view = viewList.get(i);
            if (view instanceof ImageView && !ViewTag.APM_VIEW_VALID.equals(view.getTag())) {
                continue;
            }
            if(view instanceof TextView || view instanceof ImageView) {
                area += getViewArea(viewList.get(i), screenWidth, screenHeight);
            }
        }
        return area / phoneArea;
    }

    private static float getViewArea(View view, int screenWidth, int screenHeight) {
        int[] location = new int[2];
        view.getLocationOnScreen(location);
        return (getPos(location[0], true, screenWidth, screenHeight) - getPos(location[0] + view.getRight() - view.getLeft(), true, screenWidth, screenHeight))
                * (getPos(location[1], false, screenWidth, screenHeight) - getPos(location[1] + view.getBottom() - view.getTop(), false, screenWidth, screenHeight));
    }

    private static int getPos(int pos, boolean isX, int screenWidth, int screenHeight) {
        if (pos < 0) {
            return 0;
        }
        if (isX) {
            return Math.min(pos, screenWidth);
        } else {
            return Math.min(pos, screenHeight);
        }
    }

b、像素计算法
这种方法主要是针对非Activity页面,如H5页面,RN,Flutter页面。我们无法去获取子view的面积。我们可以通过这种像素计算的方法来处理。

public static boolean doCheckViewPixels(final View view) {
        boolean isDrawFinish = false;
        Bitmap bitmap;
        try {
            view.setDrawingCacheEnabled(true);
            bitmap = view.getDrawingCache();
            if (bitmap == null || bitmap.isRecycled()) {
                return false;
            }
            try {
                int[] pixelArray = collectPixel(bitmap);
                //只有三种情况全是 true 的时候,才认为页面是加载成功了
                if (planA(pixelArray) && planC(pixelArray) && planB(pixelArray)) {
                    isDrawFinish = true;
                }
            } catch (Exception e) {
                isDrawFinish = false;
            }
            try {
                view.setDrawingCacheEnabled(false);
            } catch (Exception e) {
            }
        } catch (Exception e) {
            return false;
        }
        return isDrawFinish;
    }

首先去采集屏幕上的像素点,比如200个

private final static int SIZE = 200;
    private final static int TOP_SIZE = 100;
    private static double randomValue = 0;
    /**
     * 采集 bitmap 中的像素点
     * 页面一分为2,上面40个点,下面20个点
     *
     * @return 采集到的像素数组
     */
    private static int[] collectPixel(Bitmap b) {
        int[] pixels = new int[SIZE];
        int w = b.getWidth();
        int h = b.getHeight();
        int segmentLength = w * h / 2;  //平均分为2段,每一段的长度
        int spaceUP = segmentLength / TOP_SIZE;
        int spaceDOWN = segmentLength / (SIZE - TOP_SIZE);
        randomValue = Math.random();
        int offset = (int) (randomValue * spaceUP);

        for (int i = 0; i < TOP_SIZE; i++) {
            int index = offset + spaceUP * i;
            int y = index / w;
            int x = index % w;
            pixels[i] = b.getPixel(x, y);
        }
        offset = offset + segmentLength;
        for (int i = 0; i < (SIZE - TOP_SIZE); i++) {
            int index = offset + spaceDOWN * i;
            int y = index / w;
            int x = index % w;
            pixels[i + TOP_SIZE] = b.getPixel(x, y);
        }
        return pixels;
    }

获取到了像素的数据,在满足了三个条件下才能算作页面加载完成,分别是:

  • 如果页面40%的像素值和loading一致,当做还在loading(其中LOADING_PIXEL_COLOR是默认加载的颜色像素值)
  • 如果页面95%的像素值一样,当做还在loading
  • 页面像素值种类小于等于4,当做还在loading
/**
     * 页面40%的像素值和loading一致,当做还在loading
     * @return 是否加载完成
     */
    private static boolean planA(int[] pixels) {
        int count = 0;
        for (int pix : pixels) {
            if (pix == LOADING_PIXEL_COLOR) {
                count++;
            }
        }
        return count < SIZE * 0.4;
    }

    /**
     * 页面95%的像素值一样,当做还在loading
     * @return 是否加载完成
     */
    private static boolean planB(int[] data) {
        int max = 0;
        SparseIntArray sparseIntArray = new SparseIntArray();
        for (int pix : data) {
            int count = sparseIntArray.get(pix) + 1;
            sparseIntArray.put(pix, count);
            if (count > max) {
                max = count;
            }
        }
        boolean res = max < SIZE * 0.95;
        Log.d(TAG, "planB: " + res + "  max size " + max + "   random " + randomValue);
        return res;
    }

    /**
     * 页面像素值种类小于等于4,当做还在loading
     * @return 是否加载完成
     */
    private static boolean planC(int[] data) {
        if (data == null) {
            return false;
        }
        Set<Integer> set = new HashSet<Integer>();
        for (int pix : data) {
            set.add(pix);
        }
        boolean res = set.size() > 4;
        Log.d(TAG, "planC: " + res + "  set size = " + set.size());
        if (res) {
            Log.d(TAG, "planC: ");
        }
        return res;
    }

2、Fragment页面加载时间

public class BaseFragment extends Fragment {

    public boolean isNeedLoadingTimeDetect() {
        return true;
    }

    public int getLoadingTimeDetectType() {
        return LoadingDetector.AREA_DETECT;
    }

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if (isNeedLoadingTimeDetect()) {
            LoadingDetector.getInstance().startWatch(this, getLoadingTimeDetectType());
        }
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        if (isNeedLoadingTimeDetect()) {
            LoadingDetector.getInstance().destroyWatch(this);
        }
    }
}

具体处理方法同Activity一样。

3、H5、RN、Flutter页面加载时间

采用像素计算法,不赘述。

4、代码地址
https://github.com/kkloqin