Android应用开发三部曲 --- View原理

目录:

1、前言
2、View原理
3、ViewRoot
4、自定义view

1、前言

在Android应用开发中,经常会用到以下3点,自定义View动画Touch事件分发。自定义View,可以写出非常漂亮的界面。良好的动画,会提升app的质感。Touch事件分发,影响着与用户的互动。

如需要写自定义view,最重要的是理解view原理,本文今天尝试从源码角度解析View原理。

2、View原理

从本文标题结合内容,部分同学在看完后可能会觉得博主在装13,View原理是什么?应该比较高深。其实View原理所有人都懂。

Paste_Image.png

如上,view原理就是measure、layout、draw的三个过程。measure,确定view的大小。layout确定view的位置,draw,绘制view。

3、ViewRoot

当Activity执行onResume后,界面就是可见的了,为什么是这样呢?本文跟踪这条线索来查看view是怎么被添加的?view是如何被刷新的?

调用时序图:

Paste_Image.png

上代码

            //ActivityThread的handleResumeActivity方法,将Activity的DecorView通过WindowManager添加,所以界面可见了
            View decor = r.window.getDecorView();
            decor.setVisibility(View.INVISIBLE);
            ViewManager wm = a.getWindowManager();
            WindowManager.LayoutParams l = r.window.getAttributes();
            a.mDecor = decor;
            l.type = WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
            l.softInputMode |= forwardBit;
            if (a.mVisibleFromClient) {
                a.mWindowAdded = true;
                wm.addView(decor, l);
            }

追踪wm.addView方法,最终调用WindowManagerGlobal类的addView方法

public void addView(View view, ViewGroup.LayoutParams params,
        Display display, Window parentWindow) {
    ViewRootImpl root;
    View panelParentView = null;
    synchronized (mLock) {
        //如果View的窗口类型是子窗口类型,则找出其父View
        if (wparams.type >= WindowManager.LayoutParams.FIRST_SUB_WINDOW &&
                wparams.type <= WindowManager.LayoutParams.LAST_SUB_WINDOW) {
            final int count = mViews.size();
            for (int i = 0; i < count; i++) {
                if (mRoots.get(i).mWindow.asBinder() == wparams.token) {
                    panelParentView = mViews.get(i);
                }
            }
        }
        //初始化ViewRoot
        root = new ViewRootImpl(view.getContext(), display);
        view.setLayoutParams(wparams);
        //将view和ViewRoot保存到列表中
        mViews.add(view);
        mRoots.add(root);
        mParams.add(wparams);
    }
    try {
        //ViewRoot设置View
        root.setView(view, wparams, panelParentView);
    } catch (RuntimeException e) {
        //View添加出错,则删除此View
        synchronized (mLock) {
            final int index = findViewLocked(view, false);
            if (index >= 0) {
                removeViewLocked(index, true);
            }
        }
        throw e;
    }
}

WindowManagerGlobal的addView方法中,初始化了ViewRoot对象,并且调用了setView方法。ViewRoot可以理解为View的管理者,View的刷新、绘制等都是通过ViewRoot调用的,且View与WMS之间的跨进程交互,也是通过ViewRoot实现的。继续查看setView方法。

public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
    synchronized (this) {
        if (mView == null) {
            mView = view;
            int res; /* = WindowManagerImpl.ADD_OKAY; */
            //请求界面刷新,要执行measure、layout、draw那套流程了
            requestLayout();
            try {
                //通过WindowSession与WMS交互,告诉WMS,这个窗口需要被添加,需要被显示了
                res = mWindowSession.addToDisplay(mWindow, mSeq, mWindowAttributes,
                        getHostVisibility(), mDisplay.getDisplayId(),
                        mAttachInfo.mContentInsets, mInputChannel);
            } catch (RemoteException e) {
            }
            //WindowSession.addToDisplay的结果值,如果返回值不等于add_ok,则添加失败,抛出异常
            if (res < WindowManagerGlobal.ADD_OKAY) {
                throw new RuntimeException(
                    "Unable to add window -- unknown error code " + res);
            }
        }
    }
}

ViewRoot与WMS使用WindowSession跨进程交互。从以上代码中可以看出,一个Activity中只有一个ViewRoot,并不是一个View对应着一个View。当然,如果是类似状态栏这种直接通过WindowManager添加的View,这类View也会对应着一个ViewRoot。

ViewRoot的requestLayout方法比较简单,一路跟踪,最后会执行ViewRoot的performTraversals方法,此方法非常复杂,非常长。

private void performTraversals() {
    final View host = mView;
    //被添加view的期望宽高
    int desiredWindowWidth;
    int desiredWindowHeight;
    //可见性是否变化
    boolean viewVisibilityChanged = mViewVisibility != viewVisibility || mNewSurfaceNeeded;
    //是否需要重新布局
    boolean layoutRequested = mLayoutRequested && !mStopped;
    //窗口是否需要重新确定大小
    boolean windowShouldResize = layoutRequested && windowSizeMayChange
        && ((mWidth != host.getMeasuredWidth() || mHeight != host.getMeasuredHeight())
            || (lp.width == ViewGroup.LayoutParams.WRAP_CONTENT &&
                    frame.width() < desiredWindowWidth && frame.width() != mWidth)
            || (lp.height == ViewGroup.LayoutParams.WRAP_CONTENT &&
                    frame.height() < desiredWindowHeight && frame.height() != mHeight));
    //计算view的大小
    performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
    //是否要layout
    final boolean didLayout = layoutRequested && !mStopped;
    if (didLayout) {
        performLayout(lp, desiredWindowWidth, desiredWindowHeight);
    }
    //如果没有取消绘制,则绘制view
    if (!cancelDraw && !newSurface) {
        performDraw();
    }
}

