【背上Jetpack之DataBinding】数据驱动魔法师 何时迎来翻身日?

系列文章

【背上Jetpack】Jetpack 主要组件的依赖及传递关系

【背上Jetpack】AdroidX下使用Activity和Fragment的变化

【背上Jetpack之Fragment】你真的会用Fragment吗?Fragment常见问题以及androidx下Fragment的使用新姿势

【背上Jetpack之Fragment】从源码角度看 Fragment 生命周期 AndroidX Fragment1.2.2源码分析

【背上Jetpack之OnBackPressedDispatcher】Fragment 返回栈预备篇

【背上Jetpack之Fragment】从源码的角度看Fragment 返回栈 附多返回栈demo

【背上Jetpack】绝不丢失的状态 androidx SaveState ViewModel-SaveState 分析

【背上Jetpack之ViewModel】即使您不使用MVVM也要了解ViewModel ——ViewModel 的职能边界

【背上Jetpack之Lifecycle】万物基于Lifecycle 默默无闻大作用

【背上Jetpack之LiveData】ViewModel 的左膀右臂 数据驱动真的香

image

前言

LiveData 篇 我们提到 Android 开发的主要工作内容是将数据转换为 UI ,同时我们也介绍了数据驱动 UI 的思想,使用 ViewModel + LiveData,可以安全地在订阅者的生命周期内分发正确的数据。但是 activity 和 fragment 充斥着大量的模板代码,铺天盖地的 findViewById,以及各种 set (根据数据设置 UI)。如果能够消灭掉这些模板代码就好了

他来了他来了

他来了他来了,他欢快地走来了

DataBinding

然而,很多开发者对 DataBinding 存在偏见,「DataBinding 不是个好东西,在声明式编程中书写 UI 逻辑,既不可调试,也不便于察觉和追踪,万一出现问题就麻烦了。」

image

本文主要介绍 DataBinding 的解决的问题以及其背后的逻辑,带您对 DataBinding 有一个感性的认识。本文末尾会对各个 findViewById 的替代方案进行对比

DataBinding 的相关资源

数据驱动魔法师

DataBinding 允许使用声明性格式而不是通过编程方式将布局中的 UI 组件与数据源绑定

// before
TextView textView = findViewById(R.id.sample_text);
textView.setText(viewModel.getUserName());
<TextView
    android:text="@{viewmodel.userName}" />

通过在布局文件中绑定组件,您可以删除 activity 中的许多设置 UI 调用,从而使它们更易于维护。 这也可以提高应用程序的性能,并有助于防止内存泄漏和空指针异常

如果仅替换 findViewById 而不需要数据的绑定,可以使用 ViewBinding,它使用起来更简单,性能也更好。

使用方法参见 [译]深入研究ViewBinding 在 include, merge, adapter, fragment, activity 中使用

基础

详细内容参见 官方文档 ,这里只简单介绍 DataBinding

DataBinding 引入

app build.gradle 中加入

android {
    ...
    dataBinding {
        enabled = true
    }
}

// Android Studio 4.0
android {
    ...
    buildFeatures {
        dataBinding = true
    }
}

必须在 app module 中声明,声明后其他子 module 可直接使用 DataBinding

使用 DataBinding 无需开发者手动引入库,android build gradle plugin 内部已经引入了

image

DataBinding 中使用了注解,因此在构建速度上比 ViewBinding 差些(不过功能这么强大要啥自行车)

布局

DataBinding 布局文件略有不同,它们以 layout 的根标记开始,后跟一个 data 元素和一个 view 根元素。 view 元素是您的根将位于非绑定布局文件中的元素。 以下代码显示了一个示例布局文件:

<layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto">
    <data>
        <variable
            name="viewmodel"
            type="com.myapp.data.ViewModel" />
    </data>
    <ConstraintLayout... /> <!-- UI layout's root element -->
</layout>

生成绑定类

