阿里ARouter开源组件化框架项目实践

1. App项目组件化

做移动开发的同学都会发现这两年在移动开发圈子里最火的就是组件化了,组件化不同的实现方案也引起了各派技术大拿的争吵,技术人员的一个通病就是,总觉得自己的方案是最好的,吵归吵但是大家的目标其实是一致的,都是实现APP的功能组件化,为什么这两年大家都开始考虑做组件化了呢?这也是因为随着这几年APP的快速发展,各个活下来的APP安装包都由原先的几兆到了几十兆,甚至有些应用超过了100M,每个APP的开发团队也有原先的2,3个人到了几十人的团队,这就带来了两个问题,我的项目里实施组件化主要也是为了解决这两个问题,

1)随着代码量的增加,每次编译都需要10分钟以上,写完代码真机调试每次launch都是个痛苦的过程,用团队里同学的话来说一天8个小时超过5个小时都是在等待,在上家公司因为单应用代码量太大,甚至出现了开发同学在AS里点了run后,就出去抽烟的现象,抽完一支烟回来也差不多编译完了;

2)第二个问题就是单应用代码量太大了以后,如果组内成员开发水平层次不齐就有可能造成代码的高度耦合,修改代码的影响会非常大,可能修改了一个很小的地方,但是却引起了很多地方出现了问题,并且代码的复用性也下降的很厉害。

组件化能很好的解决这两个开发痛点,通过组件化可以将android每个功能module都编译成aar,并统一放到maven私有仓库里进行统一管理,ios的每个功能module可以都打包放入cocoapods里,这样每次编译工程的时候开发人员只需要编译自己的模块进行开发调试了,那将是非常快的,如果需要全工程运行,那也会非常快,因为组件都是事先编译好的执行码,不需要重新编译,只需要对壳工程进行编译就可以了,速度也是非常快的,这就解决了编译速度的问题;

对于优秀的coder,一般都是有代码洁癖的,这里的洁癖不单单指代码整洁,更多的指的是应用项目中的模块高度复用,没有冗余代码,最讨厌的就是看到有开发人员在项目中ctrl c ctrl v,组件化就能很好的解决这个问题,但是前提是你的功能组件规划切分的比较好,能够方便的在其它模块中进行复用。

2. ARouter解决了什么问题

2.1 组件化技术问题

上面介绍了那么多组件化的好处,那么了解过组件化的同学应该知道组件化实施的难点和需要攻克的问题,不然组件化实施还是有一定技术门槛的,做的不好反而会增加团队的开发成本,下面我们先讨论下组件化实施会遇到的技术问题:

1)如何解决组件单独编译的问题;

在组件化过程中,我们面临的第一个问题就是如果将组件单独编译调试,并且可以方便的与其它组件一起打包成一个应用;目前解决组件单独编译编译问题一般都是通过修改module的gradle文件的apply plugin来实现,application和module的切换,做的好一些就是在gradle.properties中定义一个debug开关,在gradle中增加一个if else判断,如果当前是debug模式,则module的gradle定义的apply plugin是application,如果非debug模式,那么module定义的apply plugin是module,这样项目编译的时候就会自动根据设置的debug参数来动态选择是要单独编译模块还是要编译成整个app;

gradle.properties

IsDebug = false

module的build.gradle

if (IsDebug.toBoolean()) {
    apply plugin: 'com.android.application'
} else {
    apply plugin: 'com.android.library'
}

2)如何解决组件件通讯问题;

组件化实施后必然存在各个组件之间的调用,目前的方案大部分都是通过intent进行传递,这样的话会造成传递的对象依赖,而且每个组件之间的调用也会比较混乱;在使用ARouter之前我们使用的方案是每个组件之间通过uri传递json,每个组件在被调用后从uri中获取json,然后对json进行反序列化成对象,再进行后续动作,每个组件提供的服务都通过集中配置统一处理,在启动的时候全部进行初始化,在内存中保存这些服务uri路由信息,之前casa提出的ios组件化方案是通过runtime反射出需要调用的组件,参数传递是通过Map来传递,实现了组件之间的实体依赖耦合;ARouter提出了类似目前服务端SOA的服务化概念,将每个组件都看成一个服务系统,对其它组件都是通过提供服务的方式,让其它组件完成调用,这样可以做到每个组件的服务都是可管理的,而且组件之间的边界是清晰的,耦合是松散的;个人觉得ARouter这种服务化的概念更理想化,在底层的服务注册管理中心进行统一管理,负责服务初始化、服务路由、服务调用、服务降级、服务资源回收等;

