手记

12.享元模式设计思想

12.享元模式设计思想

目录介绍

  • 01.享元模式基础介绍
    • 1.1 享元模式由来
    • 1.2 享元模式定义
    • 1.3 享元模式场景
    • 1.4 享元模式思考
    • 1.5 核心思想是什么
  • 02.享元模式原理与实现
    • 2.1 罗列一个场景
    • 2.2 用例子理解享元
    • 2.3 内部和外部状态
    • 2.4 享元模式实现
  • 03.享元模式分析
    • 3.1 享元模式VS单例
    • 3.2 享元模式VS缓存
    • 3.3 享元模式VS对象池
  • 04.享元模式应用解析
    • 4.1 Integer享元模式应用
    • 4.2 String享元模式应用
    • 4.3 线程池享元模式应用
    • 4.4 Handler享元模式应用
  • 05.享元模式总结
    • 5.1 有哪些优点
    • 5.2 有哪些弊端
    • 5.3 休闲棋类应用
    • 5.4 文本编辑器应用
    • 5.5 应用环境说明
    • 5.6 享元模式扩展

推荐一个好玩网站

一个最纯粹的技术分享网站,打造精品技术编程专栏!编程进阶网

01.享元模式基础介绍

1.0 本博客AI摘要

享元模式是一种用于性能优化的设计模式,通过共享相同或相似对象来减少内存占用。本文档详细介绍了享元模式的基础概念、实现原理、应用场景及优缺点,并通过具体例子如Integer、String、线程池和Handler等展示了其实际应用。

此外,还探讨了享元模式与其他设计模式的结合使用,以及在休闲棋类和文本编辑器中的应用。适合需要优化系统性能和资源利用率的开发者参考。

1.1 享元模式由来

面向对象技术可以很好地解决一些灵活性或可扩展性问题,但在很多情况下需要在系统中增加类和对象的个数。

当对象数量太多时,将导致运行代价过高,带来性能下降等问题。举一个简单例子,比如handler发送消息,创建大量的消息message对象,就用到了享元模式。

享元模式由来,主要是解决对象太多创建的问题:更多内容

  1. 享元模式通过共享技术实现相同或相似对象的重用。
  2. 在享元模式中通常会出现工厂模式,需要创建一个享元工厂来负责维护一个享元池(Flyweight Pool)用于存储具有相同内部状态的享元对象。

1.2 享元模式定义

享元模式(Flyweight Pattern)

运用共享技术有效地支持大量细粒度对象的复用。系统只使用少量的对象,而这些对象都很相似,状态变化很小,可以实现对象的多次复用。

享元模式的核心思想是将具有相同内部状态的对象共享,以减少内存占用。更多内容

  • 当需要创建一个对象时,首先检查是否已经存在具有相同内部状态的对象。
  • 如果存在,则重用该对象,而不是创建一个新的对象。如果不存在,则创建一个新的对象并将其添加到共享池中,以供以后使用。

在享元模式中,对象分为两种类型:内部状态(Intrinsic State)和外部状态(Extrinsic State)。

内部状态是对象共享的部分,它不会随着外部环境的变化而改变。外部状态是对象特定的部分,它会随着外部环境的变化而变化。

1.3 享元模式场景

享元模式使用场景

  1. 大量相似对象的情况: 当一个应用程序使用大量相似对象,且这些对象可以被共享时,使用享元模式可以减少内存占用。典型的例子是文本编辑器中的字符对象,其中相同字符只需创建一次并共享。
  2. 缓存: 享元模式可以用于实现缓存,特别是在需要频繁访问、计算成本高昂的数据时。将已经计算好的数据缓存起来,并在需要时重用,可以提高系统的性能。
  3. 连接池: 在数据库连接池、线程池等资源池的设计中,享元模式也经常被应用。共享可用的连接或线程对象,避免频繁地创建和销毁。更多内容

工作中遇到场景

  1. 缓存,字符串常量池,线程池,连接池

作用

  1. 提高性能:生成对象成本比较高,生成之后缓存起来
  2. 降低内存消耗:能用一个对象就别创建两个对象

1.4 享元模式思考