DataBinding 会为每个在布局声明 layout 标签的 xml 布局文件生成一个绑定类。 默认情况下,类的名称基于布局文件的名称。 上面的布局文件名是 activity_main.xml,因此相应的生成类是 ActivityMainBinding。 此类包含从布局属性(例如,viewmodel 变量)到布局视图的所有绑定,并且知道如何为绑定表达式分配值

配置绑定

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    // before
    // setContentView(R.layout.activity_main)
    
    // after
    val binding : ActivityMainBinding =
    DataBindingUtil.setContentView(this, R.layout.activity_main)
}

使用DataBinding 解决的问题及实现原理

不知道你是否有这些烦恼:activity 和 fragment 中有着大量的模板代码,即使使用 ButterKnife 等工具写起代码来也很繁琐。而且 View id 与 View 的类型不匹配时,只有在运行期才能发现;旋转屏幕后如果新的布局中不存在之前 id 的 view ,可能还导致空指针异常;项目中使用各类 bus 通知 UI 刷新,但是有时 UI 的显示并不符合预期,而排查起来特别困难,因为数据源很多...

不要慌,DataBinding 可以解决以下问题

  • 替换 findViewById ,减少模板代码

  • 解决类型安全问题

  • 解决空安全问题

  • 保证了数据的一致性

魔法的背后

com.android.tools.build:gradle 插件中封装了 DataBinding 的魔法

image

查看 com.android.tools.build:gradle:3.6.2 的源码,找到 DataBinding 配置项的类 DataBindingOptions

// DataBindingOptions.java
@Override
public boolean isEnabled() {
    // DataBinding 是否开启,对应上面在 build.gradle 中的配置
    return enabled;
}

它的调用者很多,在 TaskManager 中的 createDataBindingTasksIfNecessary

// TaskManager 
protected void createDataBindingTasksIfNecessary(@NonNull VariantScope scope) {
    // 是否开启 DataBinding
    boolean dataBindingEnabled = extension.getDataBinding().isEnabled();
    boolean viewBindingEnabled = extension.getViewBinding().isEnabled();
    if (!dataBindingEnabled && !viewBindingEnabled) {
        // DataBinding 和 ViewBinding 均未开启则直接 return
        return;
    }
    createDataBindingMergeBaseClassesTask(scope);
    createDataBindingMergeArtifactsTask(scope);
    //...
    // 构建 DataBinding 相应绑定类
    taskFactory.register(new DataBindingGenBaseClassesTask.CreationAction(scope));
}
// CreationAction
override fun handleProvider(taskProvider: TaskProvider<out DataBindingGenBaseClassesTask>) {
    variantScope.artifacts.producesDir(
        // DATA_BINDING_BASE_CLASS_SOURCE_OUT
        InternalArtifactType.DATA_BINDING_BASE_CLASS_SOURCE_OUT,
        BuildArtifactsHolder.OperationType.INITIAL,
        taskProvider,
        DataBindingGenBaseClassesTask::sourceOutFolder
    )
}

可以看到生成 DataBinding 绑定类的 task 为 DataBindingGenBaseClassesTask,而InternalArtifactType.DATA_BINDING_BASE_CLASS_SOURCE_OUT 则对应着 build 目录生成的 DataBinding 类的

data_binding_base_class_source_out 目录

image

这里可以简单看一下,感兴趣的小伙伴可以自己查看源码

DataBinding 如何解决上述问题的

我们可以查看 DataBinding 生成的绑定类

public final class FragmentSingleChildBinding implements ViewBinding {
  // NonNull 注解标记
  // 如果存在不同配置的不同布局文件(如横竖屏)且该控件不是存在于所有布局,该处使用 Nullable注解标记
  @NonNull
  public final MaterialButton button;
    
  // 省略...
    
