理解TextView三部曲之番外篇:或许这会是最终的进化

964 阅读7分钟

额,为什么会有番外篇呢。。因为新版本上线后,别的同学用我的这个控件,描边显示出问题了-_-!

什么问题呢?

我把问题抽出来,同时把问题放大点,给大家看看(抹眼泪.png)

1_error_show

好嘛,问题不大。。就是描边歪了一点点,对吧。

可是怎么会这样!?,我自己测根本就没有问题,压根就没出现过这样的问题啊。。(抹眼泪.png)

我又去检查了一遍计算描边位置那块的代码,最初是以为其他同学一不小心该了那块的代码,导致描边位置计算出错了,结果发现,代码丝毫没有动过的痕迹。

那怎么会描边出错呢?而且他描边出问题的地方,在我这里这里显示也没什么问题,在他那里会什么会有这么大的偏差呢?

我不信邪,看看那位同学都对StrokeTextView做了哪些设置?结果发现,他多了下面这行代码:

mStrokeTextView.setTypeface(typeface);

我捉摸了一下,发现这行代码很有问题,因为我的StrokeTextView是继承自TextView的,调用setTypeface(),看看它的默认实现:

public void setTypeface(@Nullable Typeface tf) {
    if (mTextPaint.getTypeface() != tf) {
        mTextPaint.setTypeface(tf);

        if (mLayout != null) {
            nullLayouts();
            requestLayout();
            invalidate();
        }
    }
}

看了一眼我就明白了,它只是给TextPaint设置了不同的typeFace,而我们的描边是使用不同的TextPaint,也就是说setTypeface()只是给我们的文本设置了字体,却没有给我们的StrokeTextPaint设置相同的字体,导致了两种不同字体之间,没有办法对齐位置,导致了描边差异。

怎么解决?简单,照葫芦画瓢就行,我们在StrokeTextView重写setTypeface()方法。

setTypeface()的默认实现有两种,我们都要重写:

@Override
public void setTypeface(@androidx.annotation.Nullable Typeface tf) {
    // 模仿TextView的设置
    // 需在super.setTypeface()调用之前,不然没有效果
    if (mStrokePaint != null && mStrokePaint.getTypeface() != tf) {
        mStrokePaint.setTypeface(tf);
    }

    super.setTypeface(tf);
}

另一种比较复杂,不过我们会模仿就行了:

public void setTypeface(@Nullable Typeface tf, int style) {
        if (style > 0) {
            if (tf == null) {
                tf = Typeface.defaultFromStyle(style);
            } else {
                tf = Typeface.create(tf, style);
            }

            setTypeface(tf);
            // now compute what (if any) algorithmic styling is needed
            int typefaceStyle = tf != null ? tf.getStyle() : 0;
            int need = style & ~typefaceStyle;
            getPaint().setFakeBoldText((need & Typeface.BOLD) != 0);
            getPaint().setTextSkewX((need & Typeface.ITALIC) != 0 ? -0.25f : 0);

            // 同步设置mStrokeTextPaint
            if (mStrokePaint != null) {
                mStrokePaint.setFakeBoldText((need & Typeface.BOLD) != 0);
                mStrokePaint.setTextSkewX((need & Typeface.ITALIC) != 0 ? -0.25f : 0);
            }
        } else {
            getPaint().setFakeBoldText(false);
            getPaint().setTextSkewX(0);

            // 同步设置mStrokeTextPaint
            if (mStrokePaint != null) {
                mStrokePaint.setFakeBoldText(false);
                mStrokePaint.setTextSkewX(0);
            }

            setTypeface(tf);
        }
}

两步解决,但为什么我这显示没问题,别的同学那里显示就出问题了呢?

我突然想起来,相同字体在不同手机上显示是有差异的,而且有些手机不一定都支持那种字体

我和那位同学用着不同厂商的真机进行测试,而我的真机是不支持他设置的字体的,所以看着没问题,但他的小米是支持的。难怪我这看着没问题,他那看着就很离谱。

修改完后,我们在运行一遍。

2_fixed_show

怎一个完美形容!ok,bug解决了,准备提交代码

就这样结束了吗?

时隔多日,我又重新审核了一遍代码,我留意到这样一行代码

float heightWeNeed
= getCompoundPaddingTop() + getCompoundPaddingBottom() + mStrokeWidth + mTextRect.height() + DensityUtil.dp2px(getContext(), 4);

我们需要的高度 = 内边距 + 描边高度 + 文本高度 + 一个额外设定的值 ?

怎么会需要一个额外的值呢?要实现wrap_content的效果,我们的宽度不是只需要加上边距、文本高度和一个描边的高度吗?

好奇怪的逻辑,这不是多余嘛,我当时怎么想的来着哈哈?不符合我wrap_content的预期,把它删了试试,再测一遍

把我之前的测试用例都测了一遍,都运行正常

除了。。除了下面这种情况。

3_error_show

