猿题库 iOS 客户端架构设计


Lancy's Blog

Blog

Archives

About MeTwitterWeiboGitHubRSS

猿题库 iOS 客户端架构设计

猿题库是一个拥有数千万用户的创业公司,从2013年题库项目起步到2015年,团队保持了极高的生产效率,使我们的产品完成了五个大版本和数十个小版本的高速迭代。在如此快速的开发过程中,如何保证代码的质量,降低后期维护的成本,以及为项目越来越快的版本迭代速度提供支持,成为了我们关注的重要问题。这篇文章将阐明我们在猿题库 iOS 客户端的架构设计。

MVC

MVC,Model-View-Controller,我们从这个古老而经典的设计模式入手。采用 MVC 这个架构的最大的优点在于其概念简单,易于理解,几乎任何一个程序员都会有所了解,几乎每一所计算机院校都教过相关的知识。而在 iOS 客户端开发中,MVC 作为官方推荐的主流架构,不但 SDK 已经为我们实现好了 UIView、UIViewController 等相关的组件,更是有大量的文档和范例供我们参考学习,可以说是一种非常通用而成熟的架构设计。

但 MVC 也有他的坏处。由于 MVC 的概念过于简单朴素,已经越来越难以适应如今客户端的需求,大量的代码逻辑在 MVC 中并没有定义得很清楚究竟应该放在什么地方,导致他们很容易就会堆积在 Controller 里,成为了人们所说的 Massive View Controller。

MVVM

MVVM,Model-View-ViewModel,一个从 MVC 模式中进化而来的设计模式,最早于2005年被微软的 WPF 和 Silverlight 的架构师 John Gossman 提出。在 iOS 开发中实践 MVVM 的话,通常会把大量原来放在 ViewController 里的视图逻辑和数据逻辑移到 ViewModel 里,从而有效的减轻了 ViewController 的负担。另外通过分离出来的 ViewModel 获得了更好的测试性,我们可以针对 ViewModel 来测试,解决了界面元素难于测试的问题。MVVM 通常还会和一个强大的绑定机制一同工作,一旦 ViewModel 所对应的 Model 发生变化时,ViewModel 的属性也会发生变化,而相对应的 View 也随即产生变化。

同样的,MVVM 也有他的缺点:

一个首要的缺点是,MVVM 的学习成本和开发成本都很高。MVVM 是一个年轻的设计模式,大多数人对他的了解都不如 MVC 熟悉,基于绑定机制来进行编程需要一定的学习才能较好的上手。同时在 iOS 客户端开发中,并没有现成的绑定机制可以使用,要么使用 KVO,要么引入类似 ReactiveCocoa 这样的第三方库,使得学习成本和开发成本进一步提高。

另一个缺点是,数据绑定使 Debug 变得更难了。数据绑定使程序异常能快速的传递到其他位置,在界面上发现的 Bug 有可能是由 ViewModel 造成的,也有可能是由 Model 层造成的,传递链越长,对 Bug 的定位就越困难。

同时还必须指出的是,在传统的 MVVM 架构中,ViewModel 依然承载的大量的逻辑,包括业务逻辑,界面逻辑,数据存储和网络相关,使得 ViewModel 仍然有可能变得和 MVC 中 ViewController 一样臃肿。

在两种架构中权衡而产生的架构

两种架构的优点都想要,缺点又都想避开,我们在两种架构中权衡了他们的优缺点,设计出了一个新的架构,起了一个名字叫:MVVM without Binding with DataController,架构图如下:

ViewModel

先来看右边视图相关的部分,传统的 MVC 当中 ViewController 中有大量的数据展示和样式定制的逻辑,我们引入 MVVM 中 ViewModel 的概念,将这部分视图逻辑移到了 ViewModel 当中。在这个设计中,每一个 View 都会有一个对应的 ViewModel,其包含了这个 View 数据展示和样式定制所需要的所有数据。同时,我们不引入双向绑定机制或者观察机制,而是通过传统的代理回调或是通知来将 UI 事件传递给外界。而 ViewController 只需要生成一个 ViewModel 并把这个装配给对应的 View,并接受相应的 UI 事件即可。

这样做有几个好处:首先是 View 的完全解耦合,对于 View 来说,只需要确定好相应的 ViewModel 和 UI 事件的回调接口即可与 Model 层完全隔离;而 ViewController 可以避免与 View 的具体表现打交道,这部分职责被转交给了 ViewModel,有效的减轻了 ViewController 的负担;同时我们弃用了传统绑定机制,使用了传统的易于理解的回调机制来传递 UI 事件,降低了学习成本,同时使得数据的流入和流出变得易于观察和控制,降低了维护了调适的成本。

DataController

接下来我们关注 Model 和 VC 之间的关系。如之前提到,在传统的 MVVM 中,ViewModel 接管了 ViewController 的大部分职责,包括数据获取,处理,加工等等,导致其很有可能变得臃肿。我们将这部分逻辑抽离出来,引入一个新的部件,DataController。

ViewController 可以向 DataController 请求获取或是操作数据,也可以将一些事件传递给 DataController,这些事件可以是 UI 事件触发的。DataController 在收到这些请求后,再向 Model 层获取或是更新数据,最后再将得到的数据加工成 ViewController 最终需要的数据返回。

这样做之后,使得数据相关的逻辑解耦合,数据的获取、修改、加工都放在 Data Controller 中处理,View Controller 不关心数据如何获得,如何处理,Data Controller 也不关心界面如何展示,如何交互。同时 Data Controller 因为完全和界面无关,所以可以有更好的测试性和复用性。

DataController 层和 Model 层之间的界限并不是僵硬的,但需要保证每一个 ViewController 都有一个对应的 DataController。Data Controller 更强调的是其作为业务逻辑对外的接口。而在 DataController 中调用更底层的 Model 层逻辑是我们推荐的编程范式,例如数据加工层,网络层,持久层等。

在后面的例子中,我们会更详细的讲解 DataController 的实现细节。

Show me the code

我们以猿题库主页为例,展示我们是如何使用应用这个架构的。

主页有几个部分组成,最上面的小猴子 Banner 页,用于滚动展示一些活动信息;中间有一个用户名字的页面,用于展示用户信息和答题情况以及一些心灵鸡汤;最底下的这部分是一个课目选择页面,展示了用户开启的科目入口,在更多选项里面可以进一步配置这些科目入口。接下来我们会以科目页面(SubjectView)为例展示一些细节。

ViewController

我们会给每一个 ViewController 都创建一个对应的 DataController。 例如我们给主页建一个类起名叫APEHomePraticeViewController,同时他会有一个对应的 DataController 起名叫APEHomePraticeDataController。同时我们把页面拆分为几个部分,每个部分有一个相对应的 SubView。代码如下:

1234567891011

@interface APEHomePracticeViewController () @property (nonatomic, strong, nullable) UIScrollView *contentView;@property (nonatomic, strong, nullable) APEHomePracticeBannerView *bannerView;@property (nonatomic, strong, nullable) APEHomePracticeActivityView *activityView;@property (nonatomic, strong, nullable) APEHomePracticeSubjectsView *subjectsView;@property (nonatomic, strong, nullable) APEHomePracticeDataController *dataController;@end

在viewDidLoad的时候,初始化好各个 SubView,并设置好布局:

1234567891011121314

