利用 Grails 后端构建 Objective-C iOS 客户端
本指南演示了如何使用 Grails 作为使用 Objective-C 构建的 iOS 应用程序的后端
作者:塞尔吉奥·德尔·阿莫
Grails 版本 3.3.1
1 Grails 培训
Grails 培训 - 由创建并积极维护 Grails 框架的人员开发和提供的!
2 开始使用
在本指南中,您将构建一个 Grails 应用程序,它将用作公司内部网后端。它公开了公司公告的 JSON API。
此外,您将构建一个 iOS 应用程序,即内部网客户端,它使用后端提供的 JSON API。
本指南探讨了对不同 API 版本的支持。
2.1 需要具备的技能
要完成本指南,您需要具备以下技能
-
有一些时间
-
一个不错的文本编辑器或 IDE
-
安装了 JDK 1.7 或更高版本,并适当地配置了
JAVA_HOME
-
最新 Xcode 稳定版本。此指南是根据 Xcode 8.2.1 撰写的
2.2 指南完成方法
若要完成此指南,您需要从 Github 检出源代码,并按照指南给出的步骤进行操作。
以下为入门步骤
若要进行 Grails 部分操作
-
cd
进入grails-guides/building-an-ios-objectc-client-powered-by-a-grails-backend/initial
或者,如果您已安装 Grails,则可以在终端窗口中使用以下命令创建新的应用程序
$ grails create-app intranet.backend.grails-app --profile rest-api $ cd grails-app
当 create-app 命令完成操作时,Grails 会创建一个 grails-app 目录,其中包含已配置为创建 REST 应用程序(由于使用了 profile=rest-api 参数)并配置为使用 H2 数据库的 Hibernate 功能的应用程序。
如果您 cd 到 grails-guides/building-an-ios-objectc-client-powered-by-a-grails-backend/complete ,则可以看到已完成的 Grails 示例 |
若要进行 iOS 部分操作
-
cd
进入grails-guides/building-an-ios-objectc-client-powered-by-a-grails-backend/initial-objectivec-ios
-
转到下一部分
或者,您可以按照下一部分的屏幕截图所示,使用 Xcode Studio 的 New Project Wizard(新项目向导)创建 iOS 应用程序
如果您 cd 到 grails-guides/building-an-ios-objectc-client-powered-by-a-grails-backend/complete-objectivec-ios-v1 ,则可以直接转到 iOS 示例的完成版本 1 |
如果您 cd 到 grails-guides/building-an-ios-objectc-client-powered-by-a-grails-backend/complete-objectivec-ios-v2 ,则可以直接转到 iOS 示例的完成版本 2 |
3 概览
下一张图片展示了 iOS 应用程序版本 1 的行为。iOS 应用程序由两个视图控制器组成。
-
当 iOS 应用程序初始屏幕加载时,它会请求公告列表。
-
Grails 应用程序发送一个 JSON 有效负载,其中包含公告列表。对于每份公告,都会包含一个唯一标识符、一个标题和一个 HTML 正文。
-
iOS 应用程序在一个 UITableView 中呈现 JSON 有效负载
-
用户点击公告的标题,则该应用程序会以渐进的方式转到详细屏幕。初始屏幕向详细屏幕发送公告标识符、标题和 HTML 正文。后者会在一个 UIWebView 中呈现
4 编写 Grails 应用程序
现在您可以开始编写 Grails 应用程序了。
4.1 创建领域类 - 持久实体
我们需要创建持久化实体来存储公司公告。Grails 使用Grails Domain Classes进行持久化处理。
领域类在模型视图控制器 (MVC) 模式中实现了 M,并且表示映射到基础数据库表上的持久化实体。在 Grails 中,领域类是位于 grails-app/domain 目录中的类。
Grails 通过create-domain-class command 简化了领域类的创建。
./grailsw create-domain-class Announcement
| Resolving Dependencies. Please wait...
CONFIGURE SUCCESSFUL
Total time: 4.53 secs
| Created grails-app/grails/company/intranet/Announcement.groovy
| Created src/test/groovy/grails/company/intranet/AnnouncementSpec.groovy
为了保持简单,我们假设公司公告仅包含一个标题和一个HTML 正文。我们将修改在上一步中生成的领域类来存储该信息。
package intranet.backend
class Announcement {
String title
String body
static constraints = {
title size: 0..255
body nullable: true
}
static mapping = {
body type: 'text' (1)
}
}
1 | 它使我们能够在正文中存储超过 255 个字符的字符串。 |
4.2 领域类单元测试
Grails 使测试变得更加容易,从低级的单元测试到高级的功能测试。
我们将在约束属性中测试在 Announcement 领域类中定义的约束。具体来说,测试标题和正文属性的空值性和长度。
package intranet.backend
import grails.testing.gorm.DomainUnitTest
import spock.lang.Specification
class AnnouncementSpec extends Specification implements DomainUnitTest<Announcement> {
void "test body can be null"() {
expect:
new Announcement(body: null).validate(['body'])
}
void "test title can not be null"() {
expect:
!new Announcement(title: null).validate(['title'])
}
void "test body can have a more than 255 characters"() {
when: 'for a string of 256 characters'
String str = ''
256.times { str += 'a' }
then: 'body validation passes'
new Announcement(body: str).validate(['body'])
}
void "test title can have a maximum of 255 characters"() {
when: 'for a string of 256 characters'
String str = ''
256.times { str += 'a' }
then: 'title validation fails'
!new Announcement(title: str).validate(['title'])
when: 'for a string of 256 characters'
str = ''
255.times { str += 'a' }
then: 'title validation passes'
new Announcement(title: str).validate(['title'])
}
}
我们可以使用 test_app 命令运行所有的测试,包括我们刚刚创建的测试。
./grailsw test-app
| Resolving Dependencies. Please wait...
CONFIGURE SUCCESSFUL
Total time: 2.534 secs
:complete:compileJava UP-TO-DATE
:complete:compileGroovy
:complete:buildProperties
:complete:processResources
:complete:classes
:complete:compileTestJava UP-TO-DATE
:complete:compileTestGroovy
:complete:processTestResources UP-TO-DATE
:complete:testClasses
:complete:test
:complete:compileIntegrationTestJava UP-TO-DATE
:complete:compileIntegrationTestGroovy UP-TO-DATE
:complete:processIntegrationTestResources UP-TO-DATE
:complete:integrationTestClasses UP-TO-DATE
:complete:integrationTest UP-TO-DATE
:complete:mergeTestReports
BUILD SUCCESSFUL
| Tests PASSED
4.3 版本控制
从一开始就考虑 API 版本控制非常重要,尤其是在您创建由移动电话应用程序使用的 API 时。用户将运行应用程序的不同版本,您需要对 API 进行版本控制以创建高级功能,但同时保持对旧版本的支持。
Grails 允许多种版本化 REST 资源的方式。
-
使用 URI
-
使用 Accept-Version 头
-
使用超文本媒体/MIME 类型
在本指南中,我们将使用 Accept-Version 头对 API 进行版本控制。
运行版本 1.0 的设备将调用公告端点,并在Accept Version
HTTP 头中传递 1.0。
$ curl -i -H "Accept-Version: 1.0" -X GET http://localhost:8080/announcements
运行版本 2.0 的设备将调用公告端点,并在接受版本头中传递 2.0。
$ curl -i -H "Accept-Version: 2.0" -X GET http://localhost:8080/announcements
4.4 创建控制器
我们为之前创建的领域类创建一个控制器。我们的控制器扩展RestfulController。这将为我们提供 RESTful 功能来列出、创建、更新和删除Announcement
资源,使用不同的 HTTP 方法。
package intranet.backend.v1
import grails.rest.RestfulController
import intranet.backend.Announcement
class AnnouncementController extends RestfulController<Announcement> {
static namespace = 'v1' (1)
static responseFormats = ['json'] (2)
AnnouncementController() {
super(Announcement)
}
}
1 | 该控制器将处理我们的 api 的 v1 |
2 | 我们只想响应 JSON 有效负载 |
Url 映射
我们希望我们的端点监听于 /announcements 而非 /announcement。此外,我们希望将之前的控制器(我们已为其声明命名空间 v1)与设置了接受版本 HTTP 标头为 1.0 的请求一起处理。
Grails 允许强大的 URL 映射 配置来完成这项工作。将下一行添加到映射闭包中
get "/announcements"(version:'1.0', controller: 'announcement', namespace:'v1')
4.5 加载测试数据
将在应用程序启动时使用一些公告填充数据库。
为了这样做,我们编辑 grails-app/init/grails/company/intranet/BootStrap.groovy。
package grails.company.intranet
class BootStrap {
def init = { servletContext ->
announcements().each { it.save() }
}
def destroy = {
}
static List<Announcement> announcements() {
[
new Announcement(title: 'Grails Quickcast #1: Grails Interceptors'),
new Announcement(title: 'Grails Quickcast #2: JSON Views')
]
}
}
前一个代码片段中的公告没有包含 body 内容,以使代码示例保持简小。签出 grails-app/init/intranet/backend/BootStrap.groovy 以了解完整代码。 |
4.6 功能测试
功能测试涉及对正在运行的应用程序发出 HTTP 请求并验证由此导致的行为。
我们使用 Rest Client Builder Grails 插件,在使用 rest-api 配置文件创建应用程序时,会添加 dessen 依赖项。
/home/runner/work/building-an-ios-objectc-client-powered-by-a-grails-backend/building-an-ios-objectc-client-powered-by-a-grails-backend/complete
package intranet.backend
import grails.plugins.rest.client.RestBuilder
import grails.testing.mixin.integration.Integration
import org.springframework.beans.factory.annotation.Value
import spock.lang.Specification
import javax.servlet.http.HttpServletResponse
@Integration
class AnnouncementControllerSpec extends Specification {
def "test body is present in announcements json payload of Api 1.0"() {
given:
RestBuilder rest = new RestBuilder()
when: 'Requesting announcements for version 1.0'
def resp = rest.get("http://localhost:${serverPort}/announcements/") { (1)
header("Accept-Version", "1.0") (2)
}
then: 'the request was successful'
resp.status == HttpServletResponse.SC_OK (3)
and: 'the response is a JSON Payload'
resp.headers.get('Content-Type') == ['application/json;charset=UTF-8']
and: 'json payload contains an array of annoucements with id, title and body'
resp.json.each {
assert it.id
assert it.title
assert it.body (4)
}
}
def "test body is NOT present in announcements json payload of Api 2.0"() {
given:
RestBuilder rest = new RestBuilder()
1 | serverPort 属性自动注入。它包含随机端口,Grails 应用程序在功能测试期间在此端口运行 |
2 | 将 API 版本作为 HTTP 标头传递 |
3 | 验证响应代码是 200;OK |
4 | 主体存在于 JSON paylod 中 |
Grails 命令 test-app 运行单元、集成和功能测试。
4.7 运行应用程序
要运行应用程序,可以使用 ./gradlew bootRun
命令,该命令将在端口 8080 上启动应用程序。
5 编写 iOS 应用程序
5.1 获取公告
下一张图片展示了获取和渲染 Grails 应用程序公开的公告时所涉及的类
5.2 模型
服务器发送的公告将被渲染到对象中
#import <Foundation/Foundation.h>
@interface Announcement : NSObject
@property (nonatomic, copy)NSNumber *primaryKey;
@property (nonatomic, copy)NSString *title;
@property (nonatomic, copy)NSString *body;
@end
#import "Announcement.h"
@implementation Announcement
@end
5.3 Json 至模型
要从 JSON 字符串构建模型对象,我们使用几个生成器类。
#import <Foundation/Foundation.h>
#import "ElementBuilder.h"
extern NSString *kAnnouncementBuilderErrorDomain;
enum {
kAnnouncementBuilderInvalidJSONError,
kAnnouncementBuilderMissingDataError,
};
@interface AnnouncementBuilder : ElementBuilder
- (NSArray *)announcementsFromJSON:(NSString *)objectNotation
error:(NSError **)error;
@end
#import "AnnouncementBuilder.h"
#import "Announcement.h"
static NSString *kJSONKeyId = @"id";
static NSString *kJSONKeyTitle = @"title";
static NSString *kJSONKeyBody = @"body";
@implementation AnnouncementBuilder
- (NSArray *)announcementsFromJSON:(NSString *)objectNotation
error:(NSError **)error {
return [super arrayFromJSON:objectNotation
key:nil
error:error
invalidJSONErrorCode:kAnnouncementBuilderInvalidJSONError
missingDataErrorCode:kAnnouncementBuilderMissingDataError
errorDomain:kAnnouncementBuilderErrorDomain];
}
- (id)newElementWithDictionary:(NSDictionary *)dict
error:(NSError **)error
invalidJSONErrorCode:(NSInteger)invalidJSONErrorCode
missingDataErrorCode:(NSInteger)missingDataErrorCode
errorDomain:(NSString *)errorDomain {
Announcement *announcement = [[Announcement alloc] init];
if([[dict objectForKey:kJSONKeyId] isKindOfClass:[NSNumber class]]) {
announcement.primaryKey = (NSNumber *)[dict objectForKey:kJSONKeyId];
} else {
if(error != NULL) {
*error = [self invalidJsonError];
}
return nil;
}
if([[dict objectForKey:kJSONKeyTitle] isKindOfClass:[NSString class]]) {
announcement.title = (NSString *)[dict objectForKey:kJSONKeyTitle];
} else {
if(error != NULL) {
*error = [self invalidJsonError];
}
return nil;
}
if([[dict objectForKey:kJSONKeyBody] isKindOfClass:[NSString class]]) {
announcement.body = (NSString *)[dict objectForKey:kJSONKeyBody];
}
return announcement;
}
- ( NSError *)invalidJsonError {
NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithCapacity:1];
return [NSError errorWithDomain:kAnnouncementBuilderErrorDomain
code:kAnnouncementBuilderInvalidJSONError
userInfo:userInfo];
}
@end
NSString *kAnnouncementBuilderErrorDomain = @"kAnnouncementBuilderErrorDomain";
#import <Foundation/Foundation.h>
@interface ElementBuilder : NSObject
- (NSArray *)arrayFromJSON:(NSString *)objectNotation
key:(NSString *)key
error:(NSError **)error
invalidJSONErrorCode:(NSInteger)invalidJSONErrorCode
missingDataErrorCode:(NSInteger)missingDataErrorCode
errorDomain:(NSString *)errorDomain;
@end
#import "ElementBuilder.h"
@implementation ElementBuilder
- (NSArray *)arrayFromJSON:(NSString *)objectNotation
key:(NSString *)key
error:(NSError **)error
invalidJSONErrorCode:(NSInteger)invalidJSONErrorCode
missingDataErrorCode:(NSInteger)missingDataErrorCode
errorDomain:(NSString *)errorDomain {
id parsedObject = [self parseJSON:objectNotation
error:error
invalidJSONErrorCode:invalidJSONErrorCode
errorDomain:errorDomain];
NSArray *elements = nil;
if ( [parsedObject isKindOfClass:[NSDictionary class]]) {
elements = [((NSDictionary *)parsedObject) objectForKey:key];
} else if ( [parsedObject isKindOfClass:[NSArray class]] ) {
elements = (NSArray *)parsedObject;
}
if (elements == nil) {
if (error != NULL) {
*error = [NSError errorWithDomain:errorDomain code:missingDataErrorCode userInfo:nil];
}
return nil;
}
NSMutableArray *results = [NSMutableArray arrayWithCapacity:[elements count]];
for (NSDictionary *parsedEl in elements) {
id el = [self newElementWithDictionary:parsedEl
error:error
invalidJSONErrorCode:invalidJSONErrorCode
missingDataErrorCode:missingDataErrorCode
errorDomain:errorDomain];
// Return nil becuase there has been an error in the previous method call
if(!el) {
return nil;
}
if([self isElementValid:el]) {
[results addObject:el];
}
}
return [results copy];
}
- (BOOL)isElementValid:(id)el {
// This may be overriden in a subclass
return YES;
}
- (id)newElementWithDictionary:(NSDictionary *)dict
error:(NSError **)error
invalidJSONErrorCode:(NSInteger)invalidJSONErrorCode
missingDataErrorCode:(NSInteger)missingDataErrorCode
errorDomain:(NSString *)errorDomain {
@throw [NSException exceptionWithName:NSInternalInconsistencyException
reason:[NSString stringWithFormat:@"You must override %@ in a subclass", NSStringFromSelector(_cmd)]
userInfo:nil];
}
- (id)parseJSON:(NSString *)objectNotation
error:(NSError **)error
invalidJSONErrorCode:(NSInteger)invalidJSONErrorCode
errorDomain:(NSString *)errorDomain {
NSParameterAssert(objectNotation != nil);
id jsonObject;
NSError *localError = nil;
if(objectNotation != nil) {
NSData *unicodeNotation = [objectNotation dataUsingEncoding:NSUTF8StringEncoding];
jsonObject = [NSJSONSerialization JSONObjectWithData:unicodeNotation
options:0
error:&localError];
}
if (jsonObject == nil) {
if (error != NULL) {
NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithCapacity:1];
if (localError != nil) {
[userInfo setObject:localError forKey:NSUnderlyingErrorKey];
}
*error = [NSError errorWithDomain:errorDomain code:invalidJSONErrorCode userInfo:userInfo];
}
return nil;
}
return jsonObject;
}
+ (id)objectOrNull:(id)object {
if (!object || object == [NSNull null]) return nil;
return object;
}
+ (BOOL)isNotNilAndNotArrayTheKey:(NSString *)key atDict:(NSDictionary *)dict {
return [self isNotNilTheKey:key atDict:dict] && ![self isArrayTheKey:key atDict:dict];
}
+ (BOOL)isNotNilAndNotNumberTheKey:(NSString *)key atDict:(NSDictionary *)dict {
return [self isNotNilTheKey:key atDict:dict] && ![self isNumberTheKey:key atDict:dict];
}
+ (BOOL)isNotNilAndNotStringTheKey:(NSString *)key atDict:(NSDictionary *)dict {
return [self isNotNilTheKey:key atDict:dict] && ![self isStringTheKey:key atDict:dict];
}
+ (BOOL)isNotNilTheKey:(NSString *)key atDict:(NSDictionary *)dict {
if(dict[key] != (id)[NSNull null] && dict[key]) {
return YES;
}
return NO;
}
+ (BOOL)isStringTheKey:(NSString *)key atDict:(NSDictionary *)dict {
if(dict[key] != (id)[NSNull null] && dict[key] && [dict[key] isKindOfClass:[NSString class]]) {
return YES;
}
return NO;
}
+ (BOOL)isNumberTheKey:(NSString *)key atDict:(NSDictionary *)dict {
if(dict[key] != (id)[NSNull null] && dict[key] && [dict[key] isKindOfClass:[NSNumber class]]) {
return YES;
}
return NO;
}
+ (BOOL)isArrayTheKey:(NSString *)key atDict:(NSDictionary *)dict {
if(dict[key] != (id)[NSNull null] && dict[key] && [dict[key] isKindOfClass:[NSArray class]]) {
return YES;
}
return NO;
}
@end
5.4 应用程序传输安全性
我们将连接到运行 Grails 服务器的本地计算机。我们需要禁用 应用程序传输安全性,如下图所示
5.5 网络代码
我们使用 NSURLSession 连接到 Grails API。一些常量设置在 GrailsFetcher 中
#import <Foundation/Foundation.h>
static NSString *kServerUrl = @"http://192.168.1.40:8080";
static NSString *kApiVersion = @"1.0";
static NSString *kAnnouncementsResourcePath = @"announcements";
static NSInteger FAST_TIME_INTERVAL = 5.0;
@interface GrailsFetcher : NSObject
@property (nonatomic, strong) NSURLSession *session;
- (NSURLRequest *)getURLRequestWithUrlString:(NSString *)urlString
cachePolicy:(NSURLRequestCachePolicy)cachePolicy
timeoutInterval:(NSTimeInterval)timeoutInterval;
@end
1 | Grails App 服务器 URL |
2 | 我们在 Grails app 中UrlMappings.groovy中配置的路径 |
3 | API 版本 |
您可能需要更改 IP 地址以匹配您的本地计算机。 |
#import "GrailsFetcher.h"
@interface GrailsFetcher () <NSURLSessionDelegate>
@end
@implementation GrailsFetcher
#pragma mark - LifeCycle
- (id)init {
if(self = [super init]) {
NSURLSessionConfiguration* configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
self.session = [NSURLSession sessionWithConfiguration:configuration
delegate:self
delegateQueue:[NSOperationQueue mainQueue]];
}
return self;
}
#pragma mark - Public
- (NSURLRequest *)getURLRequestWithUrlString:(NSString *)urlString
cachePolicy:(NSURLRequestCachePolicy)cachePolicy
timeoutInterval:(NSTimeInterval)timeoutInterval {
NSURL *url = [NSURL URLWithString:urlString];
NSMutableURLRequest *urlRequest = [NSMutableURLRequest requestWithURL:url
cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
timeoutInterval:FAST_TIME_INTERVAL];
[urlRequest setHTTPMethod:@"GET"];
[[self headers] enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL * stop) {
[urlRequest setValue:obj forHTTPHeaderField:key];
}];
return urlRequest;
}
#pragma mark - Private
- (NSDictionary *)headers {
return @{@"Accept-Version": kApiVersion}; (1)
}
@end
1 | 我们为每个请求都设置Accept-Version Http 标头。 |
#import <Foundation/Foundation.h>
#import "GrailsFetcher.h"
@protocol AnnouncementsFetcherDelegate;
@interface AnnouncementsFetcher : GrailsFetcher
- (id)initWithDelegate:(id<AnnouncementsFetcherDelegate>)delegate;
- (void)fetchAnnouncements;
@end
#import "AnnouncementsFetcher.h"
#import "AnnouncementsFetcherDelegate.h"
#import "AnnouncementBuilder.h"
@interface AnnouncementsFetcher ()
@property ( nonatomic, weak) id<AnnouncementsFetcherDelegate> delegate;
@property ( nonatomic, strong) AnnouncementBuilder *builder;
@end
@implementation AnnouncementsFetcher
- (id)initWithDelegate:(id<AnnouncementsFetcherDelegate>)delegate {
if(self = [super init]) {
self.delegate = delegate;
self.builder = [[AnnouncementBuilder alloc] init];
}
return self;
}
- (void)fetchAnnouncements {
[self fetchAnnouncementsWithCompletionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if(error) {
if ( self.delegate ) {
[self.delegate announcementsFetchingFailed];
}
return;
}
NSInteger statusCode = [((NSHTTPURLResponse*)response) statusCode];
if(statusCode != 200) {
if ( self.delegate ) {
[self.delegate announcementsFetchingFailed];
}
return;
}
NSString *objectNotation = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
NSError *builderError;
NSArray *announcements = [self.builder announcementsFromJSON:objectNotation error:&builderError];
if(builderError) {
if ( self.delegate ) {
[self.delegate announcementsFetchingFailed];
}
return;
}
if ( self.delegate ) {
[self.delegate announcementsFetched:announcements];
}
}];
}
- (void)fetchAnnouncementsWithCompletionHandler:(void (^)(NSData * data, NSURLResponse * response, NSError * error))completionHandler {
NSURLSessionDataTask *dataTask = [self.session dataTaskWithRequest:[self announcementsURLRequest] completionHandler:completionHandler];
[dataTask resume];
}
- (NSURLRequest *)announcementsURLRequest {
NSString *urlStr = [self announcementsURLString];
return [super getURLRequestWithUrlString:urlStr
cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
timeoutInterval:FAST_TIME_INTERVAL];
}
- (NSString *)announcementsURLString {
return [NSString stringWithFormat:@"%@/%@", kServerUrl, kAnnouncementsResourcePath];
}
@end
一旦我们得到公告列表,我们就会与实现委托的类通信
#import <Foundation/Foundation.h>
@protocol AnnouncementsFetcherDelegate <NSObject>
- (void)announcementsFetchingFailed;
- (void)announcementsFetched:(NSArray *)announcements;
@end
MasterViewController 实现 fetcher 委托协议,因此它接收公告
#import <UIKit/UIKit.h>
@class DetailViewController;
@interface MasterViewController : UITableViewController
@property (strong, nonatomic) DetailViewController *detailViewController;
@end
#import "MasterViewController.h"
#import "DetailViewController.h"
#import "AnnouncementsFetcherDelegate.h"
#import "AnnouncementsFetcher.h"
#import "AnnouncementsTableViewDataSource.h"
#import "AnnouncementsTableViewDelegate.h"
#import "Announcement.h"
static NSString *kSegueShowDetail = @"showDetail";
@interface MasterViewController () <AnnouncementsFetcherDelegate>
@property NSMutableArray *objects;
@property ( nonatomic, strong ) AnnouncementsFetcher *fetcher;
@property ( nonatomic, strong) id<UITableViewDataSource> tableViewDataSource;
@property ( nonatomic, strong) id<UITableViewDelegate> tableViewDelegate;
@end
@implementation MasterViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.tableViewDataSource = [[AnnouncementsTableViewDataSource alloc] init];
self.tableViewDelegate = [[AnnouncementsTableViewDelegate alloc] init];
self.tableView.dataSource = self.tableViewDataSource;
self.tableView.delegate = self.tableViewDelegate;
}
- (void)viewWillAppear:(BOOL)animated {
self.clearsSelectionOnViewWillAppear = self.splitViewController.isCollapsed;
[super viewWillAppear:animated];
[self registerNotifications];
[self fetchAnnouncements]; (1)
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
[self unregisterNotifications];
}
#pragma mark - Notifications
-(void)registerNotifications {
NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
[nc addObserver:self selector:@selector(announcementTapped:) name:kAnnouncementTappedNotification object:nil];
}
- (void)unregisterNotifications {
NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
[nc removeObserver:self name:kAnnouncementTappedNotification object:nil];
}
#pragma mark - Segues
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
if ([[segue identifier] isEqualToString:kSegueShowDetail]) {
DetailViewController *controller = (DetailViewController *)[[segue destinationViewController] topViewController];
if([sender isKindOfClass:[Announcement class]]) {
Announcement *announcement = (Announcement *)sender;
controller.announcement = announcement;
}
controller.navigationItem.leftBarButtonItem = self.splitViewController.displayModeButtonItem;
controller.navigationItem.leftItemsSupplementBackButton = YES;
}
}
#pragma mark - Private Methods
- (void)announcementTapped:(NSNotification *)notification {
if([[notification object] isKindOfClass:[Announcement class]]) {
Announcement *announcement = (Announcement *)[notification object];
[self performSegueWithIdentifier:kSegueShowDetail sender:announcement];
}
}
- (void)setNetworkActivityIndicator:(BOOL)visible {
[[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:visible];
}
- (void)fetchAnnouncements {
[self setNetworkActivityIndicator:YES];
[self.fetcher fetchAnnouncements];
}
#pragma mark - AnnouncementsFetcherDelegate
- (void)announcementsFetchingFailed {
[self setNetworkActivityIndicator:NO];
}
- (void)announcementsFetched:(NSArray *)announcements {
[self setNetworkActivityIndicator:NO];
if ( [self.tableViewDataSource isKindOfClass:[AnnouncementsTableViewDataSource class]]) {
((AnnouncementsTableViewDataSource *)self.tableViewDataSource).announcements = announcements;
}
if ( [self.tableViewDelegate isKindOfClass:[AnnouncementsTableViewDelegate class]]) {
((AnnouncementsTableViewDelegate *)self.tableViewDelegate).announcements = announcements;
}
[self.tableView reloadData]; (2)
}
#pragma mark - Lazy
- (AnnouncementsFetcher *)fetcher {
if(!_fetcher) {
_fetcher = [[AnnouncementsFetcher alloc] initWithDelegate:self];
}
return _fetcher;
}
@end
1 | 触发公告抓取 |
2 | 在我们得到公告列表后刷新 UI |
MasterViewController 将其 UITableView 的数据源和委托设置为以下类
#import <UIKit/UIKit.h>
@interface AnnouncementsTableViewDataSource : NSObject <UITableViewDataSource>
@property (nonatomic, strong) NSArray *announcements;
@end
#import "AnnouncementsTableViewDataSource.h"
#import "Announcement.h"
@implementation AnnouncementsTableViewDataSource
#pragma mark - UITableViewDataSource
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return [self.announcements count];
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath];
if ( [self.announcements count] > indexPath.row ) {
id obj = self.announcements[indexPath.row];
if ( [obj isKindOfClass:[Announcement class]]) {
Announcement *announcement = (Announcement *)obj;
cell.textLabel.text = announcement.title;
}
}
return cell;
}
@end
#import <UIKit/UIKit.h>
static NSString *kAnnouncementTappedNotification = @"AnnouncementTappedNotification";
@interface AnnouncementsTableViewDelegate : NSObject <UITableViewDelegate>
@property (nonatomic, strong) NSArray *announcements;
@end
#import "AnnouncementsTableViewDelegate.h"
#import "Announcement.h"
@implementation AnnouncementsTableViewDelegate
#pragma mark - UITableViewDelegate
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
if ( [self.announcements count] > indexPath.row ) {
id obj = self.announcements[indexPath.row];
if ( [obj isKindOfClass:[Announcement class]]) {
Announcement *announcement = (Announcement *)obj;
[[NSNotificationCenter defaultCenter] postNotificationName:kAnnouncementTappedNotification (1)
object:announcement
userInfo:nil];
}
}
}
@end
1 | 当用户点击公告时,会发出一个NSNotification。此信息会在MasterViewController中被捕获,并启动转至 DetailViewController 的链接 |
5.6 详细信息视图控制器
当用户点击公告时,会发布一个包含所点击公告的NSNotification。在MasterViewController的prepareForSegue:sender方法中,我们设置DetailViewController的公告属性
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
if ([[segue identifier] isEqualToString:kSegueShowDetail]) {
DetailViewController *controller = (DetailViewController *)[[segue destinationViewController] topViewController];
if([sender isKindOfClass:[Announcement class]]) {
Announcement *announcement = (Announcement *)sender;
controller.announcement = announcement;
}
controller.navigationItem.leftBarButtonItem = self.splitViewController.displayModeButtonItem;
controller.navigationItem.leftItemsSupplementBackButton = YES;
}
}
为了呈现公告,我们使用UILabel 和 UIWebView,我们将其连接到 StoryBoard 中的 IBOutlets,如下图所示
这是完整的DetailViewController代码。不涉及任何网络代码。
#import <UIKit/UIKit.h>
#import "Announcement.h"
@interface DetailViewController : UIViewController
@property (nonatomic, strong) Announcement *announcement;
@end
#import "DetailViewController.h"
#import "Announcement.h"
@interface DetailViewController () <UIWebViewDelegate>
@property ( nonatomic, weak) IBOutlet UILabel *titleLabel;
@property ( nonatomic, weak) IBOutlet UIWebView *webView;
@property (weak, nonatomic) IBOutlet UIActivityIndicatorView *activityIndicatorView;
@end
@implementation DetailViewController
- (void)configureView {
// Update the user interface for the detail item.
if (self.announcement) {
self.titleLabel.text = self.announcement.title;
[[self activityIndicatorView] startAnimating];
[self.webView loadHTMLString:self.announcement.body baseURL:nil];
}
}
- (void)viewDidLoad {
[super viewDidLoad];
self.webView.delegate = self;
// Do any additional setup after loading the view, typically from a nib.
[self configureView];
}
#pragma mark - Managing the detail item
- (void)setAnnouncement:(Announcement *)announcement {
if (_announcement != announcement) {
_announcement = announcement;
// Update the view.
[self configureView];
}
}
#pragma mark - UIWebViewDelegate
- (void)webViewDidFinishLoad:(UIWebView *)webView {
[[self activityIndicatorView] stopAnimating];
}
- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error {
[[self activityIndicatorView] stopAnimating];
}
@end
6 API 版本 2.0
API 第一个版本的问题在于,我们会将每个公告正文都包括在用于显示列表的有效负载中。公告正文可以是一大块 HTML。用户可能只想检查几个公告。如果我们在初始请求中不发送公告正文,可以节省带宽,让应用运行得更快。相反,一旦用户点击公告,我们将向 API 索取一个完整的公告(包括body
)。
6.1 Grails V2 更改
我们将创建一个新的控制器来处理 API 的版本 2。我们将使用条件查询和一个投影来仅抓取公告的 ID 和标题。
package intranet.backend.v2
import grails.rest.RestfulController
import intranet.backend.Announcement
class AnnouncementController extends RestfulController<Announcement> {
static namespace = 'v2'
static responseFormats = ['json']
def announcementService
AnnouncementController() {
super(Announcement)
}
def index(Integer max) {
params.max = Math.min(max ?: 10, 100)
def announcements = announcementService.findAllIdAndTitleProjections(params)
respond announcements, model: [("${resourceName}Count".toString()): countResources()]
}
}
我们将查询封装在一个服务中
package intranet.backend
import grails.transaction.Transactional
@Transactional(readOnly = true)
class AnnouncementService {
List<Map> findAllIdAndTitleProjections(Map params) {
def c = Announcement.createCriteria()
def announcements = c.list(params) {
projections {
property('id')
property('title')
}
}.collect { [id: it[0], title: it[1]] } as List<Map>
}
}
然后对其进行测试
package intranet.backend
import grails.test.hibernate.HibernateSpec
import grails.testing.services.ServiceUnitTest
class AnnouncementServiceSpec extends HibernateSpec implements ServiceUnitTest<AnnouncementService> {
def "test criteria query with projection returns a list of maps"() {
when: 'Save some announcements'
[new Announcement(title: 'Grails Quickcast #1: Grails Interceptors'),
new Announcement(title: 'Grails Quickcast #2: JSON Views'),
new Announcement(title: 'Grails Quickcast #3: Multi-Project Builds'),
new Announcement(title: 'Grails Quickcast #4: Angular Scaffolding'),
new Announcement(title: 'Retrieving Runtime Config Values In Grails 3'),
new Announcement(title: 'Developing Grails 3 Applications With IntelliJ IDEA')].each {
it.save()
}
then: 'announcements are saved'
Announcement.count() == 6
when: 'fetching the projection'
def resp = service.findAllIdAndTitleProjections([:])
then: 'there are six maps in the response'
resp
resp.size() == 6
and: 'the maps contain only id and title'
resp.each {
it.keySet() == ['title', 'id'] as Set<String>
}
and: 'non empty values'
resp.each {
assert it.title
assert it.id
}
}
}
Url 映射
我们需要将 Accept-Header 的版本2.0映射到命名空间v2
get "/announcements"(version:'2.0', controller: 'announcement', namespace:'v2')
get "/announcements/$id(.$format)?"(version:'2.0', controller: 'announcement', action: 'show', namespace:'v2')
6.2 Api 2.0 功能性测试
我们希望测试当收到一个 GET 请求到announcements端点时 Api 版本 2.0 不包括 body 属性。以下功能测试验证了此行为。
/home/runner/work/building-an-ios-objectc-client-powered-by-a-grails-backend/building-an-ios-objectc-client-powered-by-a-grails-backend/complete
package intranet.backend
import grails.plugins.rest.client.RestBuilder
import grails.testing.mixin.integration.Integration
import org.springframework.beans.factory.annotation.Value
import spock.lang.Specification
import javax.servlet.http.HttpServletResponse
@Integration
class AnnouncementControllerSpec extends Specification {
def "test body is present in announcements json payload of Api 1.0"() {
given:
RestBuilder rest = new RestBuilder()
when: 'Requesting announcements for version 2.0'
def resp = rest.get("http://localhost:${serverPort}/announcements/") {
header("Accept-Version", "2.0")
}
then: 'the request was successful'
resp.status == HttpServletResponse.SC_OK
and: 'the response is a JSON Payload'
resp.headers.get('Content-Type') == ['application/json;charset=UTF-8']
and: 'json payload contains an array of annoucements with id, title'
resp.json.each {
assert it.id
assert it.title
assert !it.body (2)
}
}
def "test detail of an announcement contains body in both version 1.0 and 2.0"() {
given:
1 | serverPort 属性自动注入。它包含随机端口,Grails 应用程序在功能测试期间在此端口运行 |
2 | 正文不显示在 JSON 内容中 |
Grails 命令 test-app 运行单元、集成和功能测试。
6.3 iOS V2 更改
首先,我们需要在GrailsFetcher.h中更改 Api 版本常量
#import <Foundation/Foundation.h>
static NSString *kServerUrl = @"http://192.168.1.40:8080";
static NSString *kApiVersion = @"2.0"; (1)
static NSString *kAnnouncementsResourcePath = @"announcements";
static NSInteger FAST_TIME_INTERVAL = 5.0;
@interface GrailsFetcher : NSObject
@property (nonatomic, strong) NSURLSession *session;
- (NSURLRequest *)getURLRequestWithUrlString:(NSString *)urlString
cachePolicy:(NSURLRequestCachePolicy)cachePolicy
timeoutInterval:(NSTimeInterval)timeoutInterval;
@end
1 | 使用 Api 版本 2.0 |
在版本 2.0 中,Api 不返回通告的 body。在DetailViewController中,我们将只设置资源标识符(主键),而不设置通告属性。我们已经改变了MasterViewController中的prepareForSegue:sender方法,如下所示
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
if ([[segue identifier] isEqualToString:kSegueShowDetail]) {
DetailViewController *controller = (DetailViewController *)[[segue destinationViewController] topViewController];
if([sender isKindOfClass:[Announcement class]]) {
Announcement *announcement = (Announcement *)sender;
controller.announcementPrimaryKey = announcement.primaryKey; (1)
}
controller.navigationItem.leftBarButtonItem = self.splitViewController.displayModeButtonItem;
controller.navigationItem.leftItemsSupplementBackButton = YES;
}
}
1 | 我们将设置一个NSNumber,而不是设置一个对象 |
DetailViewController向服务器询问完整的通告;包括正文。
#import <UIKit/UIKit.h>
#import "Announcement.h"
@interface DetailViewController : UIViewController
@property (nonatomic, assign) NSNumber *announcementPrimaryKey;
@end
#import "DetailViewController.h"
#import "Announcement.h"
#import "AnnouncementFetcher.h"
@interface DetailViewController () <UIWebViewDelegate, AnnouncementFetcherDelegate>
@property ( nonatomic, weak) IBOutlet UILabel *titleLabel;
@property ( nonatomic, weak) IBOutlet UIWebView *webView;
@property (weak, nonatomic) IBOutlet UIActivityIndicatorView *activityIndicatorView;
@property ( nonatomic, strong ) AnnouncementFetcher *fetcher;
@property (nonatomic, strong) Announcement *announcement;
@end
@implementation DetailViewController
- (void)configureView {
// Update the user interface for the detail item.
if (self.announcement) {
self.titleLabel.text = self.announcement.title;
[[self activityIndicatorView] startAnimating];
[self.webView loadHTMLString:self.announcement.body baseURL:nil];
}
}
- (void)viewDidLoad {
[super viewDidLoad];
self.webView.delegate = self;
// Do any additional setup after loading the view, typically from a nib.
[self configureView];
if ( self.announcementPrimaryKey ) {
[self.fetcher fetchAnnouncement:self.announcementPrimaryKey];
}
}
#pragma mark - Managing the detail item
- (void)setAnnouncement:(Announcement *)announcement {
if (_announcement != announcement) {
_announcement = announcement;
// Update the view.
[self configureView];
}
}
#pragma mark - UIWebViewDelegate
- (void)webViewDidFinishLoad:(UIWebView *)webView {
[[self activityIndicatorView] stopAnimating];
}
- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error {
[[self activityIndicatorView] stopAnimating];
}
#pragma mark - AnnouncementFetcherDelegate
- (void)announcementFetchingFailed {
[[self activityIndicatorView] stopAnimating];
}
- (void)announcementFetched:(Announcement *)announcement {
[[self activityIndicatorView] stopAnimating];
self.announcement = announcement;
}
#pragma mark - Lazy
- (AnnouncementFetcher *)fetcher {
if(!_fetcher) {
_fetcher = [[AnnouncementFetcher alloc] initWithDelegate:self];
}
return _fetcher;
}
@end
它使用一个新的抓取器
#import <Foundation/Foundation.h>
#import "GrailsFetcher.h"
#import "AnnouncementFetcherDelegate.h"
@interface AnnouncementFetcher : GrailsFetcher
- (id)initWithDelegate:(id<AnnouncementFetcherDelegate>)delegate;
- (void)fetchAnnouncement:(NSNumber *)primaryKey;
@end
#import "AnnouncementFetcher.h"
#import "AnnouncementFetcherDelegate.h"
#import "AnnouncementBuilder.h"
@interface AnnouncementFetcher ()
@property ( nonatomic, weak )id<AnnouncementFetcherDelegate> delegate;
@property ( nonatomic, strong )AnnouncementBuilder *builder;
@end
@implementation AnnouncementFetcher
#pragma mark - LifeCycle
- (id)initWithDelegate:(id<AnnouncementFetcherDelegate>)delegate {
if(self = [super init]) {
self.delegate = delegate;
self.builder = [[AnnouncementBuilder alloc] init];
}
return self;
}
#pragma mark - Public Methods
- (void)fetchAnnouncement:(NSNumber *)primaryKey {
[self fetchAnnouncement:primaryKey completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if(error) {
if ( self.delegate ) {
[self.delegate announcementFetchingFailed];
}
return;
}
NSInteger statusCode = [((NSHTTPURLResponse*)response) statusCode];
if(statusCode != 200) {
if ( self.delegate ) {
[self.delegate announcementFetchingFailed];
}
return;
}
NSString *objectNotation = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
NSError *builderError;
Announcement *announcement = [self.builder announcementFromJSON:objectNotation error:&builderError];
if(builderError) {
if ( self.delegate ) {
[self.delegate announcementFetchingFailed];
}
return;
}
if ( self.delegate ) {
[self.delegate announcementFetched:announcement];
}
}];
}
#pragma mark - Private Methods
- (void)fetchAnnouncement:(NSNumber *)primaryKey completionHandler:(void (^)(NSData * data, NSURLResponse * response, NSError * error))completionHandler {
NSURLSessionDataTask *dataTask = [self.session dataTaskWithRequest:[self announcementURLRequest:primaryKey] completionHandler:completionHandler];
[dataTask resume];
}
- (NSURLRequest *)announcementURLRequest:(NSNumber *)primaryKey {
NSString *urlStr = [self announcementURLString:primaryKey];
return [super getURLRequestWithUrlString:urlStr
cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
timeoutInterval:FAST_TIME_INTERVAL];
}
- (NSString *)announcementURLString:(NSNumber *)primaryKey {
return [NSString stringWithFormat:@"%@/%@/%@", kServerUrl, kAnnouncementsResourcePath, primaryKey];
}
@end
和一个委托协议来表明是否已经获取到通告
#import <Foundation/Foundation.h>
@class Announcement;
@protocol AnnouncementFetcherDelegate <NSObject>
- (void)announcementFetchingFailed;
- (void)announcementFetched:(Announcement *)announcement;
@end
7 结论
由于 Grails 的易于 API 版本化,我们现在可以支持运行不同版本的两个 iOS 应用程序。