3)如何解决组件件页面调用问题;

组件页面的调用,按照服务化的理念,其实可以理解为服务的调用,原先我们实现的方案中,组件之间调用,如果是通过在自定义uri后面通过json传递需要传递的实体内容,但对于非页面的调用,就会导致产生组件间依赖是一种不干净的实现方案,但是解决组件间的页面调用用传统的uri带参数的方式是完全没有问题的,这样还可以在服务端统一管理所有的页面调度策略,可以方便的做灰度发布,可以做到每个用户的页面调度策略都是由服务端下发,对于需要进行进行灰度的用户,可以使用灰度版本的页面调度策略,非常方便;ARouter底层其实也是通过uri的方式来实现,只不过又重新做了更好的封装,能够比较方便的使用,并且对非页面的功能组件调用更加简单,只需要定义好服务uri,然后在另一个组件中直接调用定义的uri就可以了;

4)如何解决组件间解耦问题;

组件间的解耦是组件化的另一个核心问题,评判一个组件化方案好不好,一个要看该组件化方案是否对工程的性能会造成影响,另一方面就是能够很好的将组件化后的各个组件完美解耦;目前比较好的解耦方案一个是通过uri进行服务化定义,另一个就是通过runtime反射来实现组件的调用,第一种是目前用的比较多的方案,第二种在部分方案中也有使用,反射方案上手会存在一定的难度,而且查询问题会比较困难,但是性能方面反射方案会比uri方案要好,反正各有优缺点,各家按照自己的实际情况对比后可以自行选择;

2.2 ARouter功能介绍

https://github.com/alibaba/ARouter

  1. 支持直接解析标准URL进行跳转,并自动注入参数到目标页面中
  2. 支持多模块工程使用
  3. 支持添加多个拦截器,自定义拦截顺序
  4. 支持依赖注入,可单独作为依赖注入框架使用
  5. 支持InstantRun
  6. 支持MultiDex(Google方案)
  7. 映射关系按组分类、多级管理,按需初始化
  8. 支持用户指定全局降级与局部降级策略
  9. 页面、拦截器、服务等组件均自动注册到框架
  10. 支持多种方式配置转场动画
  11. 支持获取Fragment
  12. 完全支持Kotlin以及混编(配置见文末 其他#5)

2.3 ARouter典型应用

  1. 从外部URL映射到内部页面,以及参数传递与解析
  2. 跨模块页面跳转,模块间解耦
  3. 拦截跳转过程,处理登陆、埋点等逻辑
  4. 跨模块API调用,通过控制反转来做组件解耦

从上面介绍的ARouter功能,可以发现,第一节提到的这些组件化需要解决的技术问题,用ARouter基本都可以相对比较完美的解决, 如果项目使用ARouter后可以比较好的实现组件化,之前在2014年准备实施组件化的时候,当时还没有特别好的组件化方案,所以项目是自己实现的组件化方案,实现的比较简单,只是实现了组件页面的跳转,参数的传递,将代码从功能层面进行了拆分,当时拆了十几个子工程,当时拆分的过程还是比较顺利的,当时也希望能有一个比较完美的解决方案能在社区中冒出来,到了15年各种会议都有公司提出自己APP的组件化方案,其实也都是大同小异,而且也都没有成体系,笔者也一直很关注这块,直到阿里云的组件化方案ARouter发布,发现这就是我要的组件化方案,所以在新项目中也使用了该方案,目前所在公司的开发人员规模不是很大,没有足够的人力去自研组件化框架,所以使用阿里云的组件化方案还是比较方便稳妥的,下面就讲下怎么接入。

3. 快速接入ARouter

1)gradle中增加库依赖

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
        exclude group: 'com.android.support', module: 'support-annotations'
    })
    compile 'com.android.support:appcompat-v7:25.1.0'
    testCompile 'junit:junit:4.12'
    //下面两行就是需要添加的ARouter的依赖,arouter-api这个库是arouter的核心库
    //arouter-compiler是annnotation的定义库依赖,对于组件中使用到arouter注解的情况,一定要增加该依赖
    compile 'com.alibaba:arouter-api:1.2.1.1'
    annotationProcessor 'com.alibaba:arouter-compiler:1.0.3'

}

