重写iOS项目 浅谈iOS架构

背景

在公司写iOS项目,但是好几个月没写代码了(没写什么有意义的代码了),大概一两年前公司的一位前辈开发了一套便于快速开发的框架,我们每天就是照猫画虎,写着重复的代码,说实话这种工作...额...找个楼管大妈培训一个月,开发起来也没问题;在这样下去自己要被遗忘于江湖了,所以下了个决心重写项目...搞完后,通过老项目,苹果api,新项目对比,浅谈下iOS开发的一些心得体会。

前言

面对网上各种框架,各种编程方式,各种词汇的出现,好多人会感到不知所措,感觉要学的东西太多,学不过来了;面向过程,面向对象,面向切面,面向函数,面向协议; MVC MVVM.... pop oop vop 各种p,but...亲,莫慌(就不要抱我了),请不要拘泥于形式,我们不要做跟随者,要知道这些概念的出现都是为了解决实际问题,这些名词只是在为代码的内聚,耦合,复用,灵活...找到一个最佳的平衡点,是在解决问题的过程中总结而形成的;黑猫、白猫,能抓老鼠的就是好猫。请记住精通的目的在于应用。你的某些代码是解决某个实际问题的最佳实践,那就是最好的架构,最漂亮的代码;

我很多时候不太会按常理出牌,文章也是抱着初生牛犊不怕虎的心态写的,在我这里规则是用来打破的,一些代码的封装思想可能不是很符合你的编程习惯,但是能激起你的一些思考;特别是子view的点击事件与控制器之间的通信处理引发的一系列问题这一部分内容,可以仔细阅读,比较精彩...

客户端开发框架漫谈

说说公司老项目的框架体系:老项目是基于UITableview 和 cell 进行深度定制的,在界面上的每个UI模块都是UITableviewCell,你没有看错,每个UI模块(一个整体)都是Cell,开发中,只需要用xib描述一个cell,用一个字典指定好cell中每个UI组件对应的模型中的字段,然后会自动映射数据;搞定,收工;图解框架大概如下:

Snip20150803_1.png

框架的作者充分利用了tableView,开发快速方便,控制器的代码相对较少,作者想通过一个大牛逼viewcontroller搞定一切需求,这样做确实方便,开发者们不用动什么脑筋就能写完业务;框架在设计的过程中难点在于cell的数据映射,可变cell的高度;还有就是这个大牛逼viewcontroller的封装;
优点 : 开发快速 逻辑实现部分代码量少(cell通过xib描述) 代码逻辑比较清晰,易于阅读;
缺点 : 灵活性很差,viewcontroller封装的功能过多;不能做出好的用户体验,模式单一;严重依赖数据模型驱动,如果网络请求失败,没有数据,界面将一片空白,连一些静态数据也显示不出来;将所有的功能通过一个类全部包装,很多时候会造成小题大做,代码冗余量大;
结论 : 非常的不灵活,没有了灵活性 对客户端来说几乎是致命;如果对比一下苹果api的继承体系,不难发现上边这套框架的思想和苹果api设计思想是背离的。

简单的看一下苹果api的设计(分层封装共性功能、分支细化小功能)

UIView的继承体系:并非全部view,只是为了说明问题,画图不是很专业,海涵
在iOS当中,所有的视图都从一个叫做UIVIew的基类派生而来

Snip20150803_3.png

看上去像是一个层级关系树管理了一些矩形块。

viewController的继承体系

Snip20150803_4.png

tips: 如果你是iOS初学者,我推荐在学习过程中通过查看头文件整理出如图的继承体系,然后开始系统的学习(例如你要学习CALayer,你可以整理出CALayer和它子类的继承体系: CATransformLayer, CATextLayer, CAShapeLayer..., 然后逐个突破学习),这样当你学会了UIControl中的一个方法:- (void)addTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents; 就知道了所有继承自UIControl的view都具有该方法(举个例子); 苹果api中类似这样的图可以画出好多张,CAAnimation体系,controller体系,CALayer体系...

