【Android】3D布局分析工具

简介: 背景 飞猪上的doraemon一直对过度绘制和布局深度有监控,不合理的布局和过深得过度绘制影响页面渲染速度。虽然发现了不少问题,多处可见如下图的红红的页面,但是一直很难推动解决,主要有两个原因。 让开发找到具体的位置需要从根布局一层层遍历分析下去,确实麻烦,所以不想改; 修改后,会不会影响到其他控件的显示效果,光靠大脑想象难保万全,所以不敢改; 新工具 感谢@睿牧提供的外

背景

飞猪上的doraemon一直对过度绘制和布局深度有监控,不合理的布局和过深得过度绘制影响页面渲染速度。虽然发现了不少问题,多处可见如下图的红红的页面,但是一直很难推动解决,主要有两个原因。

  1. 让开发找到具体的位置需要从根布局一层层遍历分析下去,确实麻烦,所以不想改
  2. 修改后,会不会影响到其他控件的显示效果,光靠大脑想象难保万全,所以不敢改

新工具

感谢@睿牧提供的外部开源参考工具
于是doraemon里就多了一样新的工具,将当前页面的布局3D化,有点像xcode上的view ui hierarchy工具,如下图所示。新工具可以辅助分析页面布局的合理性和影响过度绘制的关键点:

  1. 在3D化的页面上将每个有名字的控件的名字(id)都写上了,便于直接看出是哪个控件(或者控件的爸爸)导致问题,以便快速定位到具体的控件;
  2. 在3D化的页面上通过拖拽和多点触摸放大来直观的看出每一个控件在整体布局里所处的位置,和对相关控件的影响,便于下结论能不能改;
  3. 在开发写布局文件时,经常用到layout嵌套layout,所以没有一个全局观,即不知当前正在写的布局在整体里的位置。在3D化的页面上,能够清晰的看出布局是否合理,是否有不合理的嵌套布局存在。不合理的布局导致过深得嵌套会引发crash

分析方法(这里以门票首页为例)

1. 打开过度绘制开关

2. 将门票首页布局3D化

按照上面的打开方式,然后进入门票首页,再点击“3D”Icon,可以看到如下图。可以看到所有控件的背景色都被涂上了标识过度绘制深度的颜色。

3. 找出影响过渡绘制的关键控件

从最外层布局向内看,导致背景色突变的控件是设置了背景色,如下图标记。其中5和6的背景色变化是因为加载了图片,这种情况可以不修改。我们主要看下1、2、3、4这4个地方。

1. 标记1位置

如下代码,在根布局里刷了一次全屏的白色。不合理性:标题栏本身也是白色,所在标题栏的像素区域内,根布局的全屏白色是多余的。

2. 标记2位置

整个页面的布局可以看成是上面一个标题栏,下面一个列表控件(listview),代码中为这个列表控件再一次刷了白色,如下代码所示:

3. 标记3位置

list的cell单元代码中再次刷了个白色底色,很显然这是多余的

4. 标记4位置

又一个list的cell单元这里也刷了个白色底色,很显然这也是多余的,前面的e6透明度更是多此一举。

4. 找到了痛点位置给出解决方案

  1. 去掉根布局的白色底色,保留listview的白色底色
  2. 去掉listview中的cell的白色底色

5. 初步优化前后对比

过度绘制数值由原先的4.04降低到2.63,提升53.6%。下图是初步优化前后颜色对比。

6. 布局合理性分析

如下黄色箭头指向的位置,4个图片控件(ImageView)并排放着,用了3层布局,对此表示质疑。

最后

3D布局工具结合过渡绘制开关可以有效地提升定位过度绘制问题,此外还容易发现多余的不合理的布局,以提升native的性能体验。

下面是源码,欢迎讨论共建。

public class ScalpelFrameLayout extends FrameLayout {