2)增加uri路径注解

/**
 * https://m.shrb.com/modulea/activity1
 */
@Route(path = "/modulea/activity1")
public class Activity1 extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_1);
    }
}

ARouter管理的页面都需要通过@Route注解进行标注,在注解中需要定义path来表示uri的路径,忘记从哪个版本开始,必须至少使用两级目录,第一级目录代表group,group的概念后面会阐述下,在ARouter中对所有发布的服务做了懒加载,只有group中的任意一个服务第一次被调用的时候才会去一次行把该group下的服务统一加载到内存,这样可以避免启动的时候初始化过多的可能不会用到的组件服务;

3)ARouter初始化

if (isDebug()) {           // 这两行必须写在init之前,否则这些配置在init过程中将无效
    ARouter.openLog();     // 打印日志
    ARouter.openDebug();   // 开启调试模式(如果在InstantRun模式下运行,必须开启调试模式!线上版本需要关闭,否则有安全风险)
}
ARouter.init(mApplication); // 尽可能早,推荐在Application中初始化

官方是建议将ARouter的初始化放在自定义的Application中初始化,这样可以避免在某个页面中或者service中初始化,后期资源被回收的问题,导致所有的组件服务全部失效,放在Application中就可以保证ARouter事例在内存中的生命周期和APP保持一致,不会存在资源被误回收的可能;在我后续的例子中为了方便我将初始化放在了demo主Activity中,这只是为了演示,实际项目使用过程中大家一定要放到Application中去初始化,防止app在使用过程中出现一些莫名其妙的问题;

4)页面路由

//组件无参数跳转
ARouter.getInstance()
   .build("/modulea/activity1")
   .navigation();
//组件携带参数跳转
 ARouter.getInstance()
   .build("/modulea/activity1")
   .withString("name", "老王")
   .withInt("age", 18)
   .navigation();

定义的activity1是和上面调用的activity不在一个module中,我们这里将activity1定义在了moduleA下面,activity1获取参数,可以像spring一样定义Autowired注解,但是这里的Autowired注解可不是spring类库下的自动绑定注解类,而是arouter库下的Autowired类,在activity定义了参数的同名局部变量后就可以在activity中通过ARouter.getInstance().inject(this); 来自动获取到传递的参数,arouter会自动注入到变量中,这样整个过程是不是看起来很简单,很清晰。

/**
 * https://m.shrb.com/modulea/activity1?name=老王&age=23
 */
@Route(path = "/modulea/activity1")
public class Activity1 extends Activity {

    @Autowired
    public String name;
    @Autowired
    public int age;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_1);
        //传递参数注入
        ARouter.getInstance().inject(this);

        Log.d("param", name + age);
    }
}

4. 深入学习ARouter

上面我们介绍了如果简单的引入ARouter并且进行不同组件间的页面路由,下面我们再介绍下ARouter一些高级技能。

1)解析URI中的参数

// 为每一个参数声明一个字段,并使用 @Autowired 标注
// URL中不能传递Parcelable类型数据,通过ARouter api可以传递Parcelable对象
@Route(path = "/modulea/activity1")
public class Activity1 extends Activity {
    @Autowired
    public String name;
    @Autowired
    int age;
    @Autowired(name = "girl") // 通过name来映射URL中的不同参数
    boolean boy;
    @Autowired
    TestObj obj;    // 支持解析自定义对象,URL中使用json传递

    @Override
    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    ARouter.getInstance().inject(this);

    // ARouter会自动对字段进行赋值,无需主动获取
    Log.d("param", name + age + boy);
    }
}
  1. 定义拦截器