回到正题,在编程界,你会经常看到*** 分层 这件事,其实整个计算机学科里甚至是整个人类科学中,分层设计都是一个很重要的概念,我最早把分层当回事是在大学时期的计算机网络课程中,在网络领域中众所周知的OSI参考模型体系标准,将网络分成了7层架构的参考模型,后来又被TCP/IP参考模型分为了我们所熟知的4层网络体系架构;
分层设计一个很明显的好处是:分层可以梯度的降低问题的复杂度,简化问题,也是解决某些难题的突破口;分层后的每一层功能相对单一,实现起来容易了许多;
另一个显而易见的好处是:容易拓展功能和后期的维护,由于分层后每层的职责单一,相互直接依赖降低了,装逼的说法就是耦合度低了,但是不能说相互完全不受影响,不依赖,毕竟“耦合”的
藕断丝连*;
其他好处...


通过上边两张图,大体掌握了苹果api中的View家族的设计:UIView直接派生出3个大方向的View:

  • 能滚动的view : UIScrollView及其子类
  • 需要交互的view: UIControl及其子类
  • 只是简单的展示数据,不需要交互也不需要滚动,例如UILabel....

这样梳理 我们就很清楚苹果UIView的划分体系了,掌握起来就没那么困难了;这其中的分层方式,思想,值得我们去学习、体会、实践,毕竟我们每个人都是站在巨人的肩膀上编程的。
另一种分层方式 MVC MVVM...派系

  • 这种分层不是对某个家族进行分层,不像UIView或者Core Animation属于家族分层(说白了就是: "谁应该是谁的儿子"的问题),
  • MVC MVVM 是对整个项目代码组织结构的划分,它的规则是为了指定你的代码应该写在哪个文件中,你应该如何处理项目中代码的整体结构,这更像是三国时期的魏蜀吴,地盘到底该怎么分?
  • 使用mvc开发iOS程序的一个问题是,model层太单薄(这货几乎什么都没干),controller中堆了大量的逻辑,导致代码成千上万行...,于是就考虑能不能model层直接承担UI特定的接口和属性(个人愚见),于是就整出了:Model-View-ViewModel 的概念。我没用过,所以理解有限,但是可以肯定的是MVVM是代码如何组织和结构化的另一种形式,至于适不适合iOS开发,怎么在iOS开发中使用,又是另一个大的话题了。

想明白这些东西后,如你所见,框架没什么神秘高深的,就是如何组织代码,整体和局部如何分层,然后就开始自己的代码封装之路;

搭建项目,先搞定网络层: AFN + MJExtension的最佳实践

要不要对AFN进行封装?
AFN本来用起来已经很方便了,如果你的项目规模很小,接口十几到二十个,页面也没多少,这样的话,没必要了。如果项目规模较大,有几十个控制器,每个控制器中都有个AFHTTPRequestOperationManager,如果AFN升级或者想要换其他的网络库,或者做一些统一处理,那么工作量就来了,这种情况就很有必要对AFN进行二次封装。

封装思路

  • 封装AFN,即屏蔽AFN在你的代码的侵入性,所以要将AFN统一处理到一个类中,也就是一个类 has a AFN;
  • 切割url,针对不同的host进行切割url,实际开发中,接口可能来自不同的host,即使是单一host请求,也得经常在demo 和 dev、online之间切换host,最终可以将host主机地址处理到plist文件中,方便切换,由此,我们额外需要一个解析plist文件的工具类;
  • AFN请求回来的数据默认都是字典,我们不想面向字典开发,希望网络层在请求完数据后直接转成模型,引入MJExtension(字典转模型框架),用模型是面向对象编程的一种思想,用模型可以用点语法,避免了字典中key的字符串拼写错误,必要时请求参数也可以用模型,开发中有时候会因为在字典中插入某个空值,导致应用程序崩溃,如果我们先用模型写好请求参数,然后通过MJExtension在将模型转为字典,就能有效避免这个问题,对于模型中为nil的字段,MJExtension会自动过滤,前提是字段是非基本数据类型;
  • AFN请求回调的block有个成功的block和失败的block,两个block代码有些繁琐,事实上我们仅关心成功后的有效数据和失败后的错误信息,所以将两个block合并在一个block中,通过两个参数分别接收有效数据和错误信息,并且这个参数的值是否存在必定是互斥的,即如果有效数据有值,错误信息必定是nil,反之亦然;

这部分内容较多,这里简单演示下封装后使用的精简程度,具体说明和demo可以前往github ,有详细说明,欢迎交流,共同进步。
业务类接口的实现

// .h文件
+(void)getDemoDataWithResponseHandler:(responseHandler)Handler;