  @NonNull
  public static FragmentSingleChildBinding bind(@NonNull View rootView) {
    String missingId;
    missingId: {
      //其内部也是使用 findViewById
      MaterialButton button = rootView.findViewById(R.id.button);
      if (button == null) {
        missingId = "button";
        break missingId;
      }
      return new FragmentSingleChildBinding((MaterialButton) rootView, button);
    }
    throw new NullPointerException("Missing required view with ID: ".concat(missingId));
  }
}
  • Binding 类内部的也是使用 findViewById ,因此 DataBinding 可以代替 findViewById ,并且减少模板代码

  • View 控件变量类型是固定的,因此不会出现类型安全问题

  • View 控件变量由空/非空注解修饰,(如果为 Nullable java 中会有 lint 警告,而 kotlin 直接调用时无法通过编译的)因此 不会出现空安全问题

  • 通过声明式的配置,UI 完全来自唯一可信的数据源配置,保证了数据的一致性

注意:以上分析同样适用于 ViewBinding

感受魔法的魅力

这里简单展示一下 DataBinding 的「魔法」

基本操作

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
   <data>
      
   </data>
   <LinearLayout
       android:orientation="vertical"
       android:layout_width="match_parent"
       android:layout_height="match_parent">
       <TextView
           android:id="@+id/firstName"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           />
       <TextView
           android:id="@+id/lastName"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           />
   </LinearLayout>
</layout>
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // Before Data Binding
        //setContentView(R.layout.activity_main);
        
        //TextView firstName = (TextView) findViewById(R.id.firstName);
        //TextView lastName = (TextView) findViewById(R.id.lastName);

        //firstName.setText("xxx");
        //lastName.setText("xxx");


        // After Data Binding
        ActivityMainBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_main);

        binding.firstName.setText("xxx");
        binding.lastName.setText("xxx");
    }
}

上面展示了 DataBinding 的基础操作(单纯的替换 findViewById),如果仅使用 DataBinding 这部分功能,可以考虑使用 ViewBinding

image

绑定数据

在之前的布局的基础上绑定数据

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
   <data>
       <variable name="user" type="com.example.User"/>
   </data>
   <LinearLayout
       android:orientation="vertical"
       android:layout_width="match_parent"
       android:layout_height="match_parent">
       <TextView 
           android:id="@+id/firstName"      
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:text="@{user.firstName}"/>
       <TextView 
           android:id="@+id/lastName"      
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:text="@{user.lastName}"/>
   </LinearLayout>
</layout>
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ActivityMainBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_main);
        binding.user = new User("xxx","xxx");
    }
}

这种方式也可以用在 recyclerview adapter 中,adapter 中的代码大大减少

Binding Adapter

您可能会好奇配置 android:text="@{user.firstName} 后内部发生了什么

DataBinding 中使用 Binding Adapter 来处理,它主要处理「属性」和「事件」,前者如 setText() ,后者如 setOnClickListener()。上面的 android:text 实际上调用的是下面的方法

image

DataBinding 中提供了很多 Binding Adapter

image

如果官方提供的 Binding Adapter 不满足您的需求,您还可以自定义 Binding Adapter

@BindingAdapter({"imageUrl", "error"})
public static void loadImage(ImageView view, String url, Drawable error) {
  Glide.with(view).load(url).error(error).into(view);
}
<ImageView app:imageUrl="@{venue.imageUrl}" app:error="@{@drawable/venueError}" />

DadaBinding + LiveData

要将 LiveData 与 DataBinding 一起使用,需要指定生命周期所有者来定义 LiveData 对象的范围

class ViewModelActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        UserBinding binding = DataBindingUtil.setContentView(this, R.layout.user);

        binding.setLifecycleOwner(this);
    }
}

双向绑定

使用单向 DataBinding,可以在属性上设置一个值,并设置一个对该属性的更改做出反应的监听器:

<CheckBox
    android:id="@+id/rememberMeCheckBox"
    android:checked="@{viewmodel.rememberMe}"
    android:onCheckedChanged="@{viewmodel.rememberMeChanged}"
/>

使用双向绑定可以简化该过程

<CheckBox
    android:id="@+id/rememberMeCheckBox"
    android:checked="@={viewmodel.rememberMe}"
/>