这里所指的拦截器和j2ee中里常说的拦截器是一个概念,就是在处理这个服务前需要处理的动作,也类似jsp中的filter,和okhttp中的拦截器也是一样的概念,可以在拦截器中实现切面公共的功能,这样这些切面公共功能就不会和业务服务代码耦合在一起,是一种比较好的AOP实现,ARouter实现的拦截器也可以对拦截器设置优先级,这样可以对拦截器的处理优先顺序进行处理;但是这个拦截器整体功能还是比较弱,目前的版本实现的是全服务拦截,没有参数可以定义pointcut拦截点,所以如果要对指定页面进行处理只能在

// 比较经典的应用就是在跳转过程中处理登陆事件,这样就不需要在目标页重复做登陆检查
// 拦截器会在跳转之间执行,多个拦截器会按优先级顺序依次执行
@Interceptor(priority = 8, name = "测试用拦截器")
public class TestInterceptor implements IInterceptor {
    @Override
    public void process(Postcard postcard, InterceptorCallback callback) {
    Log.d("Interceptor","拦截器测试");
    callback.onContinue(postcard);  // 处理完成,交还控制权
    // callback.onInterrupt(new RuntimeException("我觉得有点异常"));      
      // 觉得有问题,中断路由流程

    // 以上两种至少需要调用其中一种,否则不会继续路由
    }

    @Override
    public void init(Context context) {
    // 拦截器的初始化,会在sdk初始化的时候调用该方法,仅会调用一次
    }
}
  1. 外部通过URL跳转APP的内部页面

定义SchemaFilterActitity,所有外部来调用本APP的请求,都会先到该SchemeFilterActivity,由该Activity获取uri后再通过Arouter进行转发,这样就可以实现几种效果,1.同一部手机上可以通过自定义url来访问我们app对外暴露的页面并接收外部应用传递过来的值,这样对于集团内应用交叉营销非常方便;2.对外分享的二维码直接扫码后打开app,对于在推广的app,可以实现扫码识别url后直接从外部手机浏览器跳转到我们的app某个页面,支付宝、微信支付都是这样实现在支付的时候唤起原生页面的;

public class SchameFilterActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {

        super.onCreate(savedInstanceState);

        Uri uri = getIntent().getData();
        ARouter.getInstance().build(uri).navigation();
        finish();
    }
}
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.commonlib">

    <application
        android:allowBackup="true"
        android:label="@string/app_name"
        android:supportsRtl="true">
        <activity android:name=".SchameFilterActivity">

            <!-- Schame -->
            <intent-filter>
              <!--自定义host和scheme -->
                <data
                    android:host="m.hop.com"
                    android:scheme="shrb" />

                <action android:name="android.intent.action.VIEW" />

                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
            </intent-filter>
        </activity>
        <activity android:name=".HopWebview"></activity>
    </application>

</manifest>

4)定义全局降级页面

相信我们之前遇到过这样的问题,如果跳转的页面不存在,或者调用的组件服务不存在就会报错甚至crash,这里ARouter为我们提供了默认降级服务,一旦路由找不到页面或者服务就会调用该降级service,我们可以在继承自DegradeService类的onLost方法中实现降级需要实现的动作;

// 实现DegradeService接口,并加上一个Path内容任意的注解即可
@Route(path = "/xxx/xxx")
public class DegradeServiceImpl implements DegradeService {
    @Override
    public void onLost(Context context, Postcard postcard) {
        Log.d("DegradeService","降级服务启动!");
    }

    @Override
    public void init(Context context) {

    }
}

5)组件服务注册与调用

前面我们说了组件间的互相调用都是通过暴露接口,或者服务来实现,笔者原来公司服务之间的互相调用都是通过直接调用依赖接口,所以调用方需要依赖被调用组件的接口,ARouter是将组件的服务对外暴露,调用方直接使用组件暴露的服务uri就可以了,使用起来和spring调用远程服务接口很像,使用起来很简洁;

服务注册

// 声明接口,其他组件通过接口来调用服务
public interface HelloService extends IProvider {
    String sayHello(String name);
}

// 实现接口
@Route(path = "/service/hello", name = "测试服务")
public class HelloServiceImpl implements HelloService {

    @Override
    public String sayHello(String name) {
    return "hello, " + name;
    }

