显示导航栏

利用 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 功能的应用程序。

如果您 cdgrails-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 应用程序

create new project
choose master detail application
choose objective c
如果您 cdgrails-guides/building-an-ios-objectc-client-powered-by-a-grails-backend/complete-objectivec-ios-v1,则可以直接转到 iOS 示例的完成版本 1
如果您 cdgrails-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 中呈现

overview

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 正文。我们将修改在上一步中生成的领域类来存储该信息。

grails-app/domain/intranet/backend/Announcement.groovy
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 领域类中定义的约束。具体来说,测试标题和正文属性的空值性和长度。

src/test/groovy/intranet/backend/AnnouncementSpec.groovy
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 方法。

grails-app/controllers/intranet/backend/v1/AnnouncementController.groovy
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 映射 配置来完成这项工作。将下一行添加到映射闭包中

/grails-app/controllers/intranet/backend/UrlMappings.groovy
        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

/src/integration-test/groovy/intranet/backend/AnnouncementControllerSpec.groovy
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 应用程序公开的公告时所涉及的类

ios announcements overview

5.2 模型

服务器发送的公告将被渲染到对象中

/complete-objectivec-ios-v1/IntranetClient/Announcement.h
#import <Foundation/Foundation.h>

@interface Announcement : NSObject

@property (nonatomic, copy)NSNumber *primaryKey;
@property (nonatomic, copy)NSString *title;
@property (nonatomic, copy)NSString *body;

@end
/complete-objectivec-ios-v1/IntranetClient/Announcement.m
#import "Announcement.h"

@implementation Announcement

@end

5.3 Json 至模型

要从 JSON 字符串构建模型对象,我们使用几个生成器类。

/complete-objectivec-ios-v1/IntranetClient/AnnouncementBuilder.h
#import <Foundation/Foundation.h>

#import "ElementBuilder.h"

extern NSString *kAnnouncementBuilderErrorDomain;

enum {
    kAnnouncementBuilderInvalidJSONError,
    kAnnouncementBuilderMissingDataError,
};

@interface AnnouncementBuilder : ElementBuilder

- (NSArray *)announcementsFromJSON:(NSString *)objectNotation
                             error:(NSError **)error;

@end
/complete-objectivec-ios-v1/IntranetClient/AnnouncementBuilder.m
#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";
/complete-objectivec-ios-v1/IntranetClient/ElementBuilder.h
#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
/complete-objectivec-ios-v1/IntranetClient/ElementBuilder.m
#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 服务器的本地计算机。我们需要禁用 应用程序传输安全性,如下图所示

apptransportsecurity

5.5 网络代码

我们使用 NSURLSession 连接到 Grails API。一些常量设置在 GrailsFetcher

/complete-objectivec-ios-v1/IntranetClient/GrailsFetcher.h
#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 地址以匹配您的本地计算机。
/complete-objectivec-ios-v1/IntranetClient/GrailsFetcher.m
#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 标头。
/complete-objectivec-ios-v1/IntranetClient/AnnouncementsFetcher.h
#import <Foundation/Foundation.h>
#import "GrailsFetcher.h"

@protocol AnnouncementsFetcherDelegate;

@interface AnnouncementsFetcher : GrailsFetcher

- (id)initWithDelegate:(id<AnnouncementsFetcherDelegate>)delegate;

- (void)fetchAnnouncements;

@end
/complete-objectivec-ios-v1/IntranetClient/AnnouncementsFetcher.m
#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

一旦我们得到公告列表,我们就会与实现委托的类通信

/complete-objectivec-ios-v1/IntranetClient/AnnouncementsFetcherDelegate.h
#import <Foundation/Foundation.h>

@protocol AnnouncementsFetcherDelegate <NSObject>

- (void)announcementsFetchingFailed;

- (void)announcementsFetched:(NSArray *)announcements;


@end

MasterViewController 实现 fetcher 委托协议,因此它接收公告

/complete-objectivec-ios-v1/IntranetClient/MasterViewController.h
#import <UIKit/UIKit.h>

@class DetailViewController;

@interface MasterViewController : UITableViewController

@property (strong, nonatomic) DetailViewController *detailViewController;


@end
/complete-objectivec-ios-v1/IntranetClient/MasterViewController.m
#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 的数据源和委托设置为以下类