使用 @={} 接收对该属性的数据更改,并同时监听用户更新(注意,这里有 =

那么究竟什么是双向绑定呢?

所谓的「数据驱动」就是数据驱动视图的变化,而 DataBinding 的单向绑定就是如此。反过来讲,有些时候我们需要视图来驱动数据的变化(例如当我们在 EditText 上输入了文字,我们希望对应的 ViewModel 的 LiveData 的值能够及时响应该变化)

image
image

如图,绿色部分为独立的 fragment ,内部存在两个 TextView,用于显示外部 fragment EditText 输入的文字

如果实现上述功能,传统做法可能是使用 activity 级别的 ViewModel 进行两个 fragment 之间的通信,通过监听 EditText 文字的变化改变 ViewModel 中 LiveData 的值,并在绿色 fragment 中观察 LiveData 并显示到 TextView 中

class NormalViewModel : ViewModel() {
    val firstName = MutableLiveData<String>()
    val lastName = MutableLiveData<String>()
}

class NormalDetailFragment : Fragment(R.layout.fragment_normal_detail) {
    private val mViewModel by activityViewModels<NormalViewModel>()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        mViewModel.firstName.observe(viewLifecycleOwner) {
            tvFirstName.text = it
        }
        mViewModel.lastName.observe(viewLifecycleOwner) {
            tvLastName.text = it
        }
    }
}

class NormalFragment : Fragment(R.layout.fragment_normal) {
    private val mViewModel by activityViewModels<NormalViewModel>()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        etFirstName.addTextChangedListener {
            mViewModel.firstName.value = it.toString()
        }
        etLastName.addTextChangedListener {
            mViewModel.lastName.value = it.toString()
        }
    }
}

得益于 kotlin ,上面的代码以及很简洁了,如果使用 java 代码片段只会更长。

不过使用 DataBinding,还可以更简洁

<com.google.android.material.textview.MaterialTextView
    android:id="@+id/tvFirstName"
    android:text="@{vm.firstName}"/>
<com.google.android.material.textview.MaterialTextView
    android:id="@+id/tvLastName"
    android:text="@{vm.lastName}"/>
<com.google.android.material.textfield.TextInputEditText
    android:id="@+id/etFirstName"
    android:text="@={vm.firstName}"/>
<com.google.android.material.textfield.TextInputEditText
    android:id="@+id/etLastName"
    android:text="@={vm.lastName}"/>

只需配置好双向绑定(EditText 驱动 ViewModel 的 LiveData 的值变化,ViewModel 再驱动 TextView 显示数据),并在 fragment 通过固定的模板代码设置好 ViewModel 即可

image

这里的魔法还是来自 Binding Adapter

// TextViewBindingAdapter.java
@BindingAdapter(value = {"android:beforeTextChanged", "android:onTextChanged",
        "android:afterTextChanged", "android:textAttrChanged"}, requireAll = false)
public static void setTextWatcher(TextView view, final BeforeTextChanged before,
        final OnTextChanged on, final AfterTextChanged after,
        final InverseBindingListener textAttrChanged) {
    final TextWatcher newValue;
    if (before == null && after == null && on == null && textAttrChanged == null) {
        newValue = null;
    } else {
        newValue = new TextWatcher() {
            @Override
            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
                if (before != null) {
                    before.beforeTextChanged(s, start, count, after);
                }
            }
            @Override
            public void onTextChanged(CharSequence s, int start, int before, int count) {
                if (on != null) {
                    on.onTextChanged(s, start, before, count);
                }
                if (textAttrChanged != null) {
                    //通知发生变化
                    textAttrChanged.onChange();
                }
            }
            @Override
            public void afterTextChanged(Editable s) {
                if (after != null) {
                    after.afterTextChanged(s);
                }
            }
        };
    }
    final TextWatcher oldValue = ListenerUtil.trackListener(view, newValue, R.id.textWatcher);
    if (oldValue != null) {
        view.removeTextChangedListener(oldValue);
    }
    if (newValue != null) {
        view.addTextChangedListener(newValue);
    }
}

这里使用 InverseBindingListener (调用 textAttrChanged.onChange())来通知 LiveData 数据发生变化