享元模式的一些关键要点:

  • 内部状态是可以共享的,它存储在享元对象内部,并且不会随着外部环境的变化而改变。
  • 外部状态是不可共享的,它由客户端传递给享元对象,并且会随着外部环境的变化而变化。
  • 享元工厂(Flyweight Factory)负责创建和管理享元对象。它维护一个享元对象的池,根据客户端的请求返回已存在的享元对象或创建新的享元对象。
  • 客户端通过享元工厂获取享元对象,并将外部状态传递给享元对象。客户端可以共享相同的享元对象,从而减少内存占用。

1.5 核心思想是什么

享元模式的核心思想是将对象的内部状态和外部状态分离。更多内容,内部状态可以被多个对象共享,而外部状态则由客户端传递给享元对象。通过共享内部状态,可以减少对象的数量,从而减少内存占用。

02.享元模式原理与实现

2.1 罗列一个场景

接单做网站:要求做产品展示网站,有的人希望是新闻发布形式的,有的人希望是博客形式的,也有还是原来的产品图片加说明形式的。

因为他们找我们来做的人的需求只是有一些小小的差别。但是不可能有100家企业来找你做网站,你难道去申请100个服务器,用100个数据库,然后用类似的代码复制100遍,去实现吗?

不使用享元模式 - 接单做网站。

public class FlyweightWebSite {

    public static void main(String[] args) {
        test1();
    }

    public static void test1() {
        WebSite fx = new WebSite("产品展示");
        fx.use();
        WebSite fy = new WebSite("产品展示");
        fy.use();
        WebSite fz = new WebSite("产品展示");
        fz.use();
        WebSite fl = new WebSite("博客");
        fl.use();
        WebSite fm = new WebSite("博客");
        fm.use();
        WebSite fn = new WebSite("博客");
        fn.use();
    }

    //网站
    public static class WebSite {
        private String name = "";

        public WebSite(String name) {
            this.name = name;
        }

        public void use() {
            System.out.println("网站分类:" + name);
        }
    }
}

2.2 用例子理解享元

如果要做三个产品展示,三个博客的网站,就需要六个网站类的实例,而其实它们本质上都是一样的代码,如果网站增多,实例也就随着增多。更多内容

利用享元模式做成第一版本的网站,代码如下所示:

public static void test2() {
    System.out.println("使用享元模式做的第一版网站");
    WebSiteFactory webSiteFactory = new WebSiteFactory();
    AbsWebSite siteCategory = webSiteFactory.getWebSiteCategory("产品展示");
    siteCategory.use();
    AbsWebSite siteCategory1 = webSiteFactory.getWebSiteCategory("产品展示");
    siteCategory1.use();
    AbsWebSite siteCategory2 = webSiteFactory.getWebSiteCategory("产品展示");
    siteCategory2.use();
    AbsWebSite siteCategory3 = webSiteFactory.getWebSiteCategory("博客");
    siteCategory3.use();
    AbsWebSite siteCategory4 = webSiteFactory.getWebSiteCategory("博客");
    siteCategory4.use();
    AbsWebSite siteCategory5 = webSiteFactory.getWebSiteCategory("博客");
    siteCategory5.use();
    System.out.println("总共创建了 " + webSiteFactory.getWebSiteCount() + " 个实例");
}

/**
 * 网站抽象类
 */
public abstract static class AbsWebSite {
    public abstract void use();
}

/**
 * 具体网站类
 */
public static class ConcreteWebSite extends AbsWebSite {
    private String name = "";

    public ConcreteWebSite(String name) {
        this.name = name;
    }

    @Override
    public void use() {
        System.out.println("网站分类:" + name);
    }
}

/**
 * 网站工厂类
 */
public static class WebSiteFactory {
    private HashMap<String, AbsWebSite> flyweights = new HashMap<>();

    /**
     * 获得网站分类
     *
     * @param key key
     * @return AbsWebSite
     */
    public AbsWebSite getWebSiteCategory(String key) {
        if (!flyweights.contains(key)) {
            flyweights.put(key, new ConcreteWebSite(key));
        }
        return flyweights.get(key);
    }

    /**
     * 获得网站实例个数
     *
     * @return 数量
     */
    public int getWebSiteCount() {
        return flyweights.size();
    }
}

上面案例遇到了一些问题

基本实现了享元模式的共享对象的目的,也就是说,不管建个网站,只要是 ‘产品展示’ ,都是一样的,只要是 ‘博客’ ,也是完全相同的。