果然,去掉额外的高度,就会有这种高度不够显示的情况。看来当时的我,就是遇到了这种情况,然后一个手快,就给heightWeNeed做了这种适配。

不过这种手快的适配方法貌似不太优雅,为了适配单一的这种情况,要牺牲剩下的所有情况都增加一个额外的高度。

而且因为我们适配的额外高度是一个固定值,如果我们给文本字体大小设置大一点,还是会有高度不够显示的可能,毕竟文本变大了,所需要的高度也就更多了。

好吧,这种适配方法看来是用不得了,要换一个吗?但是计算高度的公式 = 内边距 + 文本高度 + 描边高度,这个公式肯定是没错的。

回到我们最初的问题,我们为什么会需要增加一个额外的固定高度呢?明明公式都是对的,为什么还是会有偏差,难道是公式里的对应的值计算错误了?

我们看看再来看看这个式子:

heightWeNeed = getCompoundPaddingTop() + getCompoundPaddingBottom() +
        mStrokeWidth + mTextRect.height();

其中,getCompoundPaddingTop() 和 getCompoundPaddingBottom() 是Android提供的计算内边距的api,这个肯定不至于错吧。

mStrokeWidth是我们的描边宽度,是由用户使用时自定义的,这个没什么需要计算的,就是一个值而已

那么mTextRect.height() 这个呢,我们需要这里返回一个正确的文本高度。

看看这个mTextRect是在哪里赋值的

getPaint().getTextBounds(text, 0, text.length(), mTextRect);

从getTextBounds()里跟下去,发现最后调用测量的是native方法,看不到内部实现,不过我们可以看看getTextBounds()的注释

/**
 * Retrieve the text boundary box and store to bounds.
 *
 * Return in bounds (allocated by the caller) the smallest rectangle that
 * encloses all of the characters, with an implied origin at (0,0).
 *
 * @param text string to measure and return its bounds
 * @param start index of the first char in the string to measure
 * @param end 1 past the last char in the string to measure
 * @param bounds returns the unioned bounds of all the text. Must be allocated by the caller
 */
public void getTextBounds(String text, int start, int end, Rect bounds) {
    ...
    // native 方法
    nGetStringBounds(mNativePaint, text, start, end, mBidiFlags, bounds);
}

Return in bounds the smallest rectangle that encloses all of the characters

在bounds中返回包含所有字符的最小矩形

5_bounds_height

也就是说bounds返回的高度,只是能够包含文本的最小高度。

我们在三部曲概览里就讨论过,安卓里文本的描绘,是由几根线来确定的

4_text_lines

文本的高度应该为(fontMetrics.bottom -fontMetrics. top),但是,bounds中返回的height也够文本显示啊?怎么会显示成下面这个样子?

6_error_show

比如这样

7_thought_show

但实际情况好像是这样的

8_thought_show_2

我想到,安卓绘制文本是有起点坐标的,这个起点由gravity,textAlign,和baseline确定,和内容展示高度好像没有关系。

虽然我们展示高度设小了,但它的起点坐标还在原来的位置(比如y坐标baseline),这才导致了18数字显示不完整,底部好像缺了一块。

问题的根本找到了,看来好像有两种解决方法

  1. 调整baseline的位置:把我们的baseline位置上移一些,让它和展示区域底部位置重合,这样就能以最小区域显示完整的文本内容。
  2. 拓宽bounds.height的高度,以(fontMetrics.bottom - fontMetrics.top)作为文本的高度显示,这样就无需改变baseline的位置,但比第一种方案要多需要一些空间。

这里我选了第二种,顺着系统的绘制规则来,图个方便,而且我们的描边也可以利用文本顶部多出来的这些空间。

我们新设个变量 textHeight = fontMetrics.descent - fontMetrics.top

heightWeNeed = getCompoundPaddingTop() + getCompoundPaddingBottom() +
        textHeight + mStrokeWidth / 2;

为了最大化利用空间,文字顶部到top线的距离已经足够我们的描边显示了,而bottom线到descent线之间的距离很窄,就可能不够我们的描边显示。

所以只需要在文字底部加一半的描边宽度,同时去掉buttom线和descent线之间的距离,这样就能确保文字和描边都有足够的位置显示了。

好了,番外篇终于结束了,看了眼字数,居然比之前的三部曲系列都要多一些。实在没想到需要这么长的篇幅来讲这两个小优化,谢谢小伙伴们能够看到这里啦。

源码我都已经上传到github了,欢迎小伙伴自取,如果觉得写得不错的,还请给这份工程给个star ~_ <

兄dei,如果觉得我写的还不错,麻烦帮个忙呗 :-)

  1. 给俺点个赞被,激励激励我,同时也能让这篇文章让更多人看见,(#^.^#)
  2. 不用点收藏,诶别点啊,你怎么点了?这多不好意思!
  3. 噢!还有,我维护了一个路由库。。没别的意思,就是提一下,我维护了一个路由库 =.= !!

拜托拜托,谢谢各位同学!