    /**
     * 传入当前顶部的Activity
     */
    public static void attachActivityTo3dView(Activity activity) {
        if (activity == null) {
            return;
        }

        ScalpelFrameLayout layout;
        ViewGroup decorView = (ViewGroup) activity.getWindow().getDecorView();
        /**
         * 在ids.xml里定义一个id
         */
        if (decorView.findViewById(R.id.id_scalpel_frame_layout) == null) {
            layout = new ScalpelFrameLayout(activity);
            layout.setId(R.id.id_scalpel_frame_layout);
            View rootView = decorView.getChildAt(0);
            decorView.removeView(rootView);
            layout.addView(rootView);
            decorView.addView(layout);
        } else {
            layout = (ScalpelFrameLayout) decorView.findViewById(R.id.id_scalpel_frame_layout);
        }

        if (!layout.isLayerInteractionEnabled()) {
            layout.setLayerInteractionEnabled(true);
        } else {
            layout.setLayerInteractionEnabled(false);
        }
    }

    /**
     * 标记位:当前多点触摸滑动方向未确定
     */
    private final static int        TRACKING_UNKNOWN           = 0;
    /**
     * 标记位:当前多点触摸滑动方向是垂直方向
     */
    private final static int        TRACKING_VERTICALLY        = 1;
    /**
     * 标记位:当前多点触摸滑动方向是横向方向
     */
    private final static int        TRACKING_HORIZONTALLY      = -1;
    /**
     * 旋转的最大角度
     */
    private final static int        ROTATION_MAX               = 60;
    /**
     * 反方向旋转的最大角度
     */
    private final static int        ROTATION_MIN               = -ROTATION_MAX;
    /**
     * 默认X轴旋转角度
     */
    private final static int        ROTATION_DEFAULT_X         = -10;
    /**
     * 默认Y轴旋转角度
     */
    private final static int        ROTATION_DEFAULT_Y         = 15;
    /**
     * 默认缩放比例
     */
    private final static float      ZOOM_DEFAULT               = 0.6f;
    /**
     * 最小缩放比例
     */
    private final static float      ZOOM_MIN                   = 0.33f;
    /**
     * 最大缩放比例
     */
    private final static float      ZOOM_MAX                   = 2f;
    /**
     * 图层默认间距
     */
    private final static int        SPACING_DEFAULT            = 25;
    /**
     * 图层间最小距离
     */
    private final static int        SPACING_MIN                = 10;
    /**
     * 图层间最大距离
     */
    private final static int        SPACING_MAX                = 100;
    /**
     * 绘制id的文案的偏移量
     */
    private final static int        TEXT_OFFSET_DP             = 2;
    /**
     * 绘制id的文案的字体大小
     */
    private final static int        TEXT_SIZE_DP               = 10;
    /**
     * view缓存队列初始size
     */
    private final static int        CHILD_COUNT_ESTIMATION     = 25;
    /**
     * 是否绘制view的内容,如TextView上的文字和ImageView上的图片
     */
    private boolean                 mIsDrawingViews            = true;
    /**
     * 是否绘制view的id
     */
    private boolean                 mIsDrawIds                 = true;
    /**
     * 打印debug log开关
     */
    private boolean                 mIsDebug                   = true;
    /**
     * view大小矩形
     */
    private Rect                    mViewBoundsRect            = new Rect();
    /**
     * 绘制view边框和id
     */
    private Paint                   mViewBorderPaint           = new Paint(ANTI_ALIAS_FLAG);
    private Camera                  mCamera                    = new Camera();
    private Matrix                  mMatrix                    = new Matrix();
    private int[]                   mLocation                  = new int[2];
    /**
     * 用来记录可见view
     * 可见view需要绘制
     */
    private BitSet                  mVisibilities              = new BitSet(CHILD_COUNT_ESTIMATION);
    /**
     * 对id转字符串的缓存
     */
    private SparseArray<String>     mIdNames                   = new SparseArray<String>();
    /**
     * 队列结构实现广度优先遍历
     */
    private ArrayDeque<LayeredView> mLayeredViewQueue          = new ArrayDeque<LayeredView>();
    /**
     * 复用LayeredView
     */
    private Pool<LayeredView>       mLayeredViewPool           = new Pool<LayeredView>(
                                                                   CHILD_COUNT_ESTIMATION) {

                                                                   @Override
                                                                   protected LayeredView newObject() {
                                                                       return new LayeredView();
                                                                   }
                                                               };
    /**
     * 屏幕像素密度
     */
    private float                   mDensity                   = 0f;
    /**
     * 对移动最小距离的合法性的判断
     */
    private float                   mSlop                      = 0f;
    /**
     * 绘制view id的偏移量
     */
    private float                   mTextOffset                = 0f;
    /**
     * 绘制view id字体大小
     */
    private float                   mTextSize                  = 0f;
    /**
     * 3D视图功能是否开启
     */
    private boolean                 mIsLayerInteractionEnabled = false;
    /**
     * 第一个触摸点索引
     */
    private int                     mPointerOne                = INVALID_POINTER_ID;
    /**
     * 第一个触摸点的坐标X
     */
    private float                   mLastOneX                  = 0f;
    /**
     * 第一个触摸点的坐标Y
     */
    private float                   mLastOneY                  = 0f;
    /**
     * 当有多点触摸时的第二个触摸点索引
     */
    private int                     mPointerTwo                = INVALID_POINTER_ID;
    /**
     * 第二个触摸点的坐标X
     */
    private float                   mLastTwoX                  = 0f;
    /**
     * 第二个触摸点的坐标Y
     */
    private float                   mLastTwoY                  = 0f;
    /**
     * 当前多点触摸滑动方向
     */
    private int                     mMultiTouchTracking        = TRACKING_UNKNOWN;
    /**
     * Y轴旋转角度
     */
    private float                   mRotationY                 = ROTATION_DEFAULT_Y;
    /**
     * X轴旋转角度
     */
    private float                   mRotationX                 = ROTATION_DEFAULT_X;
    /**
     * 缩放比例,默认是0.6
     */
    private float                   mZoom                      = ZOOM_DEFAULT;
    /**
     * 图层之间距离,默认是25单位
     */
    private float                   mSpacing                   = SPACING_DEFAULT;