但这样是有问题的,以上这样建的网站不是一家客户的,它们的数据不会相同,所以至少它们都应该有不同的账号。这样写没有体现对象间的不同,只体现了它们共享的部分。

2.3 内部和外部状态

内部状态 - 外部状态 - 享元模式 - 接单做网站

在享元对象内部并且不会随环境改变而改变的共享部分,可以称为享元对象的内部状态,随环境改变而改变的、不可以共享的状态就是外部状态。

事实上,享元模式可以避免大量非常相似类的开销。在程序设计中,有时需要生成大量细粒度的类实例来表示数据。如果能发现这些实例除了几个 参数外基本上都是相同的,有时就能够大幅度地减少需要实例化的类的数量。如果能把那些参数移到类实例的外面,在方法调用时将它们传递进来, 就可以通过共享大幅度地减少单个实例的数目。

也就是说,享元模式 Flyweight 执行时所需的状态有内部的也可能有外部的,内部状态存储于 ConcreteFlyweight 对象之中,而外部对象则应该考虑由客户端对象存储或计 算,当调用Flyweight对象的操作时,将该状态传递给它。

客户的账号就是外部状态,应该由专门的对象来处理。更多内容

/**
 * 用户类,用于网站的客户账号,是"网站"类的外部状态。
 */
public static class User {
    private String name;
    public User(String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
}
/**
 * 网站抽象类
 */
public abstract static class AbsWebSite2 {
    public abstract void use(User user);
}
public static class ConcreteWebSite2 extends AbsWebSite2 {
    private String name = "";
    public ConcreteWebSite2(String name) {
        this.name = name;
    }
    @Override
    public void use(User user) {
        System.out.println("网站分类:" + name + " 来自客户:" + user.getName() + "的需求");
    }
}

打印数据如下所示,这样就可以协调内部与外部状态了。

使用享元模式做的第二版网站,添加外部状态
网站分类:产品展示 来自客户:打工充0的需求
网站分类:产品展示 来自客户:打工充1的需求
网站分类:产品展示 来自客户:打工充2的需求
网站分类:博客 来自客户:打工充3的需求
网站分类:博客 来自客户:打工充4的需求
网站分类:博客 来自客户:打工充5的需求
总共创建了 2 个实例

2.4 享元模式实现

享元模式实现的角色

  1. Flyweight:享元对象抽象基类或接口。
  2. ConcreteFlyweight:具体的享元对象。
  3. FlyweightFactory:享元工厂,负责管理享元对象池和创建享元对象。
  4. UnsharedConcreteFlyweight:非共享具体享元类

享元模式的基本实现代码如下所示

/**
 * 享元模式基本实现,比较官方的demo案例
 */
public class FlyweightDemo {

    public static void main(String[] args) {
        int extrinsicState = 30;
        FlyweightFactory factory = new FlyweightFactory();
        Flyweight flyweightA = factory.getFlyweight("A");
        flyweightA.operation(--extrinsicState);
        Flyweight flyweightB = factory.getFlyweight("B");
        flyweightB.operation(--extrinsicState);
        Flyweight flyweightC = factory.getFlyweight("C");
        flyweightC.operation(--extrinsicState);

        // 不要共享的
        UnsharedConcreteFlyweight unsharedFly = new UnsharedConcreteFlyweight();
        unsharedFly.operation(--extrinsicState);
    }

    /**
     * Flyweight类是所有具体享元类的超类或接口,通过这个接口, Flyweight可以接受并作用于外部状态。
     */
    public static abstract class Flyweight {
        public abstract void operation(int extrinsicState);
    }

    /**
     * ConcreteFlyweight是继承Flyweight超类或实现Flyweight接口,并为内部状态增加存储空间。
     * 需要共享的具体Flyweight子类
     */
    public static class ConcreteFlyweight extends Flyweight {
        @Override
        public void operation(int extrinsicState) {
            System.out.println("需要共享的具体Flyweight子类:" + extrinsicState);
        }
    }

    /**
     * 需要共享的具体Flyweight子类
     * UnsharedConcreteFlyweight是指那些不需要共享的Flyweight子类。因为Flyweight接口共享成为可能,但它并不强制共享。
     */
    public static class UnsharedConcreteFlyweight extends Flyweight {
        @Override
        public void operation(int extrinsicState) {
            System.out.println("不需要共享的具体Flyweight子类:" + extrinsicState);
        }
    }