- (void)setupContentView {self.contentView = [[UIScrollView alloc] init];[self.view addSubview:self.contentView];self.bannerView = [[APEHomePracticeBannerView alloc] init];self.activityView = [[APEHomePracticeActivityView alloc] init];self.subjectsView = [[APEHomePracticeSubjectsView alloc] init];self.subjectsView.delegate = self;[self.contentView addSubview:self.bannerView];[self.contentView addSubview:self.activityView];[self.contentView addSubview:self.subjectsView];// Layout Views ...}

接下来,ViewController 会向 DataController 请求 Subject 相关的数据,并在请求完成后,用获得的数据生成 ViewModel,将其装配给 SubjectView,完成界面渲染,代码如下:

123456789101112

- (void)fetchSubjectData {[self.dataController requestSubjectDataWithCallback:^(NSError *error) {if (error == nil) {[self renderSubjectView];}}];}- (void)renderSubjectView {APEHomePracticeSubjectsViewModel *viewModel =[APEHomePracticeSubjectsViewModel viewModelWithSubjects:self.dataController.openSubjects];[self.subjectsView bindDataWithViewModel:viewModel];}

数据结构

为了更好的演示,我们接下来要介绍一下 Subject 相关的数据结构:

APESubject是科目的资源结构,包含了 Subject 的 id 和 name 等资源属性,这部分属性是用户无关的;APEUserSubject是用户的科目信息,包含了用户是否打开某个学科的属性。

123456789101112131415

@interface APESubject : NSObject@property (nonatomic, strong, nullable) NSNumber *id;@property (nonatomic, strong, nullable) NSString *name;@end@interface APEUserSubject : NSObject@property (nonatomic, strong, nullable) NSNumber *id;@property (nonatomic, strong, nullable) NSNumber *updatedTime;///  On or Off@property (nonatomic) APEUserSubjectStatus status;@end

DataController

如我们之前所说,每一个 ViewController 都会有一个对应的 DataController,这一类 DataController 的主要职责是处理这个页面上的所有数据相关的逻辑,我们称其为 View Related Data Controller。

12345678

// APEHomePracticeDataController.h@interface APEHomePracticeDataController : APEBaseDataController// 1@property (nonatomic, strong, nonnull, readonly) NSArray *openSubjects;// 2- (void)requestSubjectDataWithCallback:(nonnull APECompletionCallback)callback;@end

上面的这个代码

我们定义了一个界面最终需要的数据的 property,这里是openSubjects,这个 property 会存储用户打开的科目列表,他的类型是APESubject。

我们还会定义一个接口来请求 openSubject 数据。

DataController 这一层是一个灵活性很高的部件,一个 DataController 可以复用更小的 DataController,这一类更小的 DataController 通常只会包含纯粹的或是更抽象的 Model 相关的逻辑,例如网络请求,数据库请求,或是数据加工等。我们称这一类 DataController 为 Model Related Data Controller。

Model Related Data Controller 通常会为上层提供正交的数据:

12345678910111213141516171819202122

// APEHomePracticeDataController.m@interface APEHomePracticeDataController ()@property (nonatomic, strong, nonnull) APESubjectDataController *subjectDataController;@end@implementation APEHomePracticeDataController- (void)requestSubjectDataWithCallback:(nonnull APECompletionCallback)callback {APEDataCallback dataCallback = ^(NSError *error, id data) {callback(error);};[self.subjectDataController requestAllSubjectsWithCallback:dataCallback];[self.subjectDataController requestUserSubjectsWithCallback:dataCallback];}- (nonnull NSArray *)openSubjects {return self.subjectDataController.openSubjectsWithCurrentPhase ?: @[];}@end

在我们的APEHomePraticeDataController的实现中,就包含了一个APESubjectDataController,这个subjectDataController会负责请求 All Subjects 和 User Subjects,并将其加工成上层所最终需要的 Open Subjects。(备注:这个例子里面的 callback 会回调多次是猿题库产品的需求,如有需要,可在这一层控制请求都完成后再调用上层回调)

事实上,Model Related Data Controller 可以一般性的认为就是大家经常在写的 Model 层代码,例如 UserAgent,UserService,PostService 之类的服务。之后读者若想重构就项目成这个架构,大可以不必纠结于形式,直接在 DataController 里调用旧有代码的逻辑即可,如图下面这样的行为都是允许的:

ViewModel

每一个 View 都会有一个对应的 ViewModel,这个 ViewModel 会包含展示这个 View 所需要的所有数据。

我们会使用工厂方法来创建 View Model,例如这个例子里,Subject View Model 不需要关心传递给他是什么样的 Subject,所有的课目或者只是用户开启的科目。

1234567891011

@interface APEHomePracticeSubjectsViewModel : NSObject@property (nonatomic, strong, nonnull) NSArray*cellViewModels;@property (nonatomic, strong, nonnull) UIColor *backgroundColor;+ (nonnull APEHomePracticeSubjectsViewModel *)viewModelWithSubjects:(nonnull NSArray*)subjects;@end

ViewModel 可以包含更小的 ViewModel,就像 View 可以有 SubView 一样。SubjectView 的内部是由一个UICollectionView实现的,所以我们也给了对应的 Cell 设计了一个 ViewModel。

需要额外注意的是,ViewModel 一般来说会包含的显示界面所需要的所有元素,但粒度是可以控制。一般来说,我们只把会因为业务变化而变化的部分设为 ViewModel 的一部分,例如这里的 titleColor 和 backgroundColor 会因为主题不同而变化,但字体的大小(titleFont)却是不会变的,所以不需要事无巨细的都加到 ViewModel 里。

12345678910111213

@interface APEHomePracticeSubjectsCollectionCellViewModel : NSObject@property (nonatomic, strong, nonnull) UIImage *image;@property (nonatomic, strong, nonnull) UIImage *highlightedImage;@property (nonatomic, strong, nonnull) NSString *title;@property (nonatomic, strong, nonnull) UIColor *titleColor;@property (nonatomic, strong, nonnull) UIColor *backgroundColor;+ (nonnull APEHomePracticeSubjectsCollectionCellViewModel *)viewModelWithSubject:(nonnullAPESubject *)subject;+ (nonnull APEHomePracticeSubjectsCollectionCellViewModel *)viewModelForMore;@end

View

View 只需要定义好装配 ViewModel 的接口和定义好 UI 回调事件即可:

123456789101112131415

@protocol APEHomePracticeSubjectsViewDelegate - (void)homePracticeSubjectsView:(nonnull APEHomePracticeSubjectsView *)subjectViewdidPressItemAtIndex:(NSInteger)index;@end@interface APEHomePracticeSubjectsView : UIView@property (nonatomic, strong, nullable, readonly) APEHomePracticeSubjectsViewModel *viewModel;@property (nonatomic, weak, nullable) id delegate;- (void)bindDataWithViewModel:(nonnull APEHomePracticeSubjectsViewModel *)viewModel;@end

渲染界面的时候,完全依靠 ViewModel 进行,包括 View 的 SubView 也会使用 ViewModel 里面的子 ViewModel 渲染。

1234567891011121314151617

- (void)bindDataWithViewModel:(nonnull APEHomePracticeSubjectsViewModel *)viewModel {self.viewModel = viewModel;self.backgroundColor = viewModel.backgroundColor;[self.collectionView reloadData];[self setNeedsUpdateConstraints];}- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {APEHomePracticeSubjectsCollectionViewCell *cell = [collectionViewdequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];if (0 <= indexPath.row && indexPath.row < self.viewModel.cellViewModels.count) {APEHomePracticeSubjectsCollectionCellViewModel *vm =self.viewModel.cellViewModels[indexPath.row];[cell bindDataWithViewModel:vm];}return cell;}

至此,我们就完成了所有的步骤。我们回过头再看一下 ViewController 的职责就回变的非常简单,装配好 View,向 DataController 请求数据,装配 ViewModel,配置给 View,接收 View 的UI事,一切复杂的操作都能够的代理出去。

总结

优点

通过上面的例子我们可以看到,这个架构有几个优点:

层次清晰,职责明确:和界面有关的逻辑完全划到 ViewModel 和 View 一遍,其中 ViewModel 负责界面相关逻辑,View 负责绘制;Data Controller 负责页面相关的数据逻辑,而 Model 还是负责纯粹的数据层逻辑。 ViewController 仅仅只是充当简单的胶水作用。

耦合度低,测试性高:除开 ViewController 外,各个部件可以说是完全解耦合的,各个部分也是可以完全独立测试的。同一个功能,可以分别由不同的开发人员分别进行开发界面和逻辑,只需要确立好接口即可。

复用性高:解耦合带来的额外好处就是复用性高,例如同一个View,只需要多一个工厂方法生成 ViewModel,就可以直接复用。数据逻辑代码不放在 ViewController 层也可以更方便的复用。

学习成本低: 本质上来说,这个架构属于对 MVC 的优化,主要在于解决 Massive View Controller 问题,把原本属于 View Controller 的职责根据界面和逻辑部分相应的拆到 ViewModel 和 DataController 当中,所以是一个非常易于理解的架构设计,即使是新手也可以很快上手。

开发成本低: 完全不需要引入任何第三方库就可以进行开发,也避免了因为 MVVM 维护成本高的问题。

实施性高,重构成本低:可以在 MVC 架构上逐步重构的架构,不需要整体重写,是一种和 MVC 兼容的设计。

缺点

不可否认的是,这个设计也有其相应的缺点,由于其把传统 MVVM 里面的 VM 拆成两部分,会照成下面的一些情况:

当页面的交互逻辑非常多时,需要频繁的在 DC-VC-VM 里来回传递信息,造成了大量胶水代码。

另外,由于在传统的 MVVM 中 VM 原本是一体的,一些复杂的交互本来可以在 VM 中直接完成测试,如今却需要同时使用 DC 和 VM 并附上一些胶水代码才能进行测试。

没有了 Binding,代码写起来会更费劲一点(仁者见仁,智者见智)。

后记

MVVM 是一个很棒的架构,私底下我也会用其来做一些个人项目,但在公司项目里,我会更慎重的考虑个中利弊。我做这个设计的时候,心仪 MVVM 的种种好处,又忌惮于它的种种坏处,再考虑到团队的开发和维护成本,所以最终设计成了如今这样。

个人认为,好的架构设计的都是和团队以及业务场景息息相关的。我们这套架构帮助我们解决了 ViewController 代码堆积的问题,也带来了更清晰明了的代码层级和模块职责,同时没有引入过多的复杂性。希望大家也能充分理解这套架构的适用场景,在自己的 APP 架构设计中有所借鉴。

Lancy

2015.12.30

Jan 6th, 2016

iOS

32 Comments

Variable Argument Lists

Variable argument lists 使用方法

可变参数函数(Variadic Function),即是指一个可以接受可变数量的参数的函数。在C语言中,对该特性的支持,即是通过可变参数列表(Variable Argument list)来实现的,其定义在stdarg.h头文件。(若使用C++则在cstdarg头文件)。

以如下C代码为例说明,该函数接受可变数量的整数作为参数,求和:

1234567891011121314151617181920

intaddemUp(intfirstNum,...){// 1. 参数后面添加省略号...va_listargs;// 2. 创建一个va_list类型的变量intsum=firstNum;intnumber;va_start(args,firstNum);// 3. 初始化va_list,此时va_list指向firstNum之后的第一个参数while(1){number=va_arg(args,int);// 4. 获取当前指向的参数的值,并移动到下一个参数sum+=number;if(number==0){// 用0表示结束break;}}va_end(args);// 5. 清理returnsum;}// 调用sum=addemUp(1,2,3,4,5,0);// sum = 15

要创建一个可变参数函数,需要把一个省略号(…)放在函数的参数列表后面。

接着需要声明一个一个va_list类型的变量,这个va_list类型的变量类似于一个指向参数的指针。

接着我们调用va_start()并传入函数的最后一个声明的参数的变量名,来使得va_list变量指向第一个附加的参数。

接着我们调用va_arg()并传入我们期待的参数类型,程序就会返回与该类型匹配数量的字节(即参数的值),并且移动va_list指向下一个参数。之后不断的调用va_arg(),获得更多的参数的值,直到完成整个参数处理的过程。

最后调用va_end()来进行清理。

variable argument lists 的内部机制

如我们之前所说,当我们调用va_start()并将va_list和函数最后定义的参数传入时,实际上是将va_list内在的一个指针指向函数调用栈 (call stack)中参数所在的区域的一端,每一次我们调用va_arg(),其都会根据提供的类型,返回当前指针所指向的地址开始对应的字节数的数据,即参数的值,并移动指针相应字节数的距离。我们传给va_arg()的类型,即是其用来判定需要取得得数据的大小,以及指针需要移动的距离。如图描述了这个过程:

事实上,这是一个很危险的事情,你总是需要提供正确的类型来让va_arg()正确执行,而且va_arg()并不知道何时停止,你需要提供一个标记或一个参数的总数来停止va_arg()继续执行。若你提供了不正确的类型,或者没有在该停止的时候停止,你将会获得不可预测的值,并且很有可能导致程序崩溃。

解决方案

一般而言,为了确保参数的获取正确进行,有如下两种解决方案:

Format string

如C语言中的printf,Cocoa中的NSLog,[NSString stringWithFormat:]就是使用了Format String的解决方案。通常,该函数的第一个参数既为一个format string,函数内部实现会扫描这个format string,来确定之后接着的可变参数的数量和类型。例如:

1

NSString*str=[NSStringstringWithFormat:@"int %d, str %@, float %g",123,@"ok",123.4];

这里使用了%作为转义符,其后跟着的d代表int,@代表id,g代表float/double,这表示后面必须有三个参数,其类型必须与format string所指定的一致。

如之前所说,提供的参数的数量或者类型若与提供的format string不一致,则会发生不可预知的问题。而在运行的时候,我们没有任何的办法去保证其正确性,幸运的是编译器提供了一些方法,能让我们在编译的时候做一些检查:

gcc中定义了__attribute__((format))来标示一个可变参函数使用了format string,从而在编译时对其进行检查。其定义为format (archetype, string-index, first-to-check),其中archetype代表format string的类型,它可以是printf,scanf,strftime或者strfmon,Cocoa开发者还可以使用__NSString__来指定其使用和[NSString stringWithFormat:]与NSLog()一致的format string规则。string-index代表format string是第几个参数,first-to-check则代表了可变参数列表从第几个参数开始。示例:

12345

// 第一个参数是format,第二个参数起是可变参数列表,format的格式规则与printf一致voidcustomPrintf(constchar*format,...)__attribute__((format(printf,1,2)));// 使用的时候,若format和参数不符,则会报warningcustomPrintf("what? %d",1.2,2);

Cocoa开发者可以使用NS_FORMAT_FUNCTION(F,A)宏来替代__atribute__format,F和A即对应string-index和first-to-check,事实上,他的实现类似于:

1

#define NS_FORMAT_FUNCTION(F,A) __attribute__((format(__NSString__, F, A)))

示例如下:

1

FOUNDATION_EXPORTvoidNSLog(NSString*format,...)NS_FORMAT_FUNCTION(1,2);

Sentinel value

哨兵值是另一种可变参数列表所常用的方案,如前一节我们的示例代码,即是使用了数字0作为哨兵值。当程序发现当前读取到的参数值为0时,则停止继续读取程序。在Cocoa中,我们经常使用nil作为哨兵值,比如[NSArray arrayWithObjects:]方法,其接受数量不等的对象作为参数,而在最后则必须使用nil结尾。如:

12

[NSArrayarrayWithObjects:@1,@2,@3,nil];//备注:我们现在通常使用@[@1, @2, @3]来代替这一行代码,且不需要在最后添加nil,这称为字面量(Literals)

同format string一样危险的是,若开发者调用方法(函数)的时候,忘记在最后添加上哨兵值,则会发生不可预知的问题。同样幸运的是,编译器也为我们提供了一些方法来在编译时进行检查。

gcc中定义了___attribute__((sentinel))来标示一个函数需要在编译的时候对哨兵值进行检查。用法如下:

1

intaddemUp(intfirstNum,...)__attribute__((sentinel));

Cocoa开发者可以使用NS_REQUIRES_NIL_TERMINATION宏来替代,其实现基本等同于上述代码:

1

+(instancetype)arrayWithObjects:(id)firstObj,...NS_REQUIRES_NIL_TERMINATION;

工程实例

我在开发猿题库iOS客户端时,由于产品的需要会有许多alert弹框。但传统的UIAlertView经常需要实现相应的UIAlertViewDelegate,使用起来非常不便。我写了一个能够接收block作为回调的自定义的AlertView组件,同时为了保证其接口与UIAlertView基本一致,使用了可变参数列表。其接口定义如下:

123456789

@interfaceCYAlertView:UIAlertView-(id)initWithTitle:(NSString*)titlemessage:(NSString*)messageclickedBlock:(void(^)(CYAlertView*alertView,BOOLcancelled,NSIntegerbuttonIndex))clickedBlockcancelButtonTitle:(NSString*)cancelButtonTitleotherButtonTitles:(NSString*)otherButtonTitles,...NS_REQUIRES_NIL_TERMINATION;@end

完整的代码开源托管在GitHub(传送门),有兴趣的同学可以参考。

联系我

水平有限,若有任何关于该文章的疑问或者指正,欢迎和我讨论

写邮件:lancy1014#gmail.com

关注我的微博

Fo我的Github

在这里写评论留言

参考

Clang 3.5 documentation: Attributes in Clang

GCC documentation: Function Attributes

NSHisper:attribute

Advanced Mac OS X Programming

lancy

2014.5.12

May 5th, 2014

iOS

1 Comment

Toll-Free Bridging

什么是 Toll-Free Bridging

有一些数据类型是能够在 Core Foundation Framework 和 Foundation Framework 之间交换使用的。这意味着,对于同一个数据类型,你既可以将其作为参数传入 Core Foundation 函数,也可以将其作为接收者对其发送 Objective-C 消息(即调用ObjC类方法)。这种在 Core Foundation 和 Foundation 之间交换使用数据类型的技术就叫 Toll-Free Bridging.

举例说明,NSString和CFStringRef即是一对可以相互转换的数据类型:

1234567891011

// ARC 环境下// Bridging from ObjC to CFNSString*hello=@"world";CFStringRefworld=(__bridgeCFStringRef)(hello);NSLog(@"%ld",CFStringGetLength(world));// Bridging from CF to ObjCCFStringRefhello=CFStringCreateWithCString(kCFAllocatorDefault,"hello",kCFStringEncodingUTF8);NSString*world=(__bridgeNSString*)(hello);NSLog(@"%ld",world.length);CFRelease(hello);

大部分(但不是所有!)Core Foundation 和 Foundation 的数据类型可以使用这个技术相互转换,Apple 的文档里有一个列表(传送门),列出了支持这项技术的数据类型。

MRC 下的 Toll-Free Bridging 因为不涉及内存管理的转移,可以直接相互 bridge 而不必使用类似__bridge修饰字,我们之后再讨论这个问题。

Toll-Free Bridging 是如何实现的?

1.

每一个能够 bridge 的 ObjC 类,都是一个类簇(class cluster)。类簇是一个公开的抽象类,但其核心功能的是在不同的私有子类中实现的,公开类只暴露一致的接口和实现一些辅助的创建方法。而与该 ObjC 类相对应的 Core Foundation 类的内存结构,正好与类簇的其中一个私有子类相同。

举个例子,NSString是一个类簇,一个公开的抽象类,但每次创建一个NSString的实例时,实际上我们会获得其中一个私有子类的实例。而NSString的其中一个私有子类实现既为NSCFString,其内存的结构与CFString是相同的,CFString的isa指针就指向NSCFString类,即,CFString对象就是一个NSCFString类的实例。

所以,当NSString的实现刚好是NSCFString的时候,他们两者之间的转换是相当容易而直接的,他们就是同一个类的实例。

2.

当NSString的实现不是NSCFString的时候(比如我们自己 subclass 了NSString),我们调用 CF 函数,就需要先检查对象的具体实现。如果发现其不是NSCFString,我们不会调用 CF 函数的实现来获得结果,而是通过给对象发送与函数功能相对应的 ObjC 消息(调用相对应的NSString的接口)来获得其结果。

例如CFStringGetLength函数,当收到一个作为参数传递进来的对象时,会先确认该对象到底是不是NSCFString实现。如果是的话,就会直接调用CFStringGetLength函数的实现来获得字符串的长度;如果不是的话,会给对象发送length消息(调用NSString的- (NSUInteger)length接口),来得到字符串的长度。

通过这样的技术,即使是我们自己子类了一个NSString,也可以和CFStringRef相互 Bridge。

3.

其他支持 Toll-Free Bridging 的数据类型原理也同NSString一样,比如NSNumber的NSCFNumber和CFNumber。

ARC 下的 Toll-Free Bridging

如之前提到的,MRC 下的 Toll-Free Bridging 因为不涉及内存管理的转移,相互之间可以直接交换使用:

123456789

// bridgeNSString*nsStr=(NSString*)cfStr;CFStringRefcfStr=(CFStringRef)nsStr;// 调用函数或者方法NSUIntegerlength=[(NSString*)cfStrlength];NSUIntegerlength=CFStringGetLength((CFStringRef)nsStr);// releaseCFRelease((CFStringRef)nsStr);[(NSString*)cfStrrelease];

而在 ARC 下,事情就会变得复杂一些,因为 ARC 能够管理 Objective-C 对象的内存,却不能管理 CF 对象,CF 对象依然需要我们手动管理内存。在 CF 和 ObjC 之间 bridge 对象的时候,问题就出现了,编译器不知道该如何处理这个同时有 ObjC 指针和 CFTypeRef 指向的对象。

这时候,我们需要使用__bridge,__bridge_retained,__bridge_transfer修饰符来告诉编译器该如何去做。

__bridge

最常用的修饰符,这意味着告诉编译器不做任何内存管理的事情,编译器仍然负责管理好在 Objc 一端的引用计数的事情,开发者也继续负责管理好在 CF 一端的事情。举例说明:

例子1

12345

// objc to cfNSString*nsStr=[selfcreateSomeNSString];CFStringRefcfStr=(__bridgeCFStringRef)nsStr;CFUseCFString(cfStr);// CFRelease(cfStr); 不需要

在这里,编译器会继续负责nsStr的内存管理的事情,不会在 bridge 的时候 retain 对象,所以也不需要开发者在 CF 一端释放。需要注意的是,当nsStr被释放的时候(比如出了作用域),意味着cfStr指向的对象被释放了,这时如果继续使用cfStr将会引起程序崩溃。

例子2

12345

// cf to objcCFStringRefhello=CFStringCreateWithCString(kCFAllocatorDefault,"hello",kCFStringEncodingUTF8);NSString*world=(__bridgeNSString*)(hello);CFRelease(hello);// 需要[selfuseNSString:world];

在这里,bridge 的时候编译器不会做任何内存管理的事情,bridge 之后,会负责 ObjC 一端的内存管理的事情 。同时,开发者需要负责管理 CF 一端的内存管理的事情,需要再 bridge 之后,负责 release 对象。

__bridge_retained

接__bridge一节的第一个例子,objc to cf。为了防止nsStr被释放,引起我们使用cfStr的时候程序崩溃,可以使用__bridge_retained修饰符。这意味着,在 bridge 的时候,编译器会 retain 对象,而由开发者在 CF 一端负责 release。这样,就算nsStr在 objc 一端被释放,只要开发者不手动去释放cfStr,其指向的对象就不会被真的销毁。但同时,开发者也必须保证和负责对象的释放。例如:

12345

// objc to cfNSString*nsStr=[selfcreateSomeNSString];CFStringRefcfStr=(__bridge_retainedCFStringRef)nsStr;CFUseCFString(cfStr);CFRelease(cfStr);// 需要

__bridge_transfer

接__bridge一节的第二个例子,cf to objc。我们发现如果使用__bridge修饰符在cf转objc的时候非常的麻烦,我们既需要一个CFTypeRef的变量,还需要在 bridge 之后负责释放。这时我们可以使用__bridge_transfer,意味着在 bridge 的时候,编译器转移了对象的所有权,开发者不再需要负责对象的释放。例如:

12345

// cf to objcCFStringRefhello=CFStringCreateWithCString(kCFAllocatorDefault,"hello",kCFStringEncodingUTF8);NSString*world=(__bridge_transferNSString*)(hello);// CFRelease(hello); 不需要[selfuseNSString:world];

甚至可以这么写:

123

// cf to objcNSString*world=(__bridge_transferNSString*)CFStringCreateWithCString(kCFAllocatorDefault,"hello",kCFStringEncodingUTF8);[selfuseNSString:world];

小结

(__bridge T) op:告诉编译器在 bridge 的时候不要做任何事情

(__bridge_retained T) op:( ObjC 转 CF 的时候使用)告诉编译器在 bridge 的时候 retain 对象,开发者需要在CF一端负责释放对象

(__bridge_transfer T) op:( CF 转 ObjC 的时候使用)告诉编译器转移 CF 对象的所有权,开发者不再需要在CF一端负责释放对象

联系我

水平有限,若有任何关于该文章的疑问或者指正,欢迎和我讨论

写邮件:lancy1014#gmail.com

关注我的微博

Fo我的Github

在这里写评论留言

参考

Concepts in Objective-C Programming

Core Foundation Design Concepts

Toll Free Bridging Internals

Clang documentation: Objective-C Automatic Reference Counting (ARC)

Lancy

4.21

Apr 21st, 2014

iOS

8 Comments

如何做一个Letterpress拼词器

故事

哥哥家的猫咪有一天迷上了风靡全球的拼词游戏Letterpress,但是贪吃的小猫咪只认识“food”和“milk”这样的词语,所以经常被对面的玩家欺负。可怜的小猫咪向哥哥求助:“喵呜~哥哥~哥哥,他欺负我!”,于是充满爱心和正义感的哥哥就踏上了拯救猫咪的道路。

开始拯救世界

唔,我们马上来做一个自动拼词器,拼词器必须实现这样的功能:

猫咪只需要选择一张游戏截图,拼词器能自动识别游戏提供的字母。(记住:小喵掌是用不了键盘的哦

拼词器根据识别出来的字母,自动拼出所有可能的单词,并按长度由长到短排序显示。(小猫咪就能方便的挑选单词啦

有了这样的工具,连猫咪都能玩拼词游戏啦!

全部的代码在Github开源托管:点这里

正式的开始

我们会使用到Xcode5,并创建一个iOS7的应用。我将用到CoreGraph来做图像处理,你需要一些图像处理的基本常识,一些C语言的能力以及一点内存管理的知识。

现在开始吧!

首先创建一个新的Xcode工程,模板选择单页面即可,名字就叫LetterFun(或者任何你和你的猫咪喜欢的名字),设备选择iPhone,其他的选项让你家猫咪决定。

接下来创建一个继承自NSObject的类CYLetterManager,我们将用它来识别游戏截图里面的字母。在头文件加上这些方法:

12345678

// CYLetterManager.h@interfaceCYLetterManager:NSObject-(id)initWithImage:(UIImage*)image;\\1-(void)trainingWihtAlphabets:(NSArray*)array;\\2-(NSArray*)ocrAlphabets;\\3@end

我们假定一个CYLetterManager的实例只处理一个图片,所以我们使用一个initWithImage:的方法,来确保需要我们处理的图片总是被事先载入。

trainingWihtAlphabets:是一个训练方法,我们人工载入识别后的字母来让其进行训练,以提供后续字母识别的样本。

ocrAlphabets从图片里识别字母。

接着开始实现CYLetterManager。首先申明一些需要使用的变量:

123456

// CYLetterManager.m@implementationCYLetterManager{CGImageRef*_tagImageRefs;UIImage*_image;CGImageRef*_needProcessImage;}

其中_image是我们从initWithImage:里初始化得到的图像,其他两个变量,我会在后面用到的时候解释。

实现初始化方法:

123456789

-(id)initWithImage:(UIImage*)image{self=[superinit];if(self){_image=image;[selfgetNeedProcessImages];}returnself;}

接着实现getNeedProcessImages,这个方法用来将原图片切分为25个字母的小块,并存入_needProcessImage数组内。

12345678910111213141516171819202122232425262728293031

-(void)getNeedProcessImages{// 1CGImageReforiginImageRef=[_imageCGImage];CGImageRefalphabetsRegionImageRef=CGImageCreateWithImageInRect(originImageRef,CGRectMake(0,CGImageGetHeight(originImageRef)-640,640,640));CGFloatwidth=640;CGFloatheight=640;CGFloatblockWidth=width/5.0;CGFloatblockHeight=height/5.0;// 2 create image blocksCGImageRef*imagesRefs=malloc(25*sizeof(CGImageRef));for(NSIntegeri=0;i<5;i++){for(NSIntegerj=0;j<5;j++){CGRectalphabetRect=CGRectMake(j*blockWidth,i*blockHeight,blockWidth,blockHeight);CGImageRefalphabetImageRef=CGImageCreateWithImageInRect(alphabetsRegionImageRef,alphabetRect);imagesRefs[i*5+j]=alphabetImageRef;}}// 3 transform to binaryImagefor(NSIntegeri=0;i<25;i++){CGImageRefbinaryImage=[selfcreateBinaryCGImageFromCGImage:imagesRefs[i]];CGImageRelease(imagesRefs[i]);imagesRefs[i]=binaryImage;}// 4_needProcessImage=imagesRefs;CGImageRelease(alphabetsRegionImageRef);}

我们观察游戏截图,发现字母所在的区域在下方的640 * 640。我们使用CGImageCreateWithImageInRect函数创建了alphabetsRegionImageRef。注意:你需要使用CGImageRelease来release这个对象(函数最后一行),而originImageRef是由UIImage的CGImage方法获得的,你并不持有它,故而不需要release。

我们把alphabetsRegionImageRef裁剪成了25个小的方块,暂时存在imagesRefs数组。

彩色图片包含的信息太多,为了方便我们后续的处理,我们将得到的字母小方块进行二值化。注意:这里我们使用了自定义的函数createBinaryCGImageFromCGImage创建了一个二值化的image,再将其替换到数组里前,需要将数组里存在的旧对象release。

最后我们将imagesRefs赋值给_needProcessImage,并release不需要imageRef。

再来看如何进行图像二值化,先将这几个常数加到initWithImage:方法的上面:

1234

constintRED=0;constintGREEN=1;constintBLUE=2;constintALPHA=3;

之后来实现createBinaryCGImageFromCGImage方法,从这里开始我们将涉及到像素的操作:

1234567891011121314151617181920212223242526272829303132333435363738

-(CGImageRef)createBinaryCGImageFromCGImage:(CGImageRef)imageRef{NSIntegerwidth=CGImageGetWidth(imageRef);NSIntegerheight=CGImageGetHeight(imageRef);CGRectimageRect=CGRectMake(0,0,width,height);// 1UInt32*pixels=(UInt32*)malloc(width*height*sizeof(UInt32));CGColorSpaceRefcolorSpace=CGColorSpaceCreateDeviceRGB();CGContextRefcontextA=CGBitmapContextCreate(pixels,width,height,8,width*sizeof(UInt32),colorSpace,kCGBitmapByteOrder32Big|kCGImageAlphaPremultipliedLast);CGContextDrawImage(contextA,imageRect,imageRef);// 2for(NSIntegery=0;y255){rgbaPixel[RED]=255;rgbaPixel[GREEN]=255;rgbaPixel[BLUE]=255;}else{rgbaPixel[RED]=0;rgbaPixel[GREEN]=0;rgbaPixel[BLUE]=0;}}}// 3CGImageRefresult=CGBitmapContextCreateImage(contextA);CGContextRelease(contextA);CGColorSpaceRelease(colorSpace);free(pixels);returnresult;}

使用CGBitmapContextCreate创建了一个 bitmap graphics context,并将 pixels 设为其 data pointer,再将 image 绘制到 context 上,这样我们可以通过操作 pixels 来直接操作 context 的数据。该方法的其他参数可以参考文档,参数会影响数据,在这里请先使用我提供的参数。

我们遍历了图像的每个像素点对每个点进行二值化,二值化有许多种算法,大体分为固定阀值和自适应阀值两类。这里我们观察待处理图片可知,我们需要提取的字母部分是明显的黑色,这样使用固定的阀值255,即可顺利将其提取,而有颜色的部分会被剔除。

使用CGBitmapContextCreateImage来从context创建处理后的图片,并清理数据。

注意:由于c没有autorelease池,你应当在函数(方法)的命名上使用create(或copy)来提醒使用者应当负责 release 对象。

至此,我们已经完成了字母方块的提取和二值化。为了防止我们没出问题,来检查一下成果。

将一张游戏截图”sample.png”拖进Xcode proj内。

在CYViewController的viewDidLoad里使用该图片实例化一个CYLetterManager。

在CYLetterManager的getNeedProcessImages里的任意地方加上断点,可以是二值化前后,也可以是切小字母块前后。

运行!然后隆重介绍Xcode5的新功能之一,快速预览,当当当当!

以本文最开始的截图为例:

可以看到我们已经成功的截出了第一个字母,并把其转为二值化图片。

下一步

载入了需要的图片和进行了预处理之后,我们来进行识别的前奏:获得识别用的样本。为此我们实现trainingWihtAlphabets方法:

123456789

-(void)trainingWihtAlphabets:(NSArray*)array{for(NSIntegeri=0;i<25;i++){if(array[i]){[selfwriteImage:_needProcessImage[i]withAlphabet:array[i]];}}[selfprepareTagImageRefs];}

该方法接受一个字母数组,里面应该包含着,我们之前载入图片里的,从左到右,从上到下的字母队列。比如@[@"t", @"e", @"j", ... , @"h"];

我们使用writeImage:withAlphabet:方法,将该图片设为标准样本,写入到文件中。读写CGImageRef的方法如下:

12345678910111213141516171819202122232425262728293031323334353637383940414243

@importImageIO;@importMobileCoreServices;-(NSString*)pathStringWithAlphabet:(NSString*)alphabet{NSString*imageName=[alphabetstringByAppendingString:@".png"];NSString*documentsPath=[@"~/Documents"stringByExpandingTildeInPath];NSString*path=[documentsPathstringByAppendingString:[NSStringstringWithFormat:@"/%@",imageName]];returnpath;}-(CGImageRef)createImageWithAlphabet:(NSString*)alphabet{NSString*path=[selfpathStringWithAlphabet:alphabet];CGImageRefimage=[selfcreateImageFromFile:path];returnimage;}-(CGImageRef)createImageFromFile:(NSString*)path{CFURLRefurl=(__bridgeCFURLRef)[NSURLfileURLWithPath:path];CGDataProviderRefdataProvider=CGDataProviderCreateWithURL(url);CGImageRefimage=CGImageCreateWithPNGDataProvider(dataProvider,NULL,NO,kCGRenderingIntentDefault);CGDataProviderRelease(dataProvider);returnimage;}-(void)writeImage:(CGImageRef)imageRefwithAlphabet:(NSString*)alphabet{NSString*path=[selfpathStringWithAlphabet:alphabet];[selfwriteImage:imageReftoFile:path];}-(void)writeImage:(CGImageRef)imageReftoFile:(NSString*)path{CFURLRefurl=(__bridgeCFURLRef)[NSURLfileURLWithPath:path];CGImageDestinationRefdestination=CGImageDestinationCreateWithURL(url,kUTTypePNG,1,NULL);CGImageDestinationAddImage(destination,imageRef,nil);if(!CGImageDestinationFinalize(destination)){NSLog(@"Failed to write image to %@",path);}CFRelease(destination);}

prepareTagImageRefs方法将磁盘里保存的样本图片摘出来,存在_tagImageRefs数组里面,用于之后的比对。实现如下:

123456789101112

-(void)prepareTagImageRefs{_tagImageRefs=malloc(26*sizeof(CGImageRef));for(NSIntegeri=0;i<26;i++){charch='a'+i;NSString*alpha=[NSStringstringWithFormat:@"%c",ch];_tagImageRefs[i]=[selfcreateImageWithAlphabet:alpha];if(_tagImageRefs[i]==NULL){NSLog(@"Need sample: %c",ch);}}}

将[self prepareTagImageRefs]加到initWitImage:方法里面,这样我们每次实例化的时候,都会自动从磁盘里读取标记好的样本图片。

非常需要注意的是:我们添加dealloc方法(用惯了arc的开发者可能会不习惯),但这是c,是需要我们自己管理内存的。在dealloc里面释放我们的成员变量吧:

12345678910111213

-(void)dealloc{for(NSIntegeri=0;i<26;i++){if(_tagImageRefs[i]!=NULL){CGImageRelease(_tagImageRefs[i]);}}free(_tagImageRefs);for(NSIntegeri=0;i<25;i++){CGImageRelease(_needProcessImage[i]);}free(_needProcessImage);}

接下来,我们需要载入足够多的包含了26个英文字母的sample图片,做好训练,将26个样品图片就都裁剪好的存入磁盘啦!(哥哥写不动了,训练代码在CYViewController里面,翻到最下面看源码啦)

识别字母!

OCR技术从最早的模式匹配,到现在流行的特征提取,有各种各样的方法。我们这里不搞那么复杂,而使用最简单粗暴的像素比对。即我们之前将其转化为二值化图像了之后,直接比对两个图片相同的像素点比例即可。

我们使用标记过的_tagImageRefs作为比对样本,将要识别的图像与26个标准样本进行比对,当相似度大于某个阀值的时候,我们即判定其为某个字母,实现如下:

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162

-(NSString*)ocrCGImage:(CGImageRef)imageRef{NSIntegerresult=-1;for(NSIntegeri=0;i<26;i++){CGImageReftagImage=_tagImageRefs[i];if(tagImage!=NULL){CGFloatsimilarity=[selfsimilarityBetweenCGImage:imageRefandCGImage:tagImage];if(similarity>0.92){result=i;break;}}}if(result==-1){returnnil;}else{charch='a'+result;NSString*alpha=[NSStringstringWithFormat:@"%c",ch];returnalpha;}}// suppose imageRefA has same size with imageRefB-(CGFloat)similarityBetweenCGImage:(CGImageRef)imageRefAandCGImage:(CGImageRef)imageRefB{CGFloatsimilarity=0;NSIntegerwidth=CGImageGetWidth(imageRefA);NSIntegerheight=CGImageGetHeight(imageRefA);CGRectimageRect=CGRectMake(0,0,width,height);UInt32*pixelsOfImageA=(UInt32*)malloc(width*height*sizeof(UInt32));UInt32*pixelsOfImageB=(UInt32*)malloc(width*height*sizeof(UInt32));CGColorSpaceRefcolorSpace=CGColorSpaceCreateDeviceRGB();CGContextRefcontextA=CGBitmapContextCreate(pixelsOfImageA,width,height,8,width*sizeof(UInt32),colorSpace,kCGBitmapByteOrder32Big|kCGImageAlphaPremultipliedLast);CGContextRefcontextB=CGBitmapContextCreate(pixelsOfImageB,width,height,8,width*sizeof(UInt32),colorSpace,kCGBitmapByteOrder32Big|kCGImageAlphaPremultipliedLast);CGContextDrawImage(contextA,imageRect,imageRefA);CGContextDrawImage(contextB,imageRect,imageRefB);NSIntegersimilarPixelCount=0;NSIntegerallStrokePixelCount=0;for(NSIntegery=0;y

有了上面两个识别的方法,我们再实现ocrAlphabets方法就很容易了:

12345678910111213

-(NSArray*)ocrAlphabets{NSMutableArray*alphabets=[NSMutableArrayarrayWithCapacity:25];for(NSIntegeri=0;i<25;i++){NSString*alphabet=[selfocrCGImage:_needProcessImage[i]];if(alphabet){[alphabetsaddObject:alphabet];}else{[alphabetsaddObject:@"unknown"];}}return[alphabetscopy];}

开始拼词

首先,我们需要准备一个词典。你可以在Unix(或者Unix-like)的系统里找到words.txt这个文件,他一般存在/usr/share/dict/words, or /usr/dict/words

将这个文件拷贝出来,并添加到我们的工程里。我们将创建一个CYWordHacker类来做拼词的事情,实现传入一组字符,返回所有合法单词按长度降序排列的数组的接口,如下:

123

@interfaceCYWordHacker:NSObject-(NSArray*)getAllValidWordWithAlphabets:(NSArray*)alphabets;@end

具体实现从略,可参照源码。

界面

做成下面这样就可以了:

界面细节大家就去看源码吧~写不动了~哥哥要和猫咪玩乐去了~

最终成品

全部的代码在Github开源托管:点这里

还有一件事

这个东西其实到这里并不是就完了,我们将图片二值化后其实去掉了图片的很多信息,比如当前游戏的状态。有兴趣的筒子,可以根据字块的颜色,来识别出游戏的状态,写出更智能更强力拼词器。实现诸如:占有更多对方的格子或者做出最大的block区域等强力功能,甚至求出最优解策略。这就涉及到人工智能的领域啦。

联系我

写邮件:lancy1014#gmail.com

关注我的微博

Fo我的Github

在这里写评论留言

Lancy

20 Oct.

Oct 19th, 2013

ios

1 Comment

Cocoa中的位与位运算

介绍

位操作是程序设计中对位模式或二进制数的一元和二元操作. 在许多古老的微处理器上, 位运算比加减运算略快, 通常位运算比乘除法运算要快很多. 在现代架构中, 情况并非如此:位运算的运算速度通常与加法运算相同(仍然快于乘法运算).(摘自wikipedia)

OC作为c的扩展和超集,位运算自然使用的是c的操作符。c提供了6个位操作符,$,|,^,~,<<,>>。本文不打算做位运算的基础教学,只介绍一些开发中能用到的场景。

提高运算速度

如前一段所说,位运算的运算速度是通常与加法速度相当,但是快于乘法运算的。故而如果我们的程序对性能有要求,我们可以使用位运算来提高运算速度。比如:

乘以2:n << 1;

除以2:n >> 1;

乘以2的m次方:n << m;

除以2的m次方:n >> m;

判断奇偶:(n & 1) == 1;

求平均数:(a + b) >> 1;

……

基于乘除法的位运算提速还有很多,这里不一一列举。需要注意的是,你应当只在遇到性能瓶颈的时候,并且瓶颈的确是计算的时候才这么做。因为使用位运算并不利于程序的可读性和可维护性。(科学计算除外)

压缩空间

以前接触过ACM的筒子们应该对状态压缩不陌生,状态压缩的目的在于把一个大数据用有限的内存空间来进行表示。比如 Programming Pearls 里面的一个经典示例:如何对最多有一千万条不重复的7位整数(电话号码)进行排序?且可使用的内存空间有大约1MB多。

显而易见的常规做法既是做一个基于磁盘操作的外排序。然而如果转换一下思路,充分的使用内存中的每一个位,加上不存在重复的电话号码,以及不存在0和1开头的电话号码。我们只需要使用1000万个位(大约1.2mb),就能以集合的方式在内存里标记下所有的数据,从而轻松的实现位排序。此种方法大幅度的减少了IO时间,从而获得巨大的性能提升。

ACM里面有大量的如果使用位来压缩空间的示例,状态压缩的动态规划等,此处不做展开,只告诉读者,充分的使用内存的每一个位,经常能带来意想不到的收获。但需要注意的是,状态的压缩和提取,都需要一定的计算量,有时一味的追求状态压缩,反而会降低效率。

表示数据

比较经典的一个应用场景,使用一串24位的十六机制数字来表现一个RGB颜色(或者32位来表示ARGB)。由于PS,Web以及各类取色器,都能快速的取出RGB的Hex值,但是UIColor没有对应的方法。故而我们可以写出下面这样一个UIColor的Category,来快速的用一个RGBHex生成一个UIColor。(源码在UIColor + CYHelper.h

12345678910111213

+(UIColor*)colorWithRGBHex:(UInt32)hex{return[UIColorcolorWithRGBHex:hexalpha:1.0f];}+(UIColor*)colorWithRGBHex:(UInt32)hexalpha:(CGFloat)alpha{intr=(hex>>16)&0xFF;intg=(hex>>8)&0xFF;intb=(hex)&0xFF;return[UIColorcolorWithRed:r/255.0fgreen:g/255.0fblue:b/255.0falpha:alpha];}

状态与选项

1234567891011121314151617181920212223242526

typedefNS_OPTIONS(NSUInteger,UIViewAnimationOptions){UIViewAnimationOptionLayoutSubviews=1<<0,UIViewAnimationOptionAllowUserInteraction=1<<1,// turn on user interaction while animatingUIViewAnimationOptionBeginFromCurrentState=1<<2,// start all views from current value, not initial valueUIViewAnimationOptionRepeat=1<<3,// repeat animation indefinitelyUIViewAnimationOptionAutoreverse=1<<4,// if repeat, run animation back and forthUIViewAnimationOptionOverrideInheritedDuration=1<<5,// ignore nested durationUIViewAnimationOptionOverrideInheritedCurve=1<<6,// ignore nested curveUIViewAnimationOptionAllowAnimatedContent=1<<7,// animate contents (applies to transitions only)UIViewAnimationOptionShowHideTransitionViews=1<<8,// flip to/from hidden state instead of adding/removingUIViewAnimationOptionOverrideInheritedOptions=1<<9,// do not inherit any options or animation typeUIViewAnimationOptionCurveEaseInOut=0<<16,// defaultUIViewAnimationOptionCurveEaseIn=1<<16,UIViewAnimationOptionCurveEaseOut=2<<16,UIViewAnimationOptionCurveLinear=3<<16,UIViewAnimationOptionTransitionNone=0<<20,// defaultUIViewAnimationOptionTransitionFlipFromLeft=1<<20,UIViewAnimationOptionTransitionFlipFromRight=2<<20,UIViewAnimationOptionTransitionCurlUp=3<<20,UIViewAnimationOptionTransitionCurlDown=4<<20,UIViewAnimationOptionTransitionCrossDissolve=5<<20,UIViewAnimationOptionTransitionFlipFromTop=6<<20,UIViewAnimationOptionTransitionFlipFromBottom=7<<20,}NS_ENUM_AVAILABLE_IOS(4_0);

我们观察Apple在UIViewAnimationOptions的枚举变量,使用了一个NSUInteger就表示了UIViewAnimation所需的所有Option。其中0~9十个是互不影响的可同时存在option。16~19,20~24使用了4位来表示互斥的option。

如此定义了之后,对UIViewAnimationOptions的赋值变得尤为简单,使用 | 操作符既可以获得一个给对应的option位赋值后的结果。例如:

1234567

[UIViewanimateWithDuration:1.0delay:0options:UIViewAnimationOptionAllowUserInteraction|UIViewAnimationOptionBeginFromCurrentState|UIViewAnimationOptionCurveEaseInanimations:{...}completion:{...}];

提取也比较简单,使用 & 操作符 和 >> 操作符,就可以轻松判定某个位有没有被设置,以及提取某些状态位,例如:

12345678910111213141516171819

UIViewAnimationOptionsoption=UIViewAnimationOptionAllowUserInteraction|UIViewAnimationOptionBeginFromCurrentState|UIViewAnimationOptionCurveEaseIn|UIViewAnimationOptionTransitionCrossDissolve;if(option&UIViewAnimationOptionAllowUserInteraction){NSLog(@"UIViewAnimationOptionAllowUserInteraction has been set");}if(option&UIViewAnimationOptionBeginFromCurrentState){NSLog(@"UIViewAnimationOptionBeginFromCurrentState has been set");}UInt8optionCurve=option>>16&0xf;if(optionCurve==1){NSLog(@"UIViewAnimationOptionCurveEaseIn has been set");}UInt8optionTransition=option>>20&0xf;if(optionTransition==5){NSLog(@"UIViewAnimationOptionTransitionCrossDissolve has been set");}

这里最需要注意的地方就是,对互斥的状态的设置必须尤为小心,如果你这么写:

1234

UIViewAnimationOptionsbadOption=UIViewAnimationOptionCurveEaseIn|UIViewAnimationOptionCurveEaseOut;UInt8oops=badOption>>16&0xf;NSLog(@"Sorry, it's not UIViewAnimationOptionCurveEaseInOut");NSLog(@"oops = %d, you got UIViewAnimationOptionCurveLinear",oops);

联系我

写邮件:lancy1014#gmail.com

关注我的微博

Fo我的Github

在这里写评论留言

Lancy

9.27

Sep 27th, 2013

iOS

1 Comment

制作自己的CocoaPods Spec

前言

关于CocoaPods,相信不用我介绍更多了。本文主要介绍如何制作自己的CocoaPods spec。

步骤

首先你要会用git,还要有一个托管在云端的repo,本文以Github为例,Git和Github的使用方式参照Github Help

在你的repo下面,使用Git的tag功能,给你的某个commit添加一个tag(比如1.1.0),并push到Github.

// 本地添加一个标签:

$ git tag -a 1.1.0 -m "Version 1.1.0 Stable"

// Push tag to GitHub:

$ git push --tags

FolkCocoaPods/Specs并 Clone 到本地。

在Clone下来的Specs/创建一个自己的spec的目录,再创建一个版本目录。比如:

Specs/CYHelper/1.1.0

在该目录下创建一个spec档案,并编辑:

$ pod spec create CYHelper

$ vi CYHelper.podspec

pod创建模板会有相关的说明,按指引一步一步填即可。例如,CYHelper的spec配置如下:

Pod::Spec.new do |s|

s.name        = "CYHelper"

s.version      = "1.1.0"

s.summary      = "CYHelper is an Objective-C library for iOS developers."

s.homepage    = "https://github.com/lancy/CYHelper"

s.license      = 'MIT (LICENSE)'

s.author      = { "lancy" => "lancy1014@gmail.com" }

s.source      = { :git => "https://github.com/lancy/CYHelper.git", :tag => "1.1.0" }

s.platform    = :ios, '5.0'

s.source_files = 'CYHelper', 'CYHelper/**/*.{h,m}'

s.exclude_files = 'CYHelperDemo'

s.frameworks = 'Foundation', 'CoreGraphics', 'UIKit'

s.requires_arc = true

end

验证podspec

pod spec lint CYHelper.podspec

如果验证成功的话,会有这样的提示

Analyzed 1 podspec.

CYHelper.podspec passed validation.

最后去Github上发一个PullRequest,等待一段时间的审核和Merge,之后就可以像别的pod那样用CocoaPods来管理了:

// Podfile

platform :ios, '6.0'

pod 'CYHelper'

$ pod install

Have Fun!

后注

CYHelper在这里,欢迎试用

顺便求fo我的github

这里有唐巧和王轲写的两篇相关的文章,可以作为扩展阅读:

使用CocoaPods来做iOS程序的包依赖管理

CocoaPods进阶:本地包管理

Aug 11th, 2013

iOS

0 Comments

Singletons in Cocoa, Are They Evil?

故事

这事是这样的,去年我在上课的时候,和老师讨论了一下关于架构的问题,我是开发Cocoa/iOS的,老师是开发Web的,而老师是一个坚定的singletons are evil的拥护者,我和他说了我的App的架构,直接被他一顿猛劈,强烈的谴责了我使用Singletons,我回应说,这个pattern在Cocoa里是大量使用的,结果被搞了一句“用的多的就是对的么?你回去多学习一下再来讨论吧”。

于是我非常郁闷的回去搜索的一大顿的资料,还在Stackoverflow上发起了一个问题:singletons in cocoa, are they evil?。甚至在某个社区,假扮singleton are evil的拥护者,把所有singleton的缺点列了一堆,结果又是群起而攻之一场舌战。

关于Singleton的缺点,放出一段引用:

They are generally used as a global instance, why is that so bad? Because you hide the dependencies of your application in your code, instead of exposing them through the interfaces. Making something global to avoid passing it around is a code smell.

They violate the Single Responsibility Principle: by virtue of the fact that they control their own creation and lifecycle.

They inherently cause code to be tightly coupled. This makes faking them out under test rather difficult in many cases.

They carry state around for the lifetime of the app. Another hit to testing since you can end up with a situation where tests need to be ordered which is a big no no for unit tests. Why? Because each unit test should be independent from the other.

公说公有理,婆说婆有理,一度把我弄得越来越困惑,后来我看到这一段话,我就彻底释然了:

As for degrees of evil – it’s like speech or literature. F-words are “evil”. If you speak constantly using f-words words the quality of your language is lower – people can’t tell if you want to say something or just swearing. But in some situations such words help you to get things done (think of the battlefield orders). It sort of the same thing with features/patterns and people who have to read and maintain their usage.

– hoha

BTW,今天我甚至看到了Accessors Are Evil这样的东西,更坚定了我再也不相信xxx are evil这种说法的决心。

我现在认为Design pattern是前人总结的经验,不同的设计模式有不同的优缺点,比如说用工厂代替单例的,虽说解决了单例的一些问题,但你要真去写一个工厂就知道有多蛋疼,多浪费生命了。然而在较为大型的应用,非常多人协作的项目,队友对项目的把握不一致,水平有高低之分,这时工厂又反而是一种安全的,省时省力的做法。

其实在代码的世界里面,你想要更多的安全,就会丧失更多的灵活性和便利性。如何在这中间取舍,就需要我们彻底的了解某种模式(或者说某种编程方法)的优缺点,在保证基本的安全性的情况下,尽可能的减少工作量,提高工作效率。

Singletons in Cocoa

回到正题,还是来说说Cocoa上的单例。Cocoa中的普遍的,大部分的单例,并不是严格的单例(strict singleton),而是一种共享单例(shared singleton),例如sharedApplication,sharedURLCache等。即,大多数情况,我们访问同一个类方法,就可以获得一个同样的实例,但若真的需要存在多个实例亦可。通常,共享单例使用一个shared开的类方法识别。只有当真的只有唯一的一个共享资源的时候,或者不可能有多个资源的时候(比如GPS模块),才会使用严格意义的共享单例。

线程安全的Singleton

绝大多数情况下,使用一个共享单例比使用共享单例要好,然而这里有一个常见的创建共享单例的错误,即使是Apple自己的开发者文档也没弄清楚的一个错误,他们把Singleton写成了非线程安全的:

1234567

+(MyClass*)sharedInstance{staticMyClass*sharedInstance;if(sharedInstance==nil){sharedInstance=[[MyClassalloc]init];}returnsharedInstance;}

正确的写法应该是:

123456789

+(MyClass*)sharedInstance{staticMyClass*sharedInstance;@synchronized(self){if(sharedInstance==nil){sharedInstance=[[MyClassalloc]init];}}returnsharedInstance;}

更恰当的写法是使用dispatch_once()

123456789

+(MYClass*)sharedInstance{staticdispatch_once_tpred=0;staticMYClass_sharedObject=nil;dispatch_once(&pred,^{_sharedObject=[[selfalloc]init];// or some other init method});return_sharedObject;}

dispatch_once()即为执行且仅仅执行某个block一次,他是同步的方法(记住GCD也有很多同步的方法),其速度也比 @synchronized 快许多。

严格的单例(strict singleton)

尽管我们很少会使用到严格的单例模式,但当真的需要的时候,还是可以实现的。

苹果官方文档提供了一个严格单例的实现(传送门)。 其重载了allocWithZone:, copyWithZone, retain, retainCount, release, autorelease。使得这个实现变得无比复杂而难以理解和控制。

而大多数情况下,实现严格的单例模式,只需要和共享单例相同的代码,再使用NSAssert使得一切调用init的代码作为一个错误处理即可,代码如下:

1234567891011121314151617181920

+(MYSingleton*)sharedSingleton{staticdispatch_once_tpred;staticMYSingleton*instance=nil;dispatch_once(&pred,^{instance=[[selfalloc]initSingleton];});returninstance;}-(id)init{// Forbid calls to –init or +newNSAssert(NO,@”CannotcreateinstanceofSingleton”);// You can return nil or [self initSingleton] here,// depending on how you prefer to fail.returnnil;}// Real (private) init method-(id)initSingleton{self=[superinit];if((self=[superinit])){// Init code }returnself;}

这份代码的优点是很明显的,避免了复杂的内存操作和重载,又静止了调用者创建多个实例。

小结

小结一下,单例模式是Cocoa中非常常用的一个模式,对于应用程序中广泛使用的对象,单例模式是非常便利的方法。而我们也应当在使用的时候多注意单例模式的一些缺点,尽可能的在实现的时候避免他们,比如让单例不存在过于复杂的依赖性和继承,保证其松耦合等。

Edit:

One more thing:有筒子问到是@synchronized(self)还是@synchronized(sharedInstance)?

答案是:均可。

self,在实例方法中表现是实例,这一点自不用多说。在类方法中则表现为一种多态的类实例(class instance),他总是会返回正确的类型,比如这样:

1234

+(id)new{return[[selfalloc]init];}

而在本文的这个@synchronized(self)里的self,总是会指向同一个对象,即那个特殊的类实例。(class也是一个对象),故而此处可以使用self。

lancy

Jun 4th, 2013

iOS

0 Comments

OBJC中声明字符串常量的一个常见错误(常量指针和指针常量)

我们知道,NSNotification是Cocoa中观察模式最易用的实现方法,比起直接使用KVO(Key-Value Observing)他更加容易实现也更好理解。一个样例:

Poster.h

12

// Define a string constant for the notificationexternNSString*constPosterDidSomethingNotification;

Poster.m

123456

NSString*constPosterDidSomethingNotification=@”PosterDidSomethingNotification”;...// Include the poster as the object in the notification[[NSNotificationCenterdefaultCenter]postNotificationName:PosterDidSomethingNotificationobject:self];

Observer.m

123456789101112131415161718

// Import Poster.h to get the string constant#import “Poster.h”...// Register to receive a notification[[NSNotificationCenterdefaultCenter]addObserver:selfselector:@selector(posterDidSomething:)name:PosterDidSomethingNotificationobject:nil];...-(void)posterDidSomething:(NSNotification*)note{// Handle the notification here}-(void)dealloc{// Always remove your observations[[NSNotificationCenterdefaultCenter]removeObserver:self];[superdealloc];}

注意到,在使用Notifikation的时候,会需要声明字符串常量,作为notification的name。这时,const的位置就比较重要,很容易让不了解的人犯错误:

错误的写法(常量指针):

1

externconstNSString*RNFooDidCompleteNotification;

正确的写法(指针常量):

1

externNSString*constRNFooDidCompleteNotification;

这里涉及到常量指针和指针常量的概念,简单的来说:

常量指针:就是指向常量的指针,关键字 const 出现在 * 左边,表示指针所指向的地址的内容是不可修改的,但指针自身可变。

指针常量:指针自身是一个常量,关键字 const 出现在 * 右边,表示指针自身不可变,但其指向的地址的内容是可以被修改的。

在此例中:我们知道,NSString永远是immutable的,所以NSString * const 是有效的,而const NSString * 则是无效的。而使用错误的写法,则无法阻止修改该指针指向的地址,使得本应该是常量的值能被修改,造成了隐患。这是需要注意的一个常见错误。

Jun 2nd, 2013

iOS

2 Comments

Objective-C Associative References(关联引用) 续:相关实践

About

我之前写了一篇博文Objective-C Associative References(关联引用),介绍我在在研究objc runtime的有趣的发现,但当时我并没有意识到这个技术应该使用在何处。在一些实践之后,小结一下有关关联引用的一些相关实践吧。

Category中使用关联引用来添加property

我们知道category是不能创建实例变量的,但我们可以通过关联引用来达到这样的目的。特别是当你不持有这个类,比如说系统的类,而你又的确需要添加一个property。

你可以这样做:

1234567891011121314151617

#import @interfacePerson(EmailAddress)@property(readwrite,copy)NSString*emailAddress;@end@implementationPerson(EmailAddress)staticcharemailAddressKey;-(NSString*)emailAddress{returnobjc_getAssociatedObject(self,&emailAddressKey);}-(void)setEmailAddress:(NSString*)emailAddress{objc_setAssociatedObject(self,&emailAddressKey,emailAddress,OBJC_ASSOCIATION_COPY);}@end

给UI控件关联上相关对象

比如UIAlert只有一个tag属性用来做标记,我们经常需要根据Tag属性在找出对应需要操作的对象。但使用关联对象,我们可以把UIAlert和某个对象关联,简化这个过程。

比如你可以这样做:

12345678910

idinterestingObject=...;UIAlertView*alert=[[UIAlertViewalloc]initWithTitle:@”Alert”message:nildelegate:selfcancelButtonTitle:@”OK”otherButtonTitles:nil];objc_setAssociatedObject(alert,&kRepresentedObject,interestingObject,OBJC_ASSOCIATION_RETAIN_NONATOMIC);[alertshow];

在alertView的delegate方法里面这样操作:

12345

-(void)alertView:(UIAlertView*)alertViewclickedButtonAtIndex:(NSInteger)buttonIndex{UIButton*sender=objc_getAssociatedObject(alertView,&kRepresentedObject);self.buttonLabel.text=[[sendertitleLabel]text];}

结合以上两者的最佳实践

在Cocoa里面,我们经常会见到user info这样一个属性,(比如NSNotification.userinfo),代表用户自定义的payload数据。

同时一般而言,显式的使用objc的runtime特性并不是一个良好的编程习惯,故而我们可以使用category给UIAlert添加一个user info的property,以将objc的runtime代码进行隐藏。

代码与前面给出的类似,你可以在Github下载到完整Demo。传送门

使用效果:

123

UIAlertView*alert=[[UIAlertViewalloc]initWithTitle:@"Alert One"message:@"I gonna show the userinfo"delegate:selfcancelButtonTitle:@"OK"otherButtonTitles:nil];[alertsetUserinfo:@{@"message":@"I'm userinfo of alert one"}];[alertshow];

May 22nd, 2013

iOS

0 Comments

使用CoreLocation来跟踪用户距离

使用CoreLocation来跟踪用户距离

背景

CoreLocation是一个强大的Framework,他能帮助开发使其免于复杂的位置处理而专注于应用逻辑的开发。然而CoreLocation并没有提供的对用户移动距离的检测,当我们开发跑步类运动类应用时,就不可避免的需要这项功能。凑巧有一个朋友让我帮忙做一个GPS模块,故而就有了CYLocationManager。

代码在Github开源托管,传送门

实现说明

Readme有详细的使用说明,我在这里主要描述一下实现的一些要点。

基本的思路既是不断的采样用户数据,过滤掉误差较大的数据,取相对误差较小的数据进行记录,然后计算相邻记录点之间的距离。

简单描述一下几个要点:

当用户开始运动,程序开始追踪,设置一个强制标记,(needForceCalculation),表示程序应该忽略其他因素,立刻获取一个点坐标。用做起始值。

设置了CLLocationManager.headingFilter,使得程序能在用户转向的时候收到通知,此时设置一个强制标记(needForceCalculation),使得程序在用户转向的时候,记录下转向时所在的位置,以减少误差。

设置CLLocationManager.distanceFilter,使得程序在变化的位置大于一定数值时该更新位置才算为有效,可以避免用户在一个地方停留,由于误差记录距离依然增长。

当程序获得位置更新时,若精度合格,切时间戳合理,则加入一个数组,用于之后的计算。若精度大于某个阀值,则认为该位置对跟踪距离无帮助,此时将该位置舍去。

数组currentKeepLocations来记录最近更新的k个位置,并每隔t秒,从该数组中,取出精度最高的位置记录。(精度见CLLocation.horizontalAccuracy)

注意,当用户停止运动时,位置将无法得到更新,此时需要设置一个timer,令其在一定时间内强制获得一个位置。

该程序还可以通过每次更新位置时获得的位置的精确度来判断GPS信号的强弱。

联系我

如果你对这个程序有疑问,请联系我

May 16th, 2013

iOS

0 Comments

Next

Blog Archives

Copyright © 2016 Lancy

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

推荐阅读更多精彩内容