    @Override
    public void init(Context context) {

    }
}

服务调用:

public class Test {
    @Autowired
    HelloService helloService;

    @Autowired(name = "/service/hello")
    HelloService helloService2;

    HelloService helloService3;

    HelloService helloService4;

    public Test() {
    ARouter.getInstance().inject(this);
    }

    public void testService() {
     // 1. (推荐)使用依赖注入的方式发现服务,通过注解标注字段,即可使用,无需主动获取
     // Autowired注解中标注name之后,将会使用byName的方式注入对应的字段,不设置name属性,会默认使用byType的方式发现服务(当同一接口有多个实现的时候,必须使用byName的方式发现服务)
    helloService.sayHello("monkey0l");
    helloService2.sayHello("monkey01");

    // 2. 使用依赖查找的方式发现服务,主动去发现服务并使用,下面两种方式分别是byName和byType
    helloService3 = ARouter.getInstance().navigation(HelloService.class);
    helloService4 = (HelloService) ARouter.getInstance().build("/service/hello").navigation();
    helloService3.sayHello("monkey01");
    helloService4.sayHello("monkey01");
    }
}

5. 现有项目ARouter改造实践

上面介绍了组件化的一些方案和ARouter组件化方案的使用,下面我们就拿笔者真实的项目例子去讲解下实施的一些经验。

对于手上有项目需要进行组件化的小伙伴,下面的这些内容应该会对你有所帮助,会给大家实施的一个思路,其实组件化的思路很重要,具体使用什么框架去实施其实是次要的。

1)对现有项目进行业务功能拆分;

项目实施组件化一般都是因为项目太复杂或者代码耦合问题比较严重,一修改代码就出现问题,所以首先我们要做的就是先对整个APP的功能进行拆分,对各个组件功能进行边界划分,这个工作其实是整个组件化过程的核心,组件化好不好全看组件功能拆分的好不好;当时我们拆分的过程主要分为几部分:

Screenshot 2017-11-21 17.04.13.png
1.壳工程
壳工程主要用于将各个组件组合起来和做一些工程初始化,初始化包含了后续各个组件会用到的一些库的初始化,也包括ApplicationContext的初始化工作;
2.基础类库
基础类库主要还是将各个组件中都会用到的一些基础库统一进行封装,例如网络请求、图片缓存、sqllite操作、数据加密等基础类库,这样可以避免各个组件都在自己的组件中单独引用,而且引用的版本可能都不一样,导致整个工程外部库混乱,统一了基础类库后,基础类库保持相对的稳定,这样各个组件对外部库的使用是相对可控的,防止出现一些外部库引出的极端问题,而且这样的话对于库的升级也比较好管理;
3.基础工程
对于每个组件都有一些是公共的抽象,例如我们工程中自己定义的BaseActivity、BaseFragment、自定义控件等,这些对于每个组件都是一样的,每个组件都基于一样的基础工程开发,一方面可以减少开发工作,另一方面也可以让各个组件的开发人员能够统一架构框架,这样每个组件的技术代码框架看起来都是一样的,也便于后期代码维护和人员互备;
4.业务模块
最大的一块体力工作就是业务模块的实现了,上面的几部分都实现以后,剩余的主要体力工作就是实现各个拆分出来的业务模块;

2)基础类库抽离;

对于原先工程中,由于开发人员技术参差不齐、开发赶进度、没有代码复查等原因,导致代码基础类库管理混乱,一个网络请求居然都有好几个外部库,在组件化过程中我们对常用的几个公共类库进行了统一,并且都封装在了一个基础类库中。

组件化:ARouter
MVVM:google Databinding
图片:picasso
网络:Retrofit
异步框架:RxJava
数据库:greenorm
Hybrid:jsBridge
日志管理:Logger、timber
消息Event:EventBus
消息推送:JPush
APM埋点:友盟
热更新:Tinker

3)基础工程抽离;