// .m实现文件  
+(void)getDemoDataWithResponseHandler:(responseHandler)Handler
{
    [self getWithUrl:demoDataUrl param:nil resultClass:[DemoAllData class] responseBlock:Handler];
}

控制器中的使用

-(void)loadNetData
{
    [AppDemoServices getDemoDataWithResponseHandler:^(DemoAllData *dataObj, NSError *error) {
        if (dataObj) {
            [self.datas removeAllObjects];
            [self.datas addObjectsFromArray:dataObj.data];
            [self.tableView reloadData];
        } else {
            NSLog(@"网络请求发生错误");
        }
    }];
}  

说明:笔者工作经验并不是很丰富,文章也是学习成长的一些总结和感受,如果觉得觉得水准太差,还请多多指教;


接下来:子view的点击事件与控制器之间的通信处理引发的一系列问题

Snip20150806_11.png

图中的灰色背景的View内部有两个按钮;虽然简单但是能说明一些问题;

对于已经添加在控制器view中的视图,如果还要对其引用(使用property),最好用weak 弱指针引用;
@property (weak, nonatomic) UIView *lightGrayView;
因为视图已经加在了控制器的view中,控制器的view已经对其强引用,控制器又被导航控制器...最后application在管理着他们,所以你在引用时没必要用strong引用;

小插曲播完,再回到正题。图中的灰色view,在开发中很常见,即使比这个复杂许多的view,分析切割后,缩影就是这样;对于创建这个灰色view的,我个人习惯是单独封装到一个view类中,除非这个view特别简单;单独将封装灰色view,又涉及到前边说的代码分层问题。

  • 基于MVC,在控制器中只是对这个view的填充数据,有时候可能会处理交互;
  • 代码层次清晰,封装后可以提高复用率,便于维护;
  • 能大规模减少控制器的代码行数;

封装后面临另外一个问题,就是事件交互,由于控制器并不涉及灰色view内部按钮的创建代码,所以不能直接监听到灰色view内部按钮的点击事件,需要传递事件;
iOS中不同对象间事件传递方式有3种:block 代理 通知; 其实这里称为代理个人感觉并不是非常合适,代理是一种设计模式,很多语言开发中都会用到,比较广义;iOS中的对象间交互多为"数据传递" 和 "事件传递",或者叫"数据源"和"委托",通过tableview的datasource和delegate可以体会到,为了方便交流很多人都称为代理,而实现这两种模式的基石就是协议; 貌似整个cocoa框架都是基于协议建立起来的;所以我们自己写的时候也尽量多用协议来完成通信,能很好的和cocoa代码想融合;
如果写协议方法和使用委托就不多说了,需要注意的是,委托对象(delegate)要用weak来解除保留环;

// 声明委托对象属性
@property (weak, nonatomic) id<YKViewClickProtcol> clickDelegate;  

对于调用委托方法,通常都是这么写的:

if ([self.clickDelegate respondsToSelector:@selector(goActionWithName:withObject:withSender:)]) {
        [self.clickDelegate goActionWithName:strName withObject:obj withSender:sender];
    };

这里如果委托对象为nil,给空对象发送消息,if条件为false,所以不会执行条件体,并不会导致程序崩溃,所以不需要在条件中先判断委托对象是否存在;
问题在于,如果定义的协议中方法较多,且多为可选实现,那么会写出一大堆这样的代码,而频繁的执行if判断除了第一次有用,后边的if检查基本上是多余的,因为一个委托对象一旦指定了几乎不会改变;所以这里可以缓存检查结果,来提升一些执行效率;

tips--缓存: 可以在delegate的set方法中实现缓存(缓存委托对象是否响应某个方法的结果),如果委托对象不发生改变,set方法只会执行一次,如果改了代理对象,也肯定会重新调用set进行赋值,所以在delegate的setter中检查协议方法和并且缓存检查结果自然是不错的方案;
关于缓存最好方式是通过二进制位,程序执行效率和资源消耗本来就是一个权衡问题,想提升程序执行效率,必然会对内存产生一定的消耗,所以很多情况下我们要权衡利弊;这里委托对象是否响应某个方法的结果只有两种情况 "响应" 和 “未响应”;一个二进制位刚好;定义这个二进制位,可以使用c语言中的 "位域";
struct {
unsigned int delegateMethod1: 1;
...
}
协议中的每个方法对应一个二进制位,进行缓存;

