原文:http://ios-blog.co.uk/tutorials/how-to-make-a-magazine-app-in-ios-part-ii/
改变
教程第一部分介绍了许多东西。 抱歉我又迟到了,在写本文的同时我关注了iOS5的新特性,但由于NDA的缘故我不得泄露任何关于新SDK的内容。最终的例子同时提供了对iOS4和iOS5 的支持。
我不会过多讲解Newsstand,归根结底我们将创建一个杂志应用程序,newsstand的实现细节与此无关。在我的博客我写了俩个教程,你可以阅读它们(这里以及这里),它们已经包括了newsstand的方方面面。简单而言,newsstand在ipad和iPhone上采用全新的方式来展现杂志,原来的图标代以杂志(或报刊)的封面,然后所有的newsstand图标被放在了一起。对于开发者,newsstand包含了一个Newsstand Kit框架,包括内容的下载、安装及组织方式。
示例程序
下图显示了程序的部分截图。9个杂志、9个水果味的封面。你可以下载、通过进度条查看下载进度、然后阅读杂志。另外一张图显示了Newsstand。在Nesstand组中,原本的应用程序图标被杂志封面图标所替代。但在iOS4的iPad中,程序仍然显示的是应用程序图标。
程序代码放在了这里: GitHub。不要将这些代码用于生产,除非你经过大量测试。但在真正的开发中可以用它来作为一个不错的起点。实际上,在第1部分我们已经解释过这些代码的主要结构;我建议你在阅读本章前先阅读第一部分内容,以更好地理解程序的主要组成。接下来讨论的过程中,要始终将期刊管理(控制器,而不是视图控制器)与UI尽可能分离。理论上,“书店管理器”和“期刊模型”也能在Mac下重用,因为它们与UI是非常“松耦合”的。
我用“单窗口模板”创建这个程序。在application:didFinishLaunchingWithOptions方法中,加入2个主要组件:
-(BOOL)application:(UIApplication *)applicationdidFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
// 创建 "Store" 实例
_store = [[Store alloc] init];
[_store startup];
self.shelf = [[[ShelfViewController alloc] initWithNibName:nilbundle:nil] autorelease];
_shelf.store=_store;
self.window = [[[UIWindow alloc] initWithFrame:[[UIScreenmainScreen] bounds]] autorelease];
self.window.rootViewController = _shelf;
[self.window makeKeyAndVisible];
return YES;
}
这两个组件分别是:
- Store类,即框图中的“书店管理器”;继承自NSObject,与UI没有任何关系。
- ShelfViewController类,代表了应用程序界面。它有一个Store类型的属性。它不直接访问Store类的属性,而是通过简单API来从Store获取所需信息。我们也可以使用委托,但这基本上只是在特定程序中的特定的两个类间使用,没有必要为它们的交互专门定义一套协议。这个控制器可以分为两部分,一个用于UI,即书架,一个用于后台,即书店。书架严格依赖于书店,反之则不然。书店到书架的通讯使用懒惰模式,即通知。
程序通过info.plist集成到newsstand。具体请阅读苹果文档或者我的教程。
模型和控制器
程序中有1个模型,即“Issue”模型,代表了位于书店或用户已购期刊中的一本期刊。还有一个控制器,即“书店”。虽然这个控制器并不是UI组件,但对于本程序的“后台”,有这两个组件就足够了。理论上,我们可以不使用任何用户界面即可检索书店状态并下载杂志。这是杂志应用程序中的基本概念,有许多事件是在后台发生而无需用户干预:也就是说,程序在UI尚未加载的情况下就能执行任务。
Issue类表示所有杂志的特性,唯一的id,名称、发行日期、封面图片的url、内容的url(杂志的内容可以是pdf文件、epub文件或者zip压缩包)。特别是id,在整个杂志的生命周期中都必须存在(比如,名称可以由于地区不同而改变,但id明显不行)。此外,id还用于让Newsstand识别一本唯一在刊物(通过NKIssue的name字段),也用于将产品和AppStore关联(如果要实现应用内购买的话)。
除此之外,Issue类在杂志下载过程中也很重要。Store类负责启动下载,但Issue类要负责监控进度,当下载完成时进行安装。
最后,Issue类还能表示一本杂志是否已经下载以及是否存在于用户的图书库中。Issue类有个isIssueAvailableForRead方法,用于通知视图是否允许对期刊进行某些操作(阅读或下载),并显示相应内容。
Store类是app中的控制器类。它在应用程序启动就初始化,而且一直不释放。这个类一初始化就开始从出版商的服务器获取商品(杂志)列表。这里我们用一个简单的plist文件实现了杂志列表,对它进行解码(反序列化)并创建Issue对象,下载它们的封面图片。所有这些都使用GCD来异步执行,当杂志列表就绪,向所有相关对象(尤其是ViewController)发送消息通知,以便UI更新。注意,status属性用于表示书店状态。我们重写了它的setter方法,在这个方法中张贴状态更新通知。为简单起见,我们将状态值限定为“未初始化”、“正在下载”、“就绪”及“错误”。根据需要你可以增加额外的状态。最后,当连接不可用时,我们从本地的plist文件加载书店数据(哪怕用户不连接互联网,也能够访问他下载过的内容)。
downloadStoreIssues方法是类的核心代码,我们列出如下:
-(void)downloadStoreIssues{
self.status=StoreStatusDownloading;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH,0), ^{
NSArray *_list = [[NSArray alloc]initWithContentsOfURL:[NSURLURLWithString:@"http://www.viggiosoft.com/media/data/iosblog/magazine/store.plist"]];
if(!_list) {
// let's try to retrieve it locally
_list = [[NSArray alloc]initWithContentsOfURL:[self fileURLOfCachedStoreFile]];
}
if(_list) {
// now creating all issues andstoring in the storeIssues array
[_listenumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
NSDictionary*issueDictionary = (NSDictionary *)obj;
Issue *anIssue =[[Issue alloc] init];
anIssue.issueID=[issueDictionaryobjectForKey:@"ID"];
anIssue.title=[issueDictionary objectForKey:@"Title"];
anIssue.releaseDate=[issueDictionary objectForKey:@"Release date"];
anIssue.coverURL=[issueDictionary objectForKey:@"Cover URL"];
anIssue.downloadURL=[issueDictionary objectForKey:@"Download URL"];
anIssue.free=[(NSNumber*)[issueDictionary objectForKey:@"Free"] boolValue];
[anIssueaddInNewsstand];
[storeIssuesaddObject:anIssue];
[anIssue release];
// dispatch coverloading
if(![anIssuecoverImage]) {
dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSData *imgData = [NSData dataWithContentsOfURL:[NSURLURLWithString:anIssue.coverURL]];
if(imgData) {
[imgData writeToURL:[anIssue.contentURLURLByAppendingPathComponent:@"cover.png"] atomically:YES];
}
});
}
}];
// let's save the file locally
[_list writeToURL:[selffileURLOfCachedStoreFile] atomically:YES];
[_list release];
self.status=StoreStatusReady;
} else {
ELog(@"Store downloadfailed.");
storeIssues = nil;
self.status=StoreStatusError;
}
});
}
在这段代码中,我在plist下载之后立即开始封面图片的下载。这样做并不好,因为在刷新app 状态之前,如果网络状况较差,这个额外的网络通信会导致延迟出现。体验更好的做法是:在一个真实的app中,由于期刊的数目总是有限的,我们可以在后台下载封面图片,每当下完一个图片就通知UI进行更新。
视图控制器
UI将很快出现,它根据Store类发送的通知进行更新。当通知中心将Store类的“就绪”信号发送到UI时,所有的UI对象将被加载(在这里即CoverView类,一个UIView,仅包含最少的应用程序逻辑),然后向用户显示书架。
这是,app停止后台处理,等待用户输入。有两种可能:
- 如果杂志已经被下载,用户将看到“READ”按钮,点击该按钮可以阅读该杂志。本例中所有杂志都是pdf文件,我们利用iOS自带的Quick Look框架就足以显示pdf文件。
- 如果杂志尚未下载,用户将看到“DOWNLOAD”按钮,点击该按钮开始下载并显示进度条。下载完成,我们替换按钮是的文字,并隐藏进度条。
视图控制器依赖于Store类。为了获得刊物信息(期刊数目、每一期的细节),视图控制器通过简单API,而不是直接访问Store类的属性。
/* "numberOfIssues"is used to retrieve the number of issues in the store */
-(NSInteger)numberOfStoreIssues;
/* "issueAtIndex:" retrieves the issue at the given index */
-(Issue *)issueAtIndex:(NSInteger)index;
/* "issueWithID:" retrieves the issue with the given ID */
-(Issue *)issueWithID:(NSString *)issueID;
根据这个简单API以及期刊的属性,视图控制器将创建期刊视图(即CoverView)并放到屏幕上。
下载杂志
在一个杂志类应用程序中,有3个重要的问题:检索和显示书架的内容,下载并阅读杂志(在一个杂志类app中,一个好的PDF或epub阅读器是必须的;但本文的主题,是介绍杂志app的结构和相关技术,而阅读器更多地是与用户体验有关)。
依照我的想法,在下载杂志的过程中,用户必须完全不加干预,用户的动作必须完全不会影响下载的结果。现在有许多app都有这样一个缺点:他们放一个转轮在屏幕中央,用于让用户等待,并阻塞用户与UI对象交互。这种做法很简单,但不是一种良好的体验。在等待的过程中,用户可以阅读其他期刊,可以返回书店并决定暂时切换到别的app,或者最终关闭网络。Newsstand Kit提供了系统级别的方法,简化开发者的工作,同时提供了良好的用户体验。
一旦用户开始下载,视图控制器会发送一个现在请求给Store类。在下面的代码(scheduleDownloadOfIssue:方法)中,会生成网络请求并发送到后台。注意,我们根据iOS的本将代码分为两个部分。如果是iOS5,我们必须使用Newsstand——下载将被放入Newsstand队列中由系统进行管理;如果是iOS4,我们采用常规的基于NSOperation的方法:这种情况下,我们无法简单地获取下载内容的长度,因此在iOS4中进度条不可见。而Newsstand在下载完将在进度条上显示“forfree”。
-(void)scheduleDownloadOfIssue:(Issue*)issueToDownload {
NSString *downloadURL = [issueToDownload downloadURL];
NSURLRequest *downloadRequest = [NSURLRequestrequestWithURL:[NSURL URLWithString:downloadURL]];
if(isOS5()) {
// iOS5 : use Newsstand
NKIssue *nkIssue = [issueToDownloadnewsstandIssue];
NKAssetDownload *assetDownload = [nkIssueaddAssetWithRequest:downloadRequest];
[assetDownloaddownloadWithDelegate:issueToDownload];
} else {
// iOS4 : use NSOperation
NSURLConnection *conn = [NSURLConnectionconnectionWithRequest:downloadRequest delegate:issueToDownload];
NSInvocationOperation *op = [[NSInvocationOperationalloc] initWithTarget:self selector:@selector(startDownload:) object:conn];
if(!downloadQueue) {
downloadQueue = [[NSOperationQueuealloc] init];
downloadQueue.maxConcurrentOperationCount=1;
}
[downloadQueue addOperation:op];
[downloadQueue setSuspended:NO];
}
}
// iOS4 only
-(void)startDownload:(id)obj {
NSURLConnection *conn = (NSURLConnection *)obj;
[conn start];
}
在这两种情况中,我要强调一个事实:Store启动下载线程(operation),但它将此后的工作委托给其他类来实现,比如正在被下载的Issue对象。因此由Issue类来跟中下载进度直至结束。
Issue类将扮演Store类创建的下载线程的委托对象。使用和不使用Newsstand,委托协议是不相同的。使用Newsstand,你需要使用NSURLConnectionDownloadDelegate 协议。如果不使用Newsstand,则使用 NSURLConnectionDataDelegate协议——它派生自NSURLConnectionDelegate协议。二个协议的不同在于,前者下载到文件系统,后者仅是内存数据。 在第二种情况下,我们将整个下载内容存放在内存,只有下载结束才将它保存到磁盘——不要在最终产品中这样做,因为如果你下载的内容达到上百M时,这将导致程序崩溃。
为了使下载进度可视化并实时根据进度更新UI,我们决定让Store 控制器和任何UI组件分离。因此我们使用了KVO模型和通知。当视图控制器开始一个下载进程时,它将自己以及杂志视图(coverView)设置为download对象(Issue)的观察者,以便当下载结束(成功或失败)便可更新UI状态。
-(void)downloadIssue:(Issue*)issue updateCover:(CoverView *)cover {
cover.progress.alpha=1.0;
cover.button.alpha=0.0;
[issue addObserver:cover forKeyPath:@"downloadProgress"options:NSKeyValueObservingOptionNew context:NULL];
[[NSNotificationCenter defaultCenter] addObserver:selfselector:@selector(issueDidEndDownload:)name:ISSUE_END_OF_DOWNLOAD_NOTIFICATION object:issue];
[[NSNotificationCenter defaultCenter] addObserver:selfselector:@selector(issueDidFailDownload:)name:ISSUE_FAILED_DOWNLOAD_NOTIFICATION object:issue];
[[NSNotificationCenter defaultCenter] addObserver:coverselector:@selector(issueDidEndDownload:)name:ISSUE_END_OF_DOWNLOAD_NOTIFICATION object:issue];
[[NSNotificationCenter defaultCenter] addObserver:coverselector:@selector(issueDidFailDownload:)name:ISSUE_FAILED_DOWNLOAD_NOTIFICATION object:issue];
[_store scheduleDownloadOfIssue:issue];
}
当下载线程终止,cover view和视图控制器都需要从通知中心注销。 但是允许cover view继续监听进程状态,因此下载开始时,cover view就注册为Issue的downloadProgress属性的观察者。也就是说,每当该下载进度变化,coverview将收到 downloadPregress属性变化通知,有此更新progress bar状态(因此我们的UIProgressBar就有了一个根据后台状态变化的属性,即“下载进度”)。coverview在下载结束时注销它自己。
当下载结束,Issue对象将下载内容拷贝到最终的目标文件夹。使用Newsstand框架,这个目标文件夹由系统指定。而在iOS4中,目标文件夹为caches目录以便和iCloud兼容(iOS4不支持iCloud,但我们的app是同时运行在两种iOS版本中的,我们必须同时考虑到两种情况)。在使用Newsstand的情况下,我们也要更新Newsstand图标为该封面图片。下面的下载完成后的处理代码中,我们简单地使用一句代码实现了这一点(同时在最后也张贴了下载完成通知)。
-(void)connectionDidFinishDownloading:(NSURLConnection*)connection destinationURL:(NSURL *)destinationURL {
// copy the file to the destination directory
NSURL *finalURL = [[self contentURL]URLByAppendingPathComponent:@"magazine.pdf"];
ELog(@"Copying item from %@ to%@",destinationURL,finalURL);
[[NSFileManager defaultManager] copyItemAtURL:destinationURLtoURL:finalURL error:NULL];
[[NSFileManager defaultManager] removeItemAtURL:destinationURLerror:NULL];
// update Newsstand icon
[[UIApplication sharedApplication] setNewsstandIconImage:[selfcoverImage]];
// post notification
[self sendEndOfDownloadNotification];
}
结论
本文即将结束。我们花了很大的决心去让app兼容iOS4和iOS5。在代码里面你还会发现一些有趣的东西,例如,一个“Store Kit”的钩子:这是典型属于开发者的一个不成熟的想法,他们的杂志可能会用于上架销售。如果是这样,则将期刊价格存放到出版商服务器上没有丝毫意义,我们只能从一个地方上检索价格信息,那就是苹果商店。因此我们的app必须异步查询iTunes商店上所有出版物的价格,然后显示。代码中没有加入这部分,只是预留了一个钩子以便今后的扩展,如果读者需要,我会另外写一个扩展的教程。欢迎任何建议,以及对本项目的参与:
GitHubhosted code。欢迎将示例代码作为框架应用于任何app。如果你不喜欢这篇文章,至少你要喜欢里面的pdf文件:一些文学经典著作以及一个Django(一个web开源框架)手册。