    public ScalpelFrameLayout(Context context) {
        super(context, null, 0);
        mDensity = getResources().getDisplayMetrics().density;
        mSlop = ViewConfiguration.get(context).getScaledDoubleTapSlop();
        mTextSize = TEXT_SIZE_DP * mDensity;
        mTextOffset = TEXT_OFFSET_DP * mDensity;
        mViewBorderPaint.setStyle(STROKE);
        mViewBorderPaint.setTextSize(mTextSize);
        if (Build.VERSION.SDK_INT >= JELLY_BEAN) {
            mViewBorderPaint.setTypeface(Typeface.create("sans-serif-condensed", NORMAL));
        }
    }

    /**
     * 设置是否让当前页面布局3D化
     * 使用该方法前先调用attachActivityTo3dView方法
     *
     * @param enabled
     */
    public void setLayerInteractionEnabled(boolean enabled) {
        if (mIsLayerInteractionEnabled != enabled) {
            mIsLayerInteractionEnabled = enabled;
            setWillNotDraw(!enabled);
            invalidate();
        }
    }

    /**
     * 当前页面布局是否已经3D化
     *
     * @return
     */
    public boolean isLayerInteractionEnabled() {
        return mIsLayerInteractionEnabled;
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return mIsLayerInteractionEnabled || super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!mIsLayerInteractionEnabled) {
            return super.onTouchEvent(event);
        }

        int action = event.getActionMasked();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_POINTER_DOWN:
                int index = action == ACTION_DOWN ? 0 : event.getActionIndex();
                if (mPointerOne == INVALID_POINTER_ID) {
                    mPointerOne = event.getPointerId(index);
                    mLastOneX = event.getX(index);
                    mLastOneY = event.getY(index);
                    if (mIsDebug) {
                        log("Got pointer 1! id: %s x: %s y: %s", mPointerOne, mLastOneY, mLastOneY);
                    }
                } else if (mPointerTwo == INVALID_POINTER_ID) {
                    mPointerTwo = event.getPointerId(index);
                    mLastTwoX = event.getX(index);
                    mLastTwoY = event.getY(index);
                    if (mIsDebug) {
                        log("Got pointer 2! id: %s x: %s y: %s", mPointerTwo, mLastTwoY, mLastTwoY);
                    }
                } else {
                    if (mIsDebug) {
                        log("Ignoring additional pointer. id: %s", event.getPointerId(index));
                    }
                }

