您的位置:IT爆料网 > 互联网

Android单元测试 - 验证函数参数、返回值的正确姿势

发布时间:2022-12-24 16:56:27  来源:互联网     背景:

原文链接:http://www.jianshu.com/p/77ee7c0270bc

前言

读者有没发觉我写文章时,喜欢有个前言、序?真相是,一半用来装逼凑字数,一半是因为不知道接下来要写什么,先闲聊几句压压惊^_^ 哈哈哈......该说的还是要说。

上一篇《Android单元测试 - Sqlite、SharedPreference、Assets、文件操作 怎么测?》 讲了一些DAO(Data Access Object)单元测试的细节。本篇讲解参数验证。

验证参数传递、函数返回值,是单元测试中十分重要的环节。笔者相信不少读者都有验证过参数,但是你的单元测试代码真的是正确的吗?笔者在早期实践的时候,遇到一些问题,积累了一点心得,本期与大家分享一下。

1.一般形式

Bean

public class Bean {     int    id;     String name;      public Bean(int id, String name) {         this.id = id;         this.name = name;     }     // getter and setter     ...... }  

DAO

public class DAO {     public Bean get(int id) {         return new Bean(id, "bean_" + id);     } }  

Presenter

public class Presenter {      DAO dao;      public Presenter(DAO dao) {         this.dao = dao;     }      public Bean getBean(int id) {         Bean bean = dao.get(id);          return bean;     } }  

单元测试PresenterTest(下文称为“例子1”)

public class PresenterTest {      DAO       dao;     Presenter presenter;      @Before     public void setUp() throws Exception {         dao = mock(DAO.class);         presenter = new Presenter(dao);     }      @Test     public void testGetBean() throws Exception {         Bean bean = new Bean(1, "bean_1");          when(dao.get(1)).thenReturn(bean);          Bean result = presenter.getBean(1);          Assert.assertEquals(result.getId(), 1);         Assert.assertEquals(result.getName(), "bean_1");     } }  

这个单元测试是通过的。

2.问题:对象很多变量

上面的Bean只有2个参数,但实际项目,对象往往有很多很多参数,例如,用户信息User :

public class User {     int    id;     String name;      String country;     String province;     String city;     String address;     int    zipCode;      long birthday;      double height;     double weigth;      ... }  

单元测试:

@Test    public void testUser() throws Exception {        User user = new User(1, "bean_1");        user.setCountry("中国");        user.setProvince("广东");        user.setCity("广州");        user.setAddress("天河区临江大道海心沙公园");        user.setZipCode(510000);        user.setBirthday(631123200);        user.setHeight(173);        user.setWeigth(55);        user.setXX(...);         .....         User result = presenter.getUser(1);         Assert.assertEquals(result.getId(), 1);        Assert.assertEquals(result.getName(), "bean_1");        Assert.assertEquals(result.getCountry(), "中国");        Assert.assertEquals(result.getProvince(), "广东");        Assert.assertEquals(result.getCity(), "广州");        Assert.assertEquals(result.getAddress(), "天河区临江大道海心沙公园");        Assert.assertEquals(result.getZipCode(), 510000);        Assert.assertEquals(result.getBirthday(), 631123200);        Assert.assertEquals(result.getHeight(), 173);        Assert.assertEquals(result.getWeigth(), 55);        Assert.assertEquals(result.getXX(), ...);        ......    }  

一般形式的单元测试,有10个参数,就要set()10次,get()10次,如果参数更多,一个工程有几十上百个这种测试......感受到那种蛋蛋的痛了吗?

这里有两个痛点:

生成对象必须 调用所有setter() 赋值成员变量 验证返回值,或者回调参数时,必须 调用所有getter() 获取成员值

3.equals()对比对象,可行吗?

直接调用equals()

这时同学A举手了:“不就是比较对象吗,用equal()还不行?”

为了演示方便,还是用回Bean做例子:

@Test     public void testGetBean() throws Exception {         Bean bean = new Bean(1, "bean_1");          when(dao.get(1)).thenReturn(bean);          Bean result = presenter.getBean(1);          Assert.assertTrue(result.equals(bean));     }  

运行一下:

诶,还真通过了!第一个问题解决了,鼓掌..... 稍等,我们把Presenter代码改改,看还能不能凑效:

public class Presenter {      public Bean getBean(int id) {         Bean bean = dao.get(id);          return new Bean(bean.getId(), bean.getName());     } }  

再运行单元测试:

果然出错了!

我们分析一下问题,修改前的Presenter.getBean()方法, dao.get()得到的Bean对象,直接作为返回值,所以PresenterTest中Assert.assertTrue(result.equals(bean));通过测试,因为bean和result是同一个对象;修改后,Presenter.getBean()里,返回值是dao.get()得到的Bean的深拷贝,bean和result是不同对象,因此result.equals(bean)==false,测试失败。如果我们使用一般形式Assert.assertEquals(result.getXX(), ...);,单元测试是通过的。

无论是直接返回对象,深拷贝,只要参数一致,都符合我们期望的结果。所以,仅仅调用equals()解决不了问题。

重写equals()方法

同学B:“既然只是比较成员值,重写equals()!”

public class Bean {     @Override     public boolean equals(Object obj) {         if (obj instanceof Bean) {             Bean bean = (Bean) obj;              boolean isEquals = false;              if (isEquals) {                 isEquals = id == bean.getId();             }              if (isEquals) {                 isEquals = (name == null && bean.getName() == null) || (name != null && name.equals(bean.getName()));             }              return isEquals;         }          return false;     } }  

再次运行单元测试Assert.assertTrue(result.equals(bean));:

稍等,这样我们不是回到老路,每个java bean都要重写equals()吗?尽管整个工程下来,总体代码会减少,但这真不是好办法。

反射比较成员值

同学C:“我们可以用反射获取两个对象所有成员值,并逐一对比。”

哈哈哈,同学C比同学A、B都要聪明点,还会反射!

public class PresenterTest{     @Test     public void testGetBean() throws Exception {         ...         ObjectHelper.assertEquals(bean, result);     } }   public class ObjectHelper {      public static boolean assertEquals(Object expect, Object actual) throws IllegalAccessException {         if (expect == actual) {             return true;         }          if (expect == null && actual != null || expect != null && actual == null) {             return false;         }          if (expect != null) {             Class clazz = expect.getClass();              while (!(clazz.equals(Object.class))) {                 Field[] fields = clazz.getDeclaredFields();                  for (Field field : fields) {                     field.setAccessible(true);                      Object value0 = field.get(expect);                     Object value1 = field.get(actual);                      Assert.assertEquals(value0, value1);                 }                  clazz = clazz.getSuperclass();             }         }          return true;     } }  

运行单元测试,通过!

用反射直接对比成员值,思路是正确的。这里解决了“对比两个对象的成员值是否相同,不需要get()n次”问题。不过,仅仅比较两个对象,这个单元测试还是有问题的。我们先讲第4节,这个问题留在第5节给大家说明。

4.省略不必要setter()

在testUser()中,第一个痛点:“生成对象必须 调用所有setter() 赋值成员变量”。 上一节同学C用反射方案,把对象成员值拿出来,逐一比较。这个方案提醒了我们,赋值也可以同样方案。

ObjectHelper:

public class ObjectHelper {      protected static final List numberTypes = Arrays.asList(int.class, long.class, double.class, float.class, boolean.class);      public static <T> T random(Class<T> clazz) throws IllegalAccessException, InstantiationException {         try {             T obj = newInstance(clazz);              Class tClass = clazz;              while (!tClass.equals(Object.class)) {                  Field[] fields = tClass.getDeclaredFields();                  for (Field field : fields) {                     field.setAccessible(true);                      Class type      = field.getType();                     int   modifiers = field.getModifiers();                      // final 不赋值                     if (Modifier.isFinal(modifiers)) {                         continue;                     }                      // 随机生成值                     if (type.equals(Integer.class) || type.equals(int.class)) {                         field.set(obj, new Random().nextInt(9999));                     } else if (type.equals(Long.class) || type.equals(long.class)) {                         field.set(obj, new Random().nextLong());                     } else if (type.equals(Double.class) || type.equals(double.class)) {                         field.set(obj, new Random().nextDouble());                     } else if (type.equals(Float.class) || type.equals(float.class)) {                         field.set(obj, new Random().nextFloat());                     } else if (type.equals(Boolean.class) || type.equals(boolean.class)) {                         field.set(obj, new Random().nextBoolean());                     } else if (CharSequence.class.isAssignableFrom(type)) {                         String name = field.getName();                         field.set(obj, name + "_" + (int) (Math.random() * 1000));                     }                 }                 tClass = tClass.getSuperclass();             }             return obj;         } catch (Exception e) {             e.printStackTrace();         }         return null;     }      protected static <T> T newInstance(Class<T> clazz) throws IllegalAccessException, InvocationTargetException, InstantiationException {          Constructor constructor = clazz.getConstructors()[0];// 构造函数可能是多参数          Class[] types = constructor.getParameterTypes();          List<Object> params = new ArrayList<>();          for (Class type : types) {             if (Number.class.isAssignableFrom(type) || numberTypes.contains(type)) {                 params.add(0);             } else {                 params.add(null);             }         }          T obj = (T) constructor.newInstance(params.toArray());//clazz.newInstance();          return obj;     } }  

写个单元测试,生成并随机赋值的Bean,输出Bean所有成员值:

@Test public void testNewBean() throws Exception {     Bean bean = ObjectHelpter.random(Bean.class);      // 输出bean     System.out.println(bean.toString()); // toString()读者自己重写一下吧 }  

运行测试:

Bean {id: 5505, name: "name_145"} 

修改单元测试

单元测试PresenterTest:

public class PresenterTest {     @Test     public void testUser() throws Exception {         User expect = ObjectHelper.random(User.class);          when(dao.getUser(1)).thenReturn(expect);          User actual = presenter.getUser(1);          ObjectHelper.assertEquals(expect, actual);     } }  

代码少了许多,很爽有没有?

运行一下,通过:

5.比较对象bug

上述笔者提到的解决方案,有一个问题,看以下代码:

Presenter:

public class Presenter {      DAO dao;      public Bean getBean(int id) {         Bean bean = dao.get(id);          // 临时修改bean值         bean.setName("我来捣乱");          return new Bean(bean.getId(), bean.getName());     } }   @Test     public void testGetBean() throws Exception {         Bean expect = random(Bean.class);          System.out.println("expect: " + expect);// 提前输出expect          when(dao.get(1)).thenReturn(expect);          Bean actual = presenter.getBean(1);          System.out.println("actual: " + actual);// 输出结果          ObjectHelper.assertEquals(expect, actual);     } 

运行一下修改后的单元测试:

Pass expect: Bean {id=3282, name='name_954'} actual: Bean {id=3282, name='我来捣乱'}  

居然通过了!(不符合预期结果)这是怎么回事?

笔者给大家分析下:我们希望返回的结果是Bean{id=3282, name='name_954'},但是在Presenter里mock指定的返回对象Bean被修改了,同时返回的Bean深拷贝对象,变量name也跟着变;运行单元测试时,在最后才比较两个对象的成员值,两个对象的name都被修改了,导致equals()认为是正确。

这里的问题:

在Presenter内部篡改了mock指定返回对象的成员值

最简单的解决方法:

在调用Presenter方法前,把的mock返回对象的成员参数,提前拿出来,在单元测试最后比较。

修改单元测试:

@Test     public void testGetBean() throws Exception {         Bean   expect = random(Bean.class);         int    id     = expect.getId();         String name   = expect.getName();          when(dao.get(1)).thenReturn(expect);          Bean actual = presenter.getBean(1);          //    ObjectHelper.assertEquals(expect, actual);          Assert.assertEquals(id, actual.getId());         Assert.assertEquals(name, actual.getName());     }  

运行,测试不通过(符合预期结果):

org.junit.ComparisonFailure:  Expected :name_825 Actual :我来捣乱  

符合我们期望值(测试不通过)!等等....这不就回到老路了吗?当有很多成员变量,不就写到手软?前面讲的都白费了?

接下来,进入本文高潮。

6.解决方案1:提前深拷贝expect对象

public class ObjectHelpter {     public static <T> T copy(T source) throws IllegalAccessException, InstantiationException, InvocationTargetException {         Class<T> clazz = (Class<T>) source.getClass();          T obj = newInstance(clazz);          Class tClass = clazz;          while (!tClass.equals(Object.class)) {              Field[] fields = tClass.getDeclaredFields();              for (Field field : fields) {                 field.setAccessible(true);                  Object value = field.get(source);                  field.set(obj, value);             }             tClass = tClass.getSuperclass();         }         return obj;     } }  

单元测试:

@Test    public void testGetBean() throws Exception {        Bean bean   = ObjectHelpter.random(Bean.class);        Bean expect = ObjectHelpter.copy(bean);         when(dao.get(1)).thenReturn(bean);         Bean actual = presenter.getBean(1);                ObjectHelpter.assertEquals(expect, actual);    }  

运行一下,测试不通过,great(符合想要的结果):

我们把Presenter改回去:

public class Presenter {     DAO dao;      public Bean getBean(int id) {         Bean bean = dao.get(id);  //        bean.setName("我来捣乱");          return new Bean(bean.getId(), bean.getName());     } }  

再运行单元测试,通过:

7.解决方案2:对象->JSON,比较JSON

看到这节标题,大家都明白怎么回事了吧。例子中,我们会用到Gson。

Gson

public class PresenterTest{     @Test     public void testBean() throws Exception {         Bean   bean       = random(Bean.class);         String expectJson = new Gson().toJson(bean);          when(dao.get(1)).thenReturn(bean);          Bean actual = presenter.getBean(1);          Assert.assertEquals(expectJson, new Gson().toJson(actual, Bean.class));     } }   

运行:

测试失败的场景:

@Test     public void testBean() throws Exception {         Bean   bean       = random(Bean.class);         String expectJson = new Gson().toJson(bean);          when(dao.get(1)).thenReturn(bean);          Bean actual = presenter.getBean(1);         actual.setName("我来捣乱");// 故意让单元测试出错          Assert.assertEquals(expectJson, new Gson().toJson(actual, Bean.class));     } 

运行,测试不通过(符合预计结果):

咋看没什么问题。但如果成员变量很多,这时单元测试报错呢?

@Test     public void testUser() throws Exception {         User   user       = random(User.class);         String expectJson = new Gson().toJson(user);          when(dao.getUser(1)).thenReturn(user);          User actual = presenter.getUser(1);         actual.setWeigth(10);// 错误值          Assert.assertEquals(expectJson, new Gson().toJson(actual, User.class));     }  

你看出哪里错了吗?你要把窗口滚动到右边,才看到哪个字段不一样;而且当对象比较复杂,就更难看了。怎么才能更人性化提示?

JsonUnit

笔者给大家介绍一个很强大的json比较库——Json Unit.

gradle引入:

dependencies {     compile group: 'net.javacrumbs.json-unit', name: 'json-unit', version: '1.16.0' }  

maven引入:

<dependency>     <groupId>net.javacrumbs.json-unit</groupId>     <artifactId>json-unit</artifactId>     <version>1.16.0</version> </dependency>   import static net.javacrumbs.jsonunit.JsonAssert.assertJsonEquals;  @Test public void testUser() throws Exception {     User   user       = random(User.class);     String expectJson = new Gson().toJson(user);      when(dao.getUser(1)).thenReturn(user);      User actual = presenter.getUser(1);     actual.setWeigth(10);// 错误值      assertJsonEquals(expectJson, actual); }  

运行,测试不通过(符合预期结果):

读者可以看到Different value found in node "weigth". Expected 0.005413020868182183, got 10.0.,意思节点weigth期望值0.005413020868182183,但是实际值10.0。

无论json多复杂,JsonUnit都可以显示哪个字段不同,让使用者最直观地定位问题。JsonUnit还有很多好处,前后参数可以json+对象,不要求都是json或都是对象;对比List时,可以忽略List顺序.....

DAO

public class DAO {      public List<Bean> getBeans() {         return ...; // sql、sharePreference操作等     } }  

Presenter

public class Presenter {     DAO dao;          public List<Bean> getBeans() {         List<Bean> result = dao.getBeans();          Collections.reverse(result); // 反转列表           return result;     } }  

PresenterTest

@Test     public void testList() throws Exception {         Bean bean0 = random(Bean.class);         Bean bean1 = random(Bean.class);          List<Bean> list       = Arrays.asList(bean0, bean1);         String     expectJson = new Gson().toJson(list);          when(dao.getBeans()).thenReturn(list);          List<Bean> actual = presenter.getBeans();                  Assert.assertEquals(expectJson, new Gson().toJson(actual));     }  

运行,单元测试不通过(预期结果):

对于junit来说,列表顺序不同,生成的json string不同,junit报错。对于“代码非常在意列表顺序”场景,这逻辑是正确的。但是很多时候,我们并不那么在意列表顺序。这种场景下,junit + gson就蛋疼了,但是JsonUnit可以简单地解决:

@Test     public void testList() throws Exception {         Bean bean0 = random(Bean.class);         Bean bean1 = random(Bean.class);          List<Bean> list       = Arrays.asList(bean0, bean1);         String     expectJson = new Gson().toJson(list);          when(dao.getBeans()).thenReturn(list);          List<Bean> actual = presenter.getBeans();          //        Assert.assertEquals(expectJson, new Gson().toJson(actual));          // expect是json,actual是对象,jsonUnit都没问题         assertJsonEquals(expectJson, actual, JsonAssert.when(Option.IGNORING_ARRAY_ORDER));     }  

运行单元测试,通过:

JsonUnit还有很多用法,读者可以上github看看介绍,有大量测试用例,供使用者参考。

解析json的场景

对于测试json解析的场景,JsonUnit的简介就更明显了。

public class Presenter {     public Bean parse(String json) {         return new Gson().fromJson(json, Bean.class);     } }     @Test     public void testParse() throws Exception {         String json = "{"id":1,"name":"bean"}";          Bean actual = presenter.parse(json);          assertJsonEquals(json, actual);     }  

运行,测试通过:

一个json,一个bean作为参数,都没问题;如果是Gson的话,还要把Bean转成json去比较。

小结

感觉这次谈了没多少东西,但文章很冗长,繁杂的代码挺多。唠唠叨叨地讲了一大堆,不知道读者有没看明白,本文写作顺序,就是笔者当时探索校验参数的经历。这次没什么高大上的概念,就是基础的、容易忽略的东西,在单元测试中也十分好用,希望读者好好体会。

单元测试的细节,已经讲得七七八八了。下一篇再指导一下项目使用单元测试,单元测试的系列就差不多完结。当然以后有更多心得,还会写的。

关于作者

我是键盘男。在广州生活,在互联网公司上班,猥琐文艺码农。喜欢科学、历史,玩玩投资,偶尔独自旅行。


点赞 0


本文标题:Android单元测试 - 验证函数参数、返回值的正确姿势 - 互联网
本文地址:www.itbaoliao.com/hlw/14872.html

返回网站首页

本文评论
魅族MX4发布会狂攻小米 Flyme4比MIUI6逊色许多
尽管在发布会之前,魅族科技副总裁李楠已经告诉网友,此次发布会将只有魅族MX4一款产品,但这仍阻挡不了“煤油”(魅族粉丝)的热情,发布会前一个小时,门口已经排起了十几米的长队。一个多月以前,这样的场景在不远处的国家...
日期:12-05
湖北彩铃制作也可考级
早报讯 今年起,湖北省劳动和社会保障部门推出手机彩铃职业资格证考试。彩铃制作技能分为四个级别,分别是初级移动彩铃制作员、移动彩铃制作员、高级移动彩铃制作员和移动彩铃制作师,专项技能水平对应于中华人民共和国职业资...
日期:12-04
华为最牛神机为啥叫Mate RS?官方这样解释
何刚表示,Mate RS是保时捷设计的第一款,也可能是唯一一款。因为是全新设计,所以华为也希望用一个全新的名字。而保时捷就有一辆经典跑车叫GT3RS,所以就用了Mate RS这个名字,在保时捷设计系列是非常独特的一款,可能在一个周期内只有这样一个产品。...
日期:12-14
明日之后希望谷攻略分享 希望谷剧情怎么过
明日之后希望谷攻略分享,不少玩家还不清楚希望谷剧情该怎么通关了,那下面就让小编来给大家介绍下方法,一起看看吧。...
日期:12-07
小陆毅走台《非诚勿扰》PPTV聚力见证金秀贤牵手
“小萌女”、“冰美人”、女警花、篮球高妹已经成为周末Love风景线,上周末,PPTV聚力网络独家综艺《非诚勿扰》又给观众增加了新的谈资。周六晚,金秀贤竟来到了《非诚勿扰》寻找“千颂...
日期:12-18
SofM加入SN:越南打野SofM加入SN战队
SOFM加入SN是怎么回事呢?越南打野SofM加入SN战队的原因是什么呢?今日SN正式官宣SofM的加盟,今天就为大家带来相关的介绍。...
日期:12-04
物理学家爱因斯坦手稿拍出 港媒:李嘉诚买的
当地时间23日,物理学家爱因斯坦的相对论手稿在法国巴黎拍卖,拍出价格高达1160万欧元(约合8310万人民币)。背后购买人成谜,引发外界猜测。据环球网援引多方消息,这份手稿的买家是李嘉诚。但李嘉诚基金会则表示,不予评论。这份爱因斯坦手稿...
日期:10-30
粉丝责WE成绩不利因商业活动太多 Sky微博回应
WE《英雄联盟》战队最近的境遇并不顺,不仅没有拿到粉丝心目中所期待的冠军,反而因为参加商业活动而受人指责,战队领队李晓峰在腾讯微博上发表了自己的看法,为战队的行为进行解释。事情的起因是WE《英雄联盟》俱乐部成员受到赞助商技嘉的邀请去徐州参...
日期:12-13
我国中学生常用网站:前8位都是商业网站
新华网天津3月2日电 最新发布的2009中国未成年人互联网运用状况调查报告显示,我国中学生对专门针对未成年人的网站了解并不多,经常使用的网站前8位都是商业网站。  这份调查显示,被调查中学...
日期:10-29
Pokemon Go精灵坐标位置 精灵在哪里抓
精灵宝可梦go精灵坐标位置分享,pokemon go精灵坐标在哪里,下面小编为大家带来精灵坐标攻略,一起来看吧!...
日期:11-25
嗨学网:培养“大国工匠”是职业教育品牌的使命
目前,我国经济已由高速增长阶段转向高质量发展阶段,人力资源作为世界上最宝贵的资源,作用日渐凸......
日期:05-08
京东投资成立智能城市数字科技公司
法定代表人为王楠,注册资本1000万元人民币。...
日期:12-18
LOL6.9上单艾克热度不减 克制艾克最强法典
艾克这个英雄自从开发为上单之后,从路人到职业赛场上都是非常热门的上单英雄。上单艾克是走纯防御装,“肉装”的艾克利用被动效果的恶心消耗能力,有着瞬时加速的能力,可以在战斗中进出自由,再加上大招的回血能力,让艾克在足够肉的同时有着超强的小范围机动能力。...
日期:11-22
小米MIX新功能上线:太方便了
今日,@小米支付 宣布,小米北京市政交通一卡通在小米Note2和小米MIX上线了。只要用户将MIUI系统升级到最新的稳定版和开发版,就可以在小米钱包中开通北京公交卡。...
日期:12-15
神舟十二号航天员首次天地通话 画面清晰得益于它
6月23日,是我国航天员首次进驻天和核心舱的第7天。北京空间信息传输中心任务大厅屏幕上,天和舱内摄像头传回的航天员画面中,右上角的“天链”标志格外醒目。从开始到结束,天地通话整个过程语音流畅,画面清晰。&ldquo...
日期:11-14
DNF魔皇最新加点攻略 DNF魔皇二觉86刷图加点大全
DNF魔皇最新加点攻略 DNF魔皇二觉86刷图加点大全。DNF魔皇二觉技能黑瞳、元素禁域、聚能魔炮、末日湮灭详细技能一览,最新DNF魔皇二觉86刷图加点方法攻略。...
日期:12-23
百度自建导航站hao222.com 将于近期正式推广
百度低调注册了hao222.com的网址,并用该网址进行网址导航业务。...
日期:10-14
天下网开创手机SNS新时代
【赛迪网讯】2010年全球手机用户将达到40亿,其中手机SNS已成为引导行业快速发展的重要链条之一,通过SNS发展壮大的国内外成功开发团队更是从中获益匪浅。国外知名社交网站Facebook在2...
日期:12-05
牛年春晚十大亮点!岳云鹏实力挽救口误,吴京被做成表情包太搞笑
【TechWeb】2月12日消息,昨晚,2021年春晚终于拉开帷幕,大年三十的鞭炮声没能挡住家人们看春晚银铃般的笑声,网友们也纷纷从春晚前“看了节目单一点没兴趣”转变成了“真香!”。那么牛年...
日期:09-25
轻松欣赏岁末大片 100tv VIP专区首次限免
年末是大片扎堆上映的热门档期,对于爱看电影的人来说,少花钱、多看片确实是一件矛盾 头疼的事儿。小编今天推荐的100tv高清播放器是一款视频聚合APP,聚集了各大网站的精彩视频,电影、综艺应有尽有。最给力的是,原价10元的100tv安卓版VI...
日期:12-23