/complete-objectivec-ios-v1/IntranetClient/AnnouncementsTableViewDataSource.h
#import <UIKit/UIKit.h>

@interface AnnouncementsTableViewDataSource : NSObject <UITableViewDataSource>

@property (nonatomic, strong) NSArray *announcements;

@end
/complete-objectivec-ios-v1/IntranetClient/AnnouncementsTableViewDataSource.m
#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
/complete-objectivec-ios-v1/IntranetClient/AnnouncementsTableViewDelegate.h
#import <UIKit/UIKit.h>

static NSString *kAnnouncementTappedNotification = @"AnnouncementTappedNotification";

@interface AnnouncementsTableViewDelegate : NSObject <UITableViewDelegate>

@property (nonatomic, strong) NSArray *announcements;

@end
/complete-objectivec-ios-v1/IntranetClient/AnnouncementsTableViewDelegate.m
#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。在MasterViewControllerprepareForSegue:sender方法中,我们设置DetailViewController的公告属性

/complete-objectivec-ios-v1/IntranetClient/MasterViewController.m
- (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;
    }
}
segue

为了呈现公告,我们使用UILabel 和 UIWebView,我们将其连接到 StoryBoard 中的 IBOutlets,如下图所示

connect detail iboutlets

这是完整的DetailViewController代码。不涉及任何网络代码。

/complete-objectivec-ios-v1/IntranetClient/DetailViewController.h
#import <UIKit/UIKit.h>
#import "Announcement.h"

@interface DetailViewController : UIViewController

@property (nonatomic, strong) Announcement *announcement;

@end
/complete-objectivec-ios-v1/IntranetClient/DetailViewController.m
#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)。

version2overview

6.1 Grails V2 更改

我们将创建一个新的控制器来处理 API 的版本 2。我们将使用条件查询和一个投影来仅抓取公告的 ID 和标题。

grails-app/controllers/intranet/backend/v2/AnnouncementController.groovy
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()]
    }
}

我们将查询封装在一个服务中

grails-app/services/intranet/backend/AnnouncementService.groovy
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>
    }
}

然后对其进行测试

/src/test/groovy/intranet/backend/AnnouncementServiceSpec.groovy
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

/grails-app/controllers/intranet/backend/UrlMappings.groovy
        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

/src/integration-test/groovy/intranet/backend/AnnouncementControllerSpec.groovy
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 版本常量

/complete-objectivec-ios-v2/IntranetClient/GrailsFetcher.h
#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方法,如下所示

/complete-objectivec-ios-v2/IntranetClient/MasterViewController.m
- (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向服务器询问完整的通告;包括正文。

/complete-objectivec-ios-v2/IntranetClient/DetailViewController.h
#import <UIKit/UIKit.h>
#import "Announcement.h"

@interface DetailViewController : UIViewController

@property (nonatomic, assign) NSNumber *announcementPrimaryKey;

@end
/complete-objectivec-ios-v2/IntranetClient/DetailViewController.m
#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

它使用一个新的抓取器

/complete-objectivec-ios-v2/IntranetClient/AnnouncementFetcher.h
#import <Foundation/Foundation.h>
#import "GrailsFetcher.h"
#import "AnnouncementFetcherDelegate.h"

@interface AnnouncementFetcher : GrailsFetcher

- (id)initWithDelegate:(id<AnnouncementFetcherDelegate>)delegate;

- (void)fetchAnnouncement:(NSNumber *)primaryKey;

@end
/complete-objectivec-ios-v2/IntranetClient/AnnouncementFetcher.m
#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

和一个委托协议来表明是否已经获取到通告

/complete-objectivec-ios-v2/IntranetClient/AnnouncementFetcherDelegate.h
#import <Foundation/Foundation.h>
@class Announcement;

@protocol AnnouncementFetcherDelegate <NSObject>

- (void)announcementFetchingFailed;

- (void)announcementFetched:(Announcement *)announcement;

@end

7 结论

由于 Grails 的易于 API 版本化,我们现在可以支持运行不同版本的两个 iOS 应用程序。

8 您是否需要 Grails 的帮助?

Object Computing, Inc. (OCI) 赞助了本指南的创建。提供各种咨询和支持服务。

OCI 是 Grails 的家园

结识团队