对于基础工程抽离其实比较简单,因为我们原先的工程对于公共类这已经抽象的比较好了,主要是在基础工程中抽象出了BaseActiviry、BaseFragment、BaseApplication、BaseViewModel等公共类,在每个Base类中都已经定义了一些必须要实现的抽象类和已经定义好的基础函数功能,这样以后每个定义的Activity或者Fragment都继承了这些Base类的基础功能,能够减少很多公共代码的开发工作,也可以在基础类中实现统一异常处理,统一消息捕获,切面拦截等等,只要是组件中公共的功能都可以在这里实现;

4)壳工程开发;

壳工程其实比较简单,一般壳工程中主要承担了项目初始化的工作,在壳的Application中需要对后续其它组件用到的全局库进行统一初始化,也要承担一些例如版本检测、配置文件加载、刷新、组件加载、热更新等工作。壳工程主要做的都是些基础的脏活累活,壳工程开发好后,就可以进行业务功能开发了,每次开发单模块功能的时候,可以在壳工程中只引用自己模块需要的功能进行开发编译调试了,不需要全部组件引用,这样可以大大缩减每次编译的时间,如果打开instance run那么编译的速度就更快了;

5)业务功能开发;

每个组件的业务功能开发这里就没什么好说的了,主要就是堆人去实施;

6)全回归测试;

在整个组件化过程中,最大的问题就是怎么保证组件化后原功能没有问题,这就要求我们有良好的CI来保障,持续集成是个很大的话题,后面我们单独写几篇文章来说下持续集成的实施经验,这里就不详细说了,通过CI不断的迭代提交代码,不断的自动化回归来保证代码的质量,如果没有好的CI,实施这么大的一个组件化重构,风险还是非常大的!这里没有危言耸听,我们的组件化和CI基本是同步实施的,没有CI那就意味着,所有组件化后的测试都要求手工回归去测试,那么测试的工作量是巨大的,而且不能很好的保证重构后一定没有问题,因为是一个线上版本,一旦出了问题那就意味着大量用户的差评和流失,后果不堪设想,所以如果开发团队对于现有产品只是为了赶新潮而去实施组件化,那就算了不要去组件化,如果遇到上面我提到的那些问题了,那就去组件化,但是要切记通过CI来保证质量,不然会死的很惨。

小结:

笔者目前一共实施过3个项目的组件化,第一个是传统国有银行APP的组件化重构,使用的是自研的简单deeplink URL组件化方案,组件化核心代码量非常小,几个核心类加起来大概也就1000多行代码,整个实施过程有个比较好的前提就是,团队已经具备比较好的CI能力,而且公司预留了充足的人力和时间(3个月)让我们去实施组件化,期间没有新需求,估计大部分公司都不会有这样的待遇,当时因为是第一次实施,期间也遇到了很多问题,当时还没有那么多资料可以参考,现在组件化的资料真的是铺天盖地,最后经过十几名同事的努力终于完成了整体项目的组件化实施并顺利上线,组件化以后再增加新功能那叫一个舒服。。

第二个组件化项目是电商项目,这个项目和上一个银行的组件化项目最大区别就在于,互联网电商项目是不可能给你3个月完全没有新需求的,这就要求我们要给在高速飞行的飞机换零件,一旦出问题那就是机毁人亡,所以在这个项目的实施过程中,我们主要解决了如何在现有的项目上实施增量的组件化,实施增量组件化的核心思路就是在开发出壳工程后,将原工程作为一个big组件,一步步的将里面的功能拆分出来,等big组件消失了,那也就拆完了,这个项目用的组件化方案也是自研的一套URL方案,和上一个银行的组件化方案非常像,只是在其中还实现了组件功能的服务化发布,但是没有实现ARouter的服务注册、治理整套机制,这个项目实施下来就比第一个项目组件化顺利的多;

第三个组件化的项目是一个O2O的项目,这个项目是新项目使用组件化实施,完全没有旧项目的负担,而且那个时候ARouter也出来了,我们在项目中也首次使用了ARouter,有了前面两次的磕磕盼盼,整体实施起来可谓是顺风顺水,而且还是新项目,很快就组件化实施上线。

组件化还是个比较庞大的工程,尤其对于线上项目进行组件化重构,还是存在很大的风险,这篇文章对于想实施组件化的同学应该有一定的参考意义,大家看完了如果觉得好就点个赞吧。

推荐阅读更多精彩内容