这里的缓存并没有对执行效率有明显的提升,现在手机的硬件能力都有很大的冗余,如果过分在乎性能这事,对开发人员来说要增加很多额外的工作量;

相信未来可能会通过强大硬件冗余来弥补的性能问题,让开发人员专心做好业务,而不用担心性能问题;

我有这样的观点的原因是:不管怎样,科技的进步都会以人为本,都是想给人类提供方便(原谅人类就是这么自私),开发人员当然也是人了,所以产生上述的观点...

这部分内容比较多,稍微缓缓...

好,继续回想一下上面的图,灰色的view中有两个按钮,协议方法为了区分点了哪个按钮,需要一个参数记录点击了哪一个按钮,区分这个可以通过tag;

-(void)grayView:(LigthGrayView *)grayView didClickAtIndex:(NSUInteger)index;

为了提高代码的可读性,我们通常要不直接传递控件的tag,而是定义枚举,然后将枚举绑定到控件的tag上,用枚举来消除魔法数字,增强代码的可读性;多数情况下你最好这么干,因为苹果的api中经常这么干,如果你打算用枚举,请一定注意命名,命名不好的枚举用起来让人很不舒服,你可不要小看命名这件事,我记得有位计算机科学家说过:“在计算机科学中只有两件难事:缓存和命名”,关于如何定义枚举和命名这里就不再赘述,实在不行,看看苹果在它的api中是如何使用和命名的,模仿它就不会有太大问题;


使用枚举消除魔法数字后,似乎代码很漂亮,很完美,符合规范;心里一阵开心‘我写的代码怎么就这么规范呢?’,就这样我写了一段时间的代码后发现一件烦人的事情:

类似这个情况太多的时候,代码中定义了大量的协议,用了大量的代理,而为了可读性我又写了大量的枚举,有时候一个控制器遵循了若干协议,每个协议都有需要实现的方法,代码量就多了,而且结构性不强,方法分散,很多时候回头review时,忘了某个方法到底是哪个协议里的;时间久了,发现这其实是一件很没有技术含量的体力活;而且多人开发的时候,有些开发人员并不会对枚举命名严格要求,很多时候看到枚举你还是不知道他是什么意思,这是个现实问题...面对现实问题,我们要灵活的处理。

于是就思考能不能不用每次都写协议,不用每次都写self.clickDelegate respondsToSelector someSel,毕竟我们处理的只是将点击事件传给控制器;
我的解决办法

  • 定义一个公用协议,控制器监听内部view的点击事件都通过公用协议的方法;
// view点击事件的协议
@protocol YKViewClickProtcol <NSObject>
@optional
-(void)goActionWithName:(NSString*)strName withObject:(id)obj withSender:(id)sender;
@end

第一个参数strName用来区分点击事件来自哪个子View,这个字符串可以使用类名传递的,因为类名在一个项目中是独一无二的,也不用动脑筋考虑命名问题;
第二个参数obj,有时候需要将数据传给控制器,方便做一些处理;
第三个参数sender用来区分子view中多个点击事件;
其实这个方法的定义不符合规范,如果看苹果的代理方法,一条原则是代理方法的第一个参数是将源对象本身传出去,这可能会是个问题;

  • 简单的自定义一个UIView作为基础view;
@interface YKView : UIView
@property (weak, nonatomic) id<YKViewClickProtcol> clickDelegate;
-(void)viewActionWithName:(NSString*)strName withObject:(id)obj withSender:(id)sender;
@end

YKView中定义一个和代理方法很像的方法,这个方法需要暴露在.h文件,以供子类使用;
viewActionWithName... 方法的实现

-(void)viewActionWithName:(NSString*)strName withObject:(id)obj withSender:(id)sender
{
    if ([self.clickDelegate respondsToSelector:@selector(goActionWithName:withObject:withSender:)]) {
        [self.clickDelegate goActionWithName:strName withObject:obj withSender:sender];
    };
}

至此self.clickDelegate respondsToSelector ...在整个项目你只需要写一遍即可;还有一个好处是控制器的代码结构会很强,所有的子view点击事件都在同一个地方处理-(void)goActionWithName:(NSString*)strName withObject:(id)obj withSender:(id)sender;
我也尝试过更为极端的方式