performTraversals方法中,根据各种条件,计算是否需要measure、layout以及draw,view的刷新完成。至此,Activity从onResume之后发生的故事,都解释清楚了。

选择一个方法重新看看,performMeasure的具体实现具体是什么:

private void performMeasure(int childWidthMeasureSpec, int childHeightMeasureSpec) {
    Trace.traceBegin(Trace.TRACE_TAG_VIEW, "measure");
    try {
        mView.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    } finally {
        Trace.traceEnd(Trace.TRACE_TAG_VIEW);
    }
}

performMeasure方法中直接调用view的measure方法,measure方法是个final方法,无法被子类重写,measure方法中调用onMeasure方法,调用子view的measure方法,完成整个view树的measure操作。

public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
    //现在的MeasureSpec与老的MeasureSpec不相同时,则需要检测判断是否调用onMeasure
    if ((mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT ||
            widthMeasureSpec != mOldWidthMeasureSpec ||
            heightMeasureSpec != mOldHeightMeasureSpec) {
        onMeasure(widthMeasureSpec, heightMeasureSpec);
    }
}

onMeasure方法,直接调用setMeasuredDimension方法,确定view的宽和高,所以在自定义View中,一定要对自己调用setMeasuredDimension方法,确定自己的宽和高。同时只需要调用子view的measure方法即可。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
            getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}

在performMeasure方法中,调用了Trace.traceBegin方法,只有调用此方法,才能在SysTrace工具中看到对应的方法的执行时间。

4、自定义view

自定义view是一个系统性的工作,必须对view原理、元素绘制等都有一定掌握才行。本博中对canvas绘制以及camera使用等进行过相关总结,不再复述。自定义view中文字的绘制较为特殊,本文以两行文字控件举例。

查看canvas.drawText接口说明:


捕获.PNG

y值意义是,被绘制文字的baseline的y坐标,baseline究竟是什么呢?

221837171589523.png

Baseline是基线,在Android中,文字的绘制都是从Baseline处开始的,Baseline往上至字符“最高处”的距离我们称之为ascent(上坡度),Baseline往下至字符“最低处”的距离我们称之为descent(下坡度);

leading(行间距)则表示上一行字符的descent到该行字符的ascent之间的距离;

top和bottom文档描述地很模糊,其实这里我们可以借鉴一下TextView对文本的绘制,TextView在绘制文本的时候总会在文本的最外层留出一些内边距,为什么要这样做?因为TextView在绘制文本的时候考虑到了类似读音符号,下图中的A上面的符号就是一个拉丁文的类似读音符号的东西:

top的意思其实就是,除了Baseline到字符顶端的距离外还应该包含这些符号的高度,bottom的意思也是一样。一般情况下我们极少使用到类似的符号,所以往往会忽略掉这些符号的存在,但是Android依然会在绘制文本的时候在文本外层留出一定的边距,这就是为什么top和bottom总会比ascent和descent大一点的原因。而在TextView中我们可以通过xml设置其属性android:includeFontPadding="false"去掉一定的边距值但是不能完全去掉。

本文将自定义一个显示两行文字的控件,文字居中显示,效果如下图:

Paste_Image.png

直接上代码,查看draw方法

public void draw(Canvas canvas){
  //Log.i("okunu"," firsttext = " + mFirstText + "  msecond = " + mSecondText);
  int totalTextHeight = mFirstTextHeight + mGap + mSecondTextHeight;
  
  TextPaint paint = getPaint();
  paint.setTextSize(mFirstSize);
  paint.setColor(mFirstColor);
  paint.setTypeface(mFirsTypeface);
  float x1 = (mWidth - mFirstTextWidth)/2;
  float y1 = (mHeight - totalTextHeight) - paint.ascent();
  //float y1 = (mHeight - totalTextHeight);
  canvas.drawText(mFirstText, 0, mFirstText.length(), x1, y1, paint);
  
  paint.setTextSize(mSecondSize);
  paint.setColor(mSecondColor);
  paint.setTypeface(mSecondTypeface);
  float x2 = (mWidth - mSecondTextWidth)/2;
  float y2 = (mHeight - totalTextHeight) + mFirstTextHeight + mGap - paint.ascent();
  canvas.drawText(mSecondText, 0, mSecondText.length(), x2, y2, paint);
}

x坐标的处理很容易理解,中间位置即可。y坐标的计算比较特殊,从效果图上看,文字的绘制起点就是view的顶点处,y坐标应该是0,但根据canvas.drawText接口的分析,此处传递的y坐标真实意义是baseline坐标值,而不是文字的顶点坐标值。所以y1值计算时需要减去ascent值。如果去掉这一步骤,那么第一行文字就看不见了。

ps:在计算y值时,顶点是0,结合前文对baseline的介绍,baseline和顶点之间相差一个ascent,那么baseline的值就是顶点坐标加上ascent即可。由于ascent值为负,所以加负号即可。计算baseline值可由顶点值推导得到。

计算baseline的位置,首先我们得知道文字的top位置,如果文字在view的正中心,top位置也可以确定,就是view的正中心和文字高度相关。如果文字在顶部,top位置就在view的顶部,确定了top位置之后,再来计算baseline的位置就相当容易了,baseline和top之前相隔的距离就是 (-top) ,于是就可以确定文字的绘制位置了

再次总结下相关点:

  • 文字的宽可以由paint计算得出
  • 文字的高可以由paint计算得出,为求精确,一般要求是 bottom - top
  • 先确定文字的top位置,再来计算baseline位置

所有代码均已上传至本人的github,欢迎访问。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容