                break;
            case MotionEvent.ACTION_MOVE:
                if (mPointerTwo == INVALID_POINTER_ID) {
                    /**
                     *  单触点滑动是控制3D布局的旋转角度
                     */
                    int i = 0;
                    int count = event.getPointerCount();
                    while (i < count) {
                        if (mPointerOne == event.getPointerId(i)) {
                            float eventX = event.getX(i);
                            float eventY = event.getY(i);
                            float dx = eventX - mLastOneX;
                            float dy = eventY - mLastOneY;
                            float drx = 90 * (dx / getWidth());
                            float dry = 90 * (-dy / getHeight());
                            /**
                             *  屏幕上X的位移影响的是坐标系里Y轴的偏移角度,屏幕上Y的位移影响的是坐标系里X轴的偏移角度
                             *  根据实际位移结合前面定义的旋转角度区间算出应该旋转的角度
                             */
                            mRotationY = Math.min(Math.max(mRotationY + drx, ROTATION_MIN),
                                ROTATION_MAX);
                            mRotationX = Math.min(Math.max(mRotationX + dry, ROTATION_MIN),
                                ROTATION_MAX);
                            if (mIsDebug) {
                                log("Single pointer moved (%s, %s) affecting rotation (%s, %s).",
                                    dx, dy, drx, dry);
                            }

                            mLastOneX = eventX;
                            mLastOneY = eventY;
                            invalidate();
                        }

                        i++;
                    }
                } else {
                    /**
                     * 多触点滑动是控制布局的缩放和图层间距
                     */
                    int pointerOneIndex = event.findPointerIndex(mPointerOne);
                    int pointerTwoIndex = event.findPointerIndex(mPointerTwo);
                    float xOne = event.getX(pointerOneIndex);
                    float yOne = event.getY(pointerOneIndex);
                    float xTwo = event.getX(pointerTwoIndex);
                    float yTwo = event.getY(pointerTwoIndex);
                    float dxOne = xOne - mLastOneX;
                    float dyOne = yOne - mLastOneY;
                    float dxTwo = xTwo - mLastTwoX;
                    float dyTwo = yTwo - mLastTwoY;
                    /**
                     * 首先判断是垂直滑动还是横向滑动
                     */
                    if (mMultiTouchTracking == TRACKING_UNKNOWN) {
                        float adx = Math.abs(dxOne) + Math.abs(dxTwo);
                        float ady = Math.abs(dyOne) + Math.abs(dyTwo);
                        if (adx > mSlop * 2 || ady > mSlop * 2) {
                            if (adx > ady) {
                                mMultiTouchTracking = TRACKING_HORIZONTALLY;
                            } else {
                                mMultiTouchTracking = TRACKING_VERTICALLY;
                            }
                        }
                    }

                    /**
                     * 如果是垂直滑动调整缩放比
                     * 如果是横向滑动调整层之间的距离
                     */
                    if (mMultiTouchTracking == TRACKING_VERTICALLY) {
                        if (yOne >= yTwo) {
                            mZoom += dyOne / getHeight() - dyTwo / getHeight();
                        } else {
                            mZoom += dyTwo / getHeight() - dyOne / getHeight();
                        }

                        /**
                         * 算出调整后的缩放比例
                         */
                        mZoom = Math.min(Math.max(mZoom, ZOOM_MIN), ZOOM_MAX);
                        invalidate();
                    } else if (mMultiTouchTracking == TRACKING_HORIZONTALLY) {
                        if (xOne >= xTwo) {
                            mSpacing += (dxOne / getWidth() * SPACING_MAX)
                                        - (dxTwo / getWidth() * SPACING_MAX);
                        } else {
                            mSpacing += (dxTwo / getWidth() * SPACING_MAX)
                                        - (dxOne / getWidth() * SPACING_MAX);
                        }

                        /**
                         * 算出调整后的图层间距
                         */
                        mSpacing = Math.min(Math.max(mSpacing, SPACING_MIN), SPACING_MAX);
                        invalidate();
                    }

                    if (mMultiTouchTracking != TRACKING_UNKNOWN) {
                        mLastOneX = xOne;
                        mLastOneY = yOne;
                        mLastTwoX = xTwo;
                        mLastTwoY = yTwo;
                    }
                }

                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_POINTER_UP:
                index = action != ACTION_POINTER_UP ? 0 : event.getActionIndex();
                int pointerId = event.getPointerId(index);
                if (mPointerOne == pointerId) {
                    /**
                     * 多触点状态切换到单触点状态
                     * 即如果原先是调整缩放和图层间距的状态,放开一个手指后转为控制图层旋转状态
                     */
                    mPointerOne = mPointerTwo;
                    mLastOneX = mLastTwoX;
                    mLastOneY = mLastTwoY;
                    if (mIsDebug) {
                        log("Promoting pointer 2 (%s) to pointer 1.", mPointerTwo);
                    }

                    /**
                     * reset多触点状态
                     */
                    mPointerTwo = INVALID_POINTER_ID;
                    mMultiTouchTracking = TRACKING_UNKNOWN;
                } else if (mPointerTwo == pointerId) {
                    if (mIsDebug) {
                        log("Lost pointer 2 (%s).", mPointerTwo);
                    }

                    /**
                     * reset多触点状态
                     */
                    mPointerTwo = INVALID_POINTER_ID;
                    mMultiTouchTracking = TRACKING_UNKNOWN;
                }