// 获取在你眼前的控制器
+(UIViewController *)getLastActivityController
{
    UIViewController *vController = [UIApplication sharedApplication].keyWindow.rootViewController;
    if ([vController isKindOfClass:[YKTabBarController class]]) {
        YKTabBarController *tabVc = (YKTabBarController *)vController;
        YKNavigationController *navVc = (YKNavigationController *)tabVc.selectedViewController;
        return navVc.visibleViewController;
    }
    
    if ([vController isKindOfClass:[YKNavigationController class]]) {
        YKNavigationController *navVc = (YKNavigationController *)vController;
        return navVc.visibleViewController;
    }
    return vController;
}

这个方法是用来获取最上层(或者叫正在活动)的控制器,然后强行指定clickDelegate为此控制器,因为理论上讲你能点到的view必定在最外层的控制器中,这么干能少写一条指定代理的赋值语句;使用中也没有出现错误,但是有点极端了(点到为止即可),并不是没次的点击事件都需要传递给控制器处理;


例子

Snip20150808_4.png

假设TestOneViewTestTwoView中的按钮点击事件都需要传递给控制器;

  • TestOneViewTestTwoView继承YKView,传递按钮点击事件只需要一行代码;
- (IBAction)btnClick:(UIButton *)sender {
    [self viewActionWithName:NSStringFromClass([self class]) withObject:nil withSender:nil];
}
  • 可能你断片了,把YKView中的处理代码回忆一下。
-(void)viewActionWithName:(NSString*)strName withObject:(id)obj withSender:(id)sender
{
    if ([self.clickDelegate respondsToSelector:@selector(goActionWithName:withObject:withSender:)]) {
        [self.clickDelegate goActionWithName:strName withObject:obj withSender:sender];
    };
}
  1. ViewController处理代码
- (void)viewDidLoad {
    [super viewDidLoad];

    CGFloat viewW = self.view.frame.size.width;

    TestOneView *oneV = [TestOneView oneView];
    oneV.clickDelegate = self;
    oneV.frame = CGRectMake(0, 74, viewW, 200);

    TestTwoView *twoV = [TestTwoView twoView];
    twoV.frame = CGRectMake(0, CGRectGetMaxY(oneV.frame) + 10, viewW, 200);
    twoV.clickDelegate = self;

    [self.view addSubview:oneV];
    [self.view addSubview:twoV];
}
#pragma mark- 子view点击事件都在这里处理 -
-(void)goActionWithName:(NSString *)strName withObject:(id)obj withSender:(id)sender
{
    if ([strName isEqualToString:@"TestOneView"]) { 
        NSLog(@"oneView--didClick innerView");
    } else if ([strName isEqualToString:@"TestTwoView"]) {
        NSLog(@"TwoTwoView--didClick innerView");
    }
}

示例只是为了说明问题,如果TestOneView中又多个事件交互需要传递,可以通过参数sender区分,需要传递其他数据可以通过obj

  • 代码量少,能节省不少时间,不用总是定义协议,检测代理,定义枚举,一些没技术含量的体力活;
  • 事件处理在控制器中统一在一个方法,代码不在松散,review的时候能快速定位代码;
    当然可以对ViewController进行封装方便开发,但是该结束本篇文章了;

后记:

篇幅已然有点长了,读完文章,并不能像其他文章那样看着demo快速写出图片折叠,绚丽的动画,或者写出个二叉树来;但是可能你的引发一些思考,...不对,或许这篇文章毛也没讲出来;
那我就推荐几本对我有很大帮助的书吧;

  • 《编写高质量iOS与OS X的代码的52个有效方法》: 会让你感觉自己之前不是在写代码;
  • 《核心动画进阶》英文名: ios core animation advanced techniques:仔细研读,能写出好多装逼动画还有图片的处理问题;
  • 《iOS进阶教程》- 唐巧写的,估计你也买了;
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,391评论 25 707
  • 吾家小女初长成, 始下庖厨表孝心。 贤内治家将路引, 玉盏元宵报亲恩。
    荆楚日月阅读 172评论 0 0
  • 方新细细端详着刚进门的女子,举手投足都是戏,眼角眉梢都带情,虽是一袭杏色裙衫,却显出撩人之色,看上去身姿婀娜,美中...
    最可阅读 487评论 2 1