Android 代码布局 —— 从入门到放弃(一)

Android

众所周知,在传统的 Android 应用开发中我们使用 XML 编写 layout , 用 Java 编写实现。 但是这样的话就涉及到在运行时解析 XML 和反射构建 View , 还涉及到遍历 View Tree 寻找 layout id 的问题。 人们还发明了一大堆方案在保持代码可维护性的同时避免 findViewById() 带来的性能损失, 例如 ViewHolder 以及用 HashMap 再缓存一次(Kotlin Android Plugin 的 View Binding 就是这样做的)。

于是就会有一些人 例如像我这样的性能强迫症患者 会尝试去使用 Java 代码手动创建 layout 。 这听起来就是个一举多得的事情,随便总结一下就有以下优点:

  1. 再也不需要在运行时解析 xml 和使用反射构建 View 了。
  2. 在手动创建 View 的同时就能持有对应 View 的引用, 避免在运行时浪费时间 findViewById() 的困扰。
  3. 可以安全地混淆我们的自定义 View 了。
  4. layout 的代码可以混淆得连 它的亲妈 你自己 都认不出来, 要知道 parse 和修改 Android binary xml 可比更改和重新编译 smali 容易多了。

最成功的 Java 代码布局的例子非 Telegram 莫属, 在阅读正文之前, 你可以先欣赏一下他们的 布局代码

代码布局

Telegram 的布局代码实在是太复杂了, 我觉得应该能吓跑一半的读者。

如果你没有被吓跑, 那我们就先从创建 View 和布局开始, 如果你已经熟悉 Android 的 layout 框架, 那可以跳过这一节。

创建 View

在 Android 5.0 或更新的版本中, View 有 4 个公有构造器。 分别是:

public View (Context context) // 1
public View (Context context, AttributeSet attrs) // 2
public View (Context context, AttributeSet attrs, int defStyleAttr) // 3
public View (Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) // 4, Android 5.0+

看起来很多, 实际上 LayoutInflator 构建 View 的时候只使用构造器 2 , 真正用反射把 View 创建出来的地方是在 这个函数。 你可以创建一个缺少了构造器 2 的自定义 View 放在 xml 里面然后喂给 LayoutInflator ,它会丢给你一个 NoSuchMethodException 。

我们基本上只用构造器 1 和 3 , 而且通常使用构造器 1, 我将在下文提及原因。 所以构造 View 就只是

View view = new View(context);

设置属性

除了构造器 1, 后面的构造器都有个 AttributeSet 类型的参数。 我们在 XML 中设置的属性也是用这个参数传进来, 先变成 TypedArray , 然后再进行读取。

这是个接口, 它看起来是那么得人畜无害, 非常符合接口隐藏原则。 然而如果你真的自己实现一个 AttributeSet 然后丢进去的话……

java.lang.ClassCastException: $Proxy0 cannot be cast to android.content.res.XmlBlock$Parser

在我们熟悉的 TypedArray.obtainAttributes() 中, 传进去的 AttributeSet 都会被强制转换成 XmlBlock.Parser 。 这是个包可见并且是 @hide 的 final class , 而且与 XmlPullParser 强耦合(这个 XmlPullParser 其实也和 AttributeSet 差不多), 想办法不通过 XML 资源来构建它或者重写它的实现都是不太现实的。 这就导致了一个比较麻烦的问题, 那就是对于我们熟悉的 XML 属性, 我们必须都找到对应的 setter 方法, 原先 View 构造器里通过 TypedArray 读取 XML 属性的那些逻辑都变得不可用了。