                break;
            default:
                break;
        }

        return true;
    }

    @Override
    public void draw(Canvas canvas) {
        if (!mIsLayerInteractionEnabled) {
            super.draw(canvas);
            return;
        }

        getLocationInWindow(mLocation);
        /**
         * 页面左上角坐标
         */
        float x = mLocation[0];
        float y = mLocation[1];
        int saveCount = canvas.save();
        /**
         * 页面中心坐标
         */
        float cx = getWidth() / 2f;
        float cy = getHeight() / 2f;
        mCamera.save();
        /**
         * 先旋转
         */
        mCamera.rotate(mRotationX, mRotationY, 0F);
        mCamera.getMatrix(mMatrix);
        mCamera.restore();
        mMatrix.preTranslate(-cx, -cy);
        mMatrix.postTranslate(cx, cy);
        canvas.concat(mMatrix);
        /**
         * 再缩放
         */
        canvas.scale(mZoom, mZoom, cx, cy);
        if (!mLayeredViewQueue.isEmpty()) {
            throw new AssertionError("View queue is not empty.");
        }

        {
            int i = 0;
            int count = getChildCount();
            while (i < count) {
                LayeredView layeredView = mLayeredViewPool.obtain();
                layeredView.set(getChildAt(i), 0);
                mLayeredViewQueue.add(layeredView);
                i++;
            }
        }

        /**
         * 广度优先进行遍历
         */
        while (!mLayeredViewQueue.isEmpty()) {
            LayeredView layeredView = mLayeredViewQueue.removeFirst();
            View view = layeredView.mView;
            int layer = layeredView.mLayer;
            /**
             * 在draw期间尽量避免对象的反复创建
             * 回收LayeredView一会再复用
             */
            layeredView.clear();
            mLayeredViewPool.restore(layeredView);
            /**
             *  隐藏viewgroup内可见的view
             */
            if (view instanceof ViewGroup) {
                ViewGroup viewGroup = (ViewGroup) view;
                mVisibilities.clear();
                int i = 0;
                int count = viewGroup.getChildCount();
                while (i < count) {
                    View child = viewGroup.getChildAt(i);
                    /**
                     * 将可见的view记录到mVisibilities中
                     */
                    if (child.getVisibility() == VISIBLE) {
                        mVisibilities.set(i);
                        child.setVisibility(INVISIBLE);
                    }

                    i++;
                }
            }

            int viewSaveCount = canvas.save();
            /** 
             * 移动出图层的距离
             */
            float translateShowX = mRotationY / ROTATION_MAX;
            float translateShowY = mRotationX / ROTATION_MAX;
            float tx = layer * mSpacing * mDensity * translateShowX;
            float ty = layer * mSpacing * mDensity * translateShowY;
            canvas.translate(tx, -ty);
            /**
             * 画view的边框
             */
            view.getLocationInWindow(mLocation);
            canvas.translate(mLocation[0] - x, mLocation[1] - y);
            mViewBoundsRect.set(0, 0, view.getWidth(), view.getHeight());
            canvas.drawRect(mViewBoundsRect, mViewBorderPaint);

            /**
             * 画view的内容
             */
            if (mIsDrawingViews) {
                view.draw(canvas);
            }

            /**
             * 画view的id
             */
            if (mIsDrawIds) {
                int id = view.getId();
                if (id != NO_ID) {
                    canvas.drawText(nameForId(id), mTextOffset, mTextSize, mViewBorderPaint);
                }
            }

            canvas.restoreToCount(viewSaveCount);
            /**
             * 把刚刚应该显示但又设置了不可见的view从队列里取出来,后面再绘制
             */
            if (view instanceof ViewGroup) {
                ViewGroup viewGroup = (ViewGroup) view;
                int i = 0;
                int count = viewGroup.getChildCount();
                while (i < count) {
                    if (mVisibilities.get(i)) {
                        View child = viewGroup.getChildAt(i);
                        child.setVisibility(VISIBLE);
                        LayeredView childLayeredView = mLayeredViewPool.obtain();
                        childLayeredView.set(child, layer + 1);
                        mLayeredViewQueue.add(childLayeredView);
                    }

                    i++;
                }
            }
        }

        canvas.restoreToCount(saveCount);
    }

    /**
     * 根据id值反算出在布局文件中定义的id名字
     *
     * @param id
     * @return
     */
    private String nameForId(int id) {
        String name = mIdNames.get(id);
        if (name == null) {
            try {
                name = getResources().getResourceEntryName(id);
            } catch (NotFoundException e) {
                name = String.format("0x%8x", id);
            }

            mIdNames.put(id, name);
        }

        return name;
    }

    private static void log(String message, Object... object) {
        TLog.i("Scalpel", String.format(message, object));
    }

    private static class LayeredView {
        private View mView  = null;
        /**
         * mView所处的层级
         */
        private int  mLayer = 0;

        void set(View view, int layer) {
            mView = view;
            mLayer = layer;
        }

        void clear() {
            mView = null;
            mLayer = -1;
        }
    }

    private static abstract class Pool<T> {
        private Deque<T> mPool;

        Pool(int initialSize) {
            mPool = new ArrayDeque<T>(initialSize);
            for (int i = 0; i < initialSize; i++) {
                mPool.addLast(newObject());
            }
        }

        T obtain() {
            return mPool.isEmpty() ? newObject() : mPool.removeLast();
        }

        void restore(T instance) {
            mPool.addLast(instance);
        }

        protected abstract T newObject();
    }
}

 