    /**
     * 享元工厂
     */
    public static class FlyweightFactory {
        private final HashMap<String, Flyweight> flyweights = new HashMap<>();

        /**
         * 初始化工厂三个实例
         */
        public FlyweightFactory() {
            flyweights.put("A", new ConcreteFlyweight());
            flyweights.put("B", new ConcreteFlyweight());
            flyweights.put("C", new ConcreteFlyweight());
        }

        /**
         * 根据客户端请求,获得已生成的实例
         *
         * @param key key
         * @return Flyweight
         */
        public Flyweight getFlyweight(String key) {
            return flyweights.get(key);
        }
    }
}

03.享元模式分析

3.1 享元模式VS单例

在单例模式中,一个类只能创建一个对象,而在享元模式中,一个类可以创建多个对象,每个对象被多处代码引用共享。

实际上,享元模式有点类似于之前讲到的单例的变体:多例。更多内容

区别两种设计模式,不能光看代码实现,而是要看设计意图,也就是要解决的问题。

  1. 尽管从代码实现上来看,享元模式和多例有很多相似之处,但从设计意图上来看,它们是完全不同的。
  2. 应用享元模式是为了对象复用,节省内存,而应用多例模式是为了限制对象的个数。

3.2 享元模式VS缓存

在享元模式的实现中,我们通过工厂类来“缓存”已经创建好的对象。

这里的“缓存”实际上是“存储”的意思,跟我们平时所说的“数据库缓存”“CPU 缓存”“MemCache 缓存”是两回事。

我们平时所讲的缓存,主要是为了提高访问效率,而非复用。

3.3 享元模式VS对象池

简单解释一下对象池。

像 C++ 这样的编程语言,内存的管理是由程序员负责的。为了避免频繁地进行对象创建和释放导致内存碎片,我们可以预先申请一片连续的内存空间,也就是这里说的对象池。

每次创建对象时,我们从对象池中直接取出一个空闲对象来使用,对象使用完成之后,再放回到对象池中以供后续复用,而非直接释放掉。

对象池、连接池(比如数据库连接池)、线程池等也是为了复用,那它们跟享元模式有什么区别呢?更多内容

虽然对象池、连接池、线程池、享元模式都是为了复用,但是抠一抠“复用”这个字眼的话,对象池、连接池、线程池等池化技术中的“复用”和享元模式中的“复用”实际上是不同的概念。

池化技术中的“复用”可以理解为“重复使用”,主要目的是节省时间(比如从数据库池中取一个连接,不需要重新创建)。在任意时刻,每一个对象、连接、线程,并不会被多处使用,而是被一个使用者独占,当使用完成之后,放回到池中,再由其他使用者重复利用。

享元模式中的“复用”可以理解为“共享使用”,在整个生命周期中,都是被所有使用者共享的,主要目的是节省空间。

04.享元模式应用解析

4.1 Integer享元模式应用

先来看下面这样一段代码。你可以先思考下,这段代码会输出什么样的结果。

private static void test() {
    Integer i1 = 31;
    Integer i2 = 31;
    Integer i3 = 129;
    Integer i4 = 129;
    System.out.println(i1 == i2);
    System.out.println(i3 == i4);

    //true
    //false
}

你可能会觉得,i1 和 i2 值都是 56,i3 和 i4 值都是 129,i1 跟 i2 值相等,i3 跟 i4 值相等,所以输出结果应该是两个 true。但其实这是错误的。因为Integer用到了缓存池的概念……

需要弄清楚下面两个问题:

  1. 如何判定两个 Java 对象是否相等(也就代码中的“==”操作符的含义)?
  2. 什么是自动装箱(Autoboxing)和自动拆箱(Unboxing)?

所谓的自动装箱,就是自动将基本数据类型转换为包装器类型。所谓的自动拆箱,也就是自动将包装器类型转化为基本数据类型。更多内容

Integer i = 31; //自动装箱         底层执行了:Integer i = Integer.valueOf(31); 
int j = i; //自动拆箱              底层执行了:int j = i.intValue();

为何 i3==i4 判定语句也会返回 false

这正是因为 Integer 用到了享元模式来复用对象,才导致了这样的运行结果。

当我们通过自动装箱,也就是调用 valueOf() 来创建 Integer 对象的时候,如果要创建的 Integer 对象的值在 -128 到 127 之间,会从 IntegerCache 类中直接返回,否则才调用 new 方法创建。

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

这里的 IntegerCache 相当于生成享元对象的工厂类,只不过名字不叫 xxxFactory 而已。

4.2 String享元模式应用

再来看下,享元模式在 Java String 类中的应用。同样,我们还是先来看一段代码,你觉得这段代码输出的结果是什么呢?

private static void test2() {
    String s1 = "打工充";
    String s2 = "打工充";
    String s3 = new String("打工充");

    System.out.println(s1 == s2);
    System.out.println(s1 == s3);

    //true
    //false
}

跟 Integer 类的设计思路相似,String 类利用享元模式来复用相同的字符串常量(也就是代码中的“打工充”)。

JVM 会专门开辟一块存储区来存储字符串常量,这块存储区叫作“字符串常量池”。

4.3 线程池享元模式应用

4.4 Handler享元模式应用

Handler 消息机制中的 Message 消息池就是使用享元模式复用了 Message 对象

遇到问题说明:如果使用 new Message() 会构造大量的 Message 对象。通过new创建Message就会创建大量重复的Message对象,导致内存占用率高,频繁GC等问题。

使用享元模式:使用 Message 时一般是用 Message.obtain 来获取消息。通过享元模式创建一个大小为50的消息池,避免了上述问题的产生。

Message享元模式分析:Message相当于承担了享元模式中3个元素的职责,既是Flyweight抽象,又是ConcreteFlyweight角色,同时又承担了FlyweightFactory管理对象池的职责。

注意点:Message的享元模式并不是经典的实现方式,它没有内部,外部状态,集各个职责于一身,甚至更像一个对象池。更多内容

05.享元模式总结

5.1 有哪些优点

优点

  1. 内存优化:通过共享对象的内部状态,可以大大减少对象的数量,从而节省内存空间。
  2. 性能提升:减少对象数量和内存占用可以提高系统的性能,特别是在处理大量细粒度对象时。
  3. 可扩展性:享元模式可以轻松扩展以支持新的外部状态,而无需修改现有的共享对象。
  4. 对象复用:通过共享对象,可以实现对象的复用,避免重复创建相同的对象,提高系统的效率和资源利用率。

5.2 有哪些弊端

缺点:

  1. 共享对象可能引入线程安全问题:如果多个线程同时访问和修改共享对象的状态,需要考虑线程安全性,以避免并发访问引发的问题。
  2. 对象状态的外部化:为了实现对象共享,部分对象状态需要外部化,这可能会增加代码复杂性和维护成本。

5.3 休闲棋类应用

享元模式更多的时候是一种底层的设计模式,但现实中也是有应用的:

比如休闲棋类。

如果不用享元模式会怎么样?

像象棋,一盘棋理论上有32棋子,那如果用常规的面向对象方式编程,每盘棋都可能有至少32个棋子对象产生。

一个游戏厅中有成千上万个“房间”,每个房间对应一个棋局。棋局要保存每个棋子的数据,比如:棋子类型(将、相、士、炮等)、棋子颜色(红方、黑方)、棋子在棋局中的位置。

思考一下下面的问题

一台服务器就很难支持更多的玩家玩围棋游戏了,比如1万棋局玩家,那就会有大量的对象,毕竟内存空间还是有限的。

那么我们如何去避免大量细粒度的对象,同时又不影响客户程序,是一个值得去思考的问题。有没有什么办法来节省内存呢?

用了享元模式主要是节省资源和降低损耗

比如说休闲游戏开发中,像象棋,它们都有大量的棋子对象,分析一下,它们的内部状态和外部状态各是什么?更多内容

象棋只有红黑两色、跳棋颜色略多一些,但也是不太变化的,所以颜色应该是棋子的内部状态,而各个棋子之间的差别主要就是位置的不同,所以方位坐标应该是棋子的外部状态。

在使用享元模式之前,记录1万个棋局,我们要创建 32 万(32*1 万)个棋子的对象。利用享元模式,我们只需要创建32个享元对象供所有棋局共享使用即可,大大节省了内存。

5.4 文本编辑器应用

使用享元模式的一个常见例子是文本编辑器中的字符对象。

在一个文本文件中,可能有大量的字符对象,它们的外部状态(例如位置、字体、颜色等)可能不同,但内部状态(例如字符代码、字符宽度等)是相同的。

通过共享具有相同字符代码的字符对象,可以大大减少内存使用。

5.5 应用环境说明

享元模式适用于大量细粒度对象的场景

通过共享对象的内部状态来减少对象数量和内存占用,从而提高系统性能和资源利用率。

它在需要重复创建相似对象的情况下特别有用,并且适用于多线程环境,但需要注意线程安全性。更多内容

5.5 如何判断是否用享元

在以下情况下可以使用享元模式:

  1. 一个系统有大量相同或者相似的对象,由于这类对象的大量使用,造成内存的大量耗费。可以使用享元模式来复用已有对象,提高系统性能。
  2. 对象的内部状态相同或相似,而外部状态有所不同时,可以将这些外部状态传入对象中。
  3. 使用享元模式需要维护一个存储享元对象的享元池,而这需要耗费资源,因此,应当在多次重复使用享元对象时才值得使用享元模式。

5.6 享元模式扩展

享元模式与其他模式的联用

  1. 在享元模式的享元工厂类中通常提供一个静态的工厂方法用于返回享元对象,使用简单工厂模式来生成享元对象。
  2. 在一个系统中,通常只有唯一一个享元工厂,因此享元工厂类可以使用单例模式进行设计。
  3. 享元模式可以结合组合模式形成复合享元模式,统一对享元对象设置外部状态。

06.享元模式总结

6.1 总结一下学习

6.2 更多内容推荐

模块 描述 备注
GitHub 多个YC系列开源项目,包含Android组件库,以及多个案例 GitHub
博客汇总 汇聚Java,Android,C/C++,网络协议,算法,编程总结等 YCBlogs
设计模式 六大设计原则,23种设计模式,设计模式案例,面向对象思想 设计模式
Java进阶 数据设计和原理,面向对象核心思想,IO,异常,线程和并发,JVM Java高级
网络协议 网络实际案例,网络原理和分层,Https,网络请求,故障排查 网络协议
计算机原理 计算机组成结构,框架,存储器,CPU设计,内存设计,指令编程原理,异常处理机制,IO操作和原理 计算机基础
学习C编程 C语言入门级别系统全面的学习教程,学习三到四个综合案例 C编程
C++编程 C++语言入门级别系统全面的教学教程,并发编程,核心原理 C++编程
算法实践 专栏,数组,链表,栈,队列,树,哈希,递归,查找,排序等 Leetcode
Android 基础入门,开源库解读,性能优化,Framework,方案设计 Android

23种设计模式

23种设计模式 & 描述 & 核心作用 包括
创建型模式
提供创建对象用例。能够将软件模块中对象的创建和对象的使用分离
工厂模式(Factory Pattern)
抽象工厂模式(Abstract Factory Pattern)
单例模式(Singleton Pattern)
建造者模式(Builder Pattern)
原型模式(Prototype Pattern)
结构型模式
关注类和对象的组合。描述如何将类或者对象结合在一起形成更大的结构
适配器模式(Adapter Pattern)
桥接模式(Bridge Pattern)
过滤器模式(Filter、Criteria Pattern)
组合模式(Composite Pattern)
装饰器模式(Decorator Pattern)
外观模式(Facade Pattern)
享元模式(Flyweight Pattern)
代理模式(Proxy Pattern)
行为型模式
特别关注对象之间的通信。主要解决的就是“类或对象之间的交互”问题
责任链模式(Chain of Responsibility Pattern)
命令模式(Command Pattern)
解释器模式(Interpreter Pattern)
迭代器模式(Iterator Pattern)
中介者模式(Mediator Pattern)
备忘录模式(Memento Pattern)
观察者模式(Observer Pattern)
状态模式(State Pattern)
空对象模式(Null Object Pattern)
策略模式(Strategy Pattern)
模板模式(Template Pattern)
访问者模式(Visitor Pattern)

6.3 更多内容

0人推荐
随时随地看视频
慕课网APP