不过还好,在 Android 文档里对于 xml 属性基本上都会 Related methods 到对应的 setter 方法。除了有少数情况需要特殊处理,这里列举如下:

  • 涉及到尺寸的属性。 大部分涉及到尺寸的属性 setter 都是传像素值, 要么写一个 从 dp 值转换到 px 值的方法 , 要么把尺寸值丢 dimens 资源里用 Resource.getDimensionPixelSize() 取。

  • android:textSize: 这是 TextView 的属性。 我们在 xml 里写一般都的是 sp 值, 无需转换直接传给 TextView.setTextSize(float) 就好。 如果需要使用像素值那得用指定了单位的 TextView.setTextSize(int, float)

  • android:textStyle: 这是 TextView 的属性。 xml 里可以直接用 "normal", "italic", "normal" 作为参数, 对应的 Java 方法是 TextView.setTypeface() 。 基本上得和字型一同指定才能确保效果正确。 setTypeface(Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD))

  • android:tint: 这是 ImageView 的属性。 在 xml 里使用时非常方便, 设置一个 Color 的引用或者直接填颜色值就好, 然而它相关的方法是 setImageTintList(ColorStateList tint) 。 构造 ColorStateList 除了使用单个颜色的 ColorStateList.valueOf() 之外, 还可以 res/color/* 资源来提供 selector 支持(也可以使用代码创建)。

  • android:foreground: 准确地说这是个文档问题。 View.setForeground() (被标识为加入于 API 1) 以及它的 TintList 和 TintMode 方法(被标识为加入于 API 21)实际上直到 API 23 才从 FrameLayout 移动到 View 类。 Google 到现在都还没有在文档里修复这个问题,但是 Android Studio 里的 View.setForeground() —— 包括真正加入于 API 1 的 FrameLayout.setForeground() 都会显示需要 API 23 才能调用。 这是个使人疑惑的问题。 对于 minSdkVersion < 23 的应用, 正确的做法是使用 FrameLayout 把需要 foreground 的 view 包起来,然后使用 FrameLayout 的 setForeground() 方法。

  • ?attr/selectableItemBackground: 使用下面这个函数获取这类资源。

     Drawable selectableItemBackground(Context context) {
         int[] attrs = new int[] { android.R.attr.selectableItemBackground };
         TypedArray ta = context.obtainStyledAttributes(attrs);
         Drawable res = ta.getDrawable(0);
         ta.recycle();
         return res;
     }
    
  • style="@style/Widget.AppCompat.Button.Borderless": 像 AppCompatButton 会需要单独指定样式, 而不是 context 里使用的默认样式 (就是在 AndroidManifest.xml 的 <application> 或者 <activity> 中使用 android:theme 指定的那个)。 指定样式需要使用构造器 3 , 把第三个参数也设置成所需的样式, 并且要把第一个参数 Context 用 ContextThemeWrapper 包起来。

     Button button = new AppCompatButton(
             new ContextThemeWrapper(context, R.style.Widget_AppCompat_Button_Borderless), 
             null,  R.style.Widget_AppCompat_Button_Borderless);
    
  • 所有的 android:layout_*: 参考下一小节。

LayoutParams

LayoutParams 是子 View 向父 View 提供布局选项的渠道。 每一个比较重要的 ViewGroup 都会拥有一个继承于 ViewGroup.LayoutParams 的内部类, 例如 FrameLayout.LayoutParamsLinearLayout.LayoutParamsRelativeLayout.LayoutParams 。 并且通常有以下这些构造器:

ViewGroup.LayoutParams(Context c, AttributeSet attrs) // 1
ViewGroup.LayoutParams(int width, int height) // 2
ViewGroup.LayoutParams(ViewGroup.LayoutParams source) // 3

构造器 1 需要 AttributeSet 参数,基于上文提到的原因我们无法选用。 构造器 3 是复制构造器。 因此我们也只能使用构造器 2, 这个构造器的两个参数, 可以传像素值, 也可以传入 ViewGroup.LayoutParams.MATCH_PARENT 或者 ViewGroup.LayoutParams.WRAP_CONTENT 。 通常的建议是直接 import static 这些常量以保持代码简洁。

Android 解析 XML 布局时通过调用父 View 的 ViewGroup.LayoutParams.generateLayoutParams() 创建所需的 LayoutParams ,不过可惜的是这个方法需要一个 AttributeSet 参数。 因此我们无法使用这种方法, 只能根据父 View 的类型来创建。

LayoutParams 的类型由父 View 决定, 例如如果父 View 是 LinearLayout 那么就使用 LinearLayout.LayoutParams 。 如果父 View 没有 LayoutParams 的子类, 那就找父 View 的父类。 无论如何, ViewGroup.LayoutParams 总是可用的, 如果你只是想设置宽度和高度的话。

值得注意的是 RelativeLayout.LayoutParams 使用 addRule 方法指定子 View 的布局方式, 并且仍然需要子 View 有 id 才能工作。 我建议在 ids.xml 定义额外的 id 资源,然后使用 R.id.* 引用唯一的 id ,而不是自己定义一个数作为 id 。

层次结构

在创建好 View 之后,只需要把它加到父 View 里就好啦。

View parent = ...
View child = ...
child.setLayoutParams(...);
parent.addView(child);

Java 代码布局的实践

下面的 Activity 类作为 Java 代码布局的样例, 它在屏幕中央显示了一个 HELLO , 并在右下角显示了 NEXT 按钮。

*此处仅仅是为了方便在代码中硬编码了字符串资源和像素值, 注意这绝对不是好的实践。

public class MainActivity extends AppCompatActivity {

    RelativeLayout rootView;
    TextView helloView;
    Button nextButton;

    View build() {
        rootView = new RelativeLayout(this);
        ViewGroup.LayoutParams rootParams = new ViewGroup.LayoutParams(
                MATCH_PARENT, MATCH_PARENT);
        rootView.setLayoutParams(rootParams);
        rootView.setFitsSystemWindows(true);

        helloView = new AppCompatTextView(this);
        RelativeLayout.LayoutParams helloParams = new RelativeLayout.LayoutParams(
                WRAP_CONTENT, WRAP_CONTENT);
        helloParams.addRule(RelativeLayout.CENTER_IN_PARENT);
        helloView.setLayoutParams(helloParams);
        helloView.setText("HELLO");
        helloView.setTextSize(40);
        rootView.addView(helloView);

        nextButton = new AppCompatButton(
                new ContextThemeWrapper(this, R.style.Widget_AppCompat_Button_Colored),
                null, R.style.Widget_AppCompat_Button_Colored);
        RelativeLayout.LayoutParams nextParams = new RelativeLayout.LayoutParams(
                WRAP_CONTENT, WRAP_CONTENT);
        nextParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM);
        nextParams.addRule(RelativeLayout.ALIGN_PARENT_END);
        int margin = 20;
        nextParams.setMargins(margin, margin, margin, margin);
        nextButton.setLayoutParams(nextParams);
        nextButton.setText("NEXT");
        rootView.addView(nextButton);

        return rootView;
    }

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(build());
    }
}

无论如何, 这篇文章已经太长了, 我决定将它分成两篇写完。 在本系列的下一篇文章中, 我将会把代码布局与 Kotlin 特性融合, 讨论让代码布局变得更具有可读性和可维护性的方式, 并反思这样做的意义是什么。