相关文章
|
8月前
|
XML 前端开发 Android开发
Android XML 布局基础(四)内外边距(margin、padding)
Android XML 布局基础(四)内外边距(margin、padding)
169 0
|
8月前
|
编解码 Android开发
Android 常用布局单位区别(dp、sp、px、pt、in、mm)
Android 常用布局单位区别(dp、sp、px、pt、in、mm)
306 0
|
4月前
|
Android开发
Android Studio入门之常用布局的讲解以及实战(附源码 超详细必看)(包括线性布局、权重布局、相对布局、网格布局、滚动视图 )
Android Studio入门之常用布局的讲解以及实战(附源码 超详细必看)(包括线性布局、权重布局、相对布局、网格布局、滚动视图 )
129 0
|
4月前
|
Android开发 容器
Android开发,学习LinearLayout布局
Android开发,学习LinearLayout布局
39 0
|
4月前
|
XML Java Android开发
Android Studio App开发之循环试图RecyclerView,布局管理器LayoutManager、动态更新循环视图讲解及实战(附源码)
Android Studio App开发之循环试图RecyclerView,布局管理器LayoutManager、动态更新循环视图讲解及实战(附源码)
43 0
|
4月前
|
XML Java Android开发
Android Studio App开发中工具栏Toolbar、溢出菜单OverflowMenu、标签布局TabLayout的讲解及实战(实现京东App的标签导航栏,附源码)
Android Studio App开发中工具栏Toolbar、溢出菜单OverflowMenu、标签布局TabLayout的讲解及实战(实现京东App的标签导航栏,附源码)
58 0
|
4月前
|
Android开发 容器
Android开发第二次课 布局方式
Android开发第二次课 布局方式
23 0
|
6月前
|
XML 前端开发 Android开发
android 前端常用布局文件升级总结(一)
android 前端常用布局文件升级总结(一)
|
8月前
|
Android开发
Android 使用DataBinding时 将布局页面转换为数据绑定布局(Convert to data binding layout) 不出现提示解决办法
Android 使用DataBinding时 将布局页面转换为数据绑定布局(Convert to data binding layout) 不出现提示解决办法
91 0
|
8月前
|
Android开发
Android 实现布局的缩小和再放大动画(使用scale动画效果进行实现)
Android 实现布局的缩小和再放大动画(使用scale动画效果进行实现)
583 0