而变化后的值 通过 @InverseBindingAdapter 注解标记的方法处理,这里的 event 与上面的标记匹配(android:textAttrChanged

// TextViewBindingAdapter.java
@InverseBindingAdapter(attribute = "android:text", event = "android:textAttrChanged")
public static String getTextString(TextView view) {
    return view.getText().toString();
}

view 层变化通知数据变化,数据变化再通知 view 层变化,仿佛是个套娃

image

因此避免这种死循环十分重要,setText 方法判断了新旧值是否相等来避免死循环

image

总结

DataBinding 主要提供两部分功能

  • 替换 findViewById ,如果只用这部分功能可以使用 ViewBinding
  • 进行 data 和 UI 的绑定,使用「数据驱动」的思想解决了视图的一致性问题

各种 findViewById 替代方案对比

  • findViewById
  • Butterknife
  • Kotlin Synthetics
  • Data Binding
  • View Binding

findViewById

image

findViewById 有两个问题

  1. 当不能在 Activity/Fragment/ViewGroup 中定位到指定 id 的 View,会在运行期间崩溃,即非空安全
  2. 如果某个 view 为 TextView 类型,而在使用中将其指定为其他类型不会在编译器报错,即非类型安全

在 compileSdk 的 API 级别 26 中,对该方法的定义稍作更改以消除强制类型转换问题

image

现在,开发人员无需在代码中手动转换 view 类型。 如果您引用 id 指向类型 TextView 的 View 并将其指定为 Button,则 Android SDK 会尝试查找具有提供的 id 的 Button,并且它将返回 Null,因为它将无法找到它

但是在 Kotlin 中,您仍然需要提供诸如 findViewById<TextView>(R.id.txtUsername) 之类的类型。 如果您不检查视图是否具有 null 安全,则可能出现 NullPointerException,但是此方法不会像以前那样抛出ClassCastException

Butterknife

ButterknifeJake Wharton 大神写的替代 findViewById 的库,该库使用注解处理并生成 findViewById 代码

image

它具有与 findViewById 几乎相似的问题。 但是,它在运行时添加了null 安全检查以避免 NullPointerException

image

由于 DataBinding 和 ViewBinding 的出现,沃神已经宣布弃用该库

Kotlin Synthetics

Kotlin 引入的最大功能之一是 Kotlin 扩展方法。 在它的帮助下,Kotlin Synthetics 诞生了。 Kotlin Synthetics 通过自动生成的 Kotlin 扩展方法,使开发人员可以从 xml 布局直接访问其内部的 view

image

Kotlin Synthetics 第一次调用 findViewById 方法,然后默认情况下将 view 实例缓存在 HashMap 中。 可以通过Gradle 设置将此缓存配置更改为 SparseArray 或不缓存

总体而言,Kotlin Synthetics 是一种很好的选择,因为它类型安全,并且通过 Kotlin 的 ?进行空检查。 它不需要开发人员的额外代码。 但这仅适用于 Kotlin 项目

但是,在使用 Kotlin Synthetics 时遇到了一个小问题。 例如,如果将内容视图设置为布局,然后使用仅存在于其他布局中的 id ,则 IDE 可让您自动完成并添加新的 import 语句。 除非您专门检查以确保其 import 语句仅导入正确的 view,否则没有安全的方法来验证这不会导致运行时问题

DataBinding

DataBinding 在功能上比其他方法优越得多,因为它不仅为您提供类型安全和空安全的 view 引用,而且还允许您直接在 xml 布局内使用数据驱动视图变化

image

ViewBinding

最近在 Android Studio 3.6 中引入的 ViewBinding 是 DataBinding 库的子集。 由于不需要注解处理,因此可以缩短构建时间。详细的使用可以参见 这篇文章

findViewById Butterknife Kotlin Synthetics DataBinding ViewBinding
一直空安全 部分 部分 ✔️ ✔️
类型安全 ✔️ ✔️ ✔️
样板代码 中等
构建时间 ✔️ ✔️ ✔️
支持语音 java/kotlin java/kotlin kotlin java/kotlin java/kotlin

关于我

我是 Fly_with24

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

推荐阅读更多精彩内容