ReactiveCocoa入门教程:第二部分

简介:
原文链接:
ReactiveCocoa 是一个框架,它允许你在你的iOS程序中使用函数响应式(FRP)技术。加上第一部分的讲解,你将会学会如何使用信号量(对事件发出数据流)如何替代标准的动作和事件处理逻辑。你也会学到如何转换、分离和组合这些信号量。
在这里,也就是第二部分里,你将会学到更多先进的ReactiveCocoa特性,包括:
1、另外两个事件类型:error和completed
2、Throttling(节流)
3、Threading
4、Continuations
5、更多。。。
是时候开始了。
 
Twitter Instant
这里我们要使用的贯穿整个教程的程序是叫做 Twitter Instant的程序,该程序可以在你输入的时候实时更新搜索到的结果。
该应用包括一些基本的用户交互界面和一些平凡的代码,了解之后就可以开始了。在 第一部分里面,你使用Cocoapods来把CocoaPods加载到你的工程里面,这里的工程里面就已经包含了Podfile文件,你只需要pod install一下即可。
然后重新打开工程即可。(这个时候打开TwitterInstant.xcworkspace):
1、TwitterInstant:这是你的程序逻辑
2、Pods:里面是包括的三方类库
运行一下程序,你会看到如下的页面:
花费一会时间让你自己熟悉一下整个工程。它就是一个简单的split viewController app.左边的是RWSearchFormViewController,右边的是:RWSearchResultsViewController。
自己说:原文简单介绍了一下该工程,就不在介绍看一下就可以了。
验证搜索文本
你第一件要做的事情就是去验证一下搜索文本,让它确保大于两个字符串。如果你看了第一篇文章,这个将会很简单。
在RWSearchFormViewController.m中添加方法如下:
- (BOOL)isValidSearchText:(NSString *)text {
  return text.length > 2;
}

这就简单的保证了搜索的字符串大于两个字符。写这个很简单的逻辑你可能会问:为什么要分开该方法到工程文件里面呢?

当前的逻辑很简单,但是如果后面这个会更复杂呢?在上面的例子中,你只需要修改一个地方。此外,上面的写法让你的代码更有表现力,它告诉你为什么要检查string的长度。我们应该遵守好的编码习惯,不是么?
然后,我们导入头文件:
#import <ReactiveCocoa.h>
然后在导入该头文件的文件里面的viewDidLoad后面写上如下代码:
复制代码
[[self.searchText.rac_textSignal
  map:^id(NSString *text) {
    return [self isValidSearchText:text] ?
      [UIColor whiteColor] : [UIColor yellowColor];
  }]
  subscribeNext:^(UIColor *color) {
    self.searchText.backgroundColor = color;
  }];
复制代码

想想这是做什么呢?上面的代码:

 1、取走搜索文本框的信号量
2、把它转换一下:用背景色来预示内容是否可用。
3、然后设置backgroundColor属性在subscribeNext:的block里面。
Build然后运行我们就会发现当搜索有效的时候就会是白色,搜索字符串无效的时候就是黄色。
下面是图解,这个简单的反应传输看起来如下:
 
ran_textSignal发出包含当前文本框每次改变内容的next事件。map那个步骤转换text value,将其转换成了color,subscribeNext那一步将这个value提供给了textField的background。
 
当然了,你从第一个教程一定记得这些,对吧?如果你不记得的话,你也许想在这里停止阅读,至少读了整个测试工程。
在添加Twitter 搜索逻辑之前 ,这里有一些更有趣的话题。
 
Formatting of Pipelines
当你正在钻研格式化的ReactiveCocoa代码的时候,普遍接受的惯例就是:每一个操作在一个新行,和所有步骤垂直对齐的。
在下面的图片,你会看到更复杂的对齐方式,从上一个教程拿来的图片:
这样你会更容易看到该组成管道的操作。另外,在每个block中用最少的代码任何超过几行的都应该拆分出一个私有的方法。
不幸的是,Xcode真的不喜欢这种格式类型的代码,因此你可能需要找到自己调整。
 
Memory Management
思考一下你刚才加入到TwitterInstant的代码。你是否想过你刚才创建的管道式如何保留的呢?无疑地,是否是它没有赋值为一个变量或者属性他就不会有自己的引用计数,注定会消亡呢?
其中一个设计目标就是ReactiveCocoa允许这种类型的编程,这里管道可以匿名形式。所有你写过的响应式代码都应该看起来比较直观。
为了支持这种模型,ReactiveCocoa维持和保留自己全局的信号。如果它有一个或者多个subscribers(订阅者),信号就会活跃。如果所有的订阅者都移除掉了,信号就会被释放。想了解更多关于ReactiveCocoa管理进程,可以参看Memory Management 文档。
这就剩下了最后的问题:你如何从一个信号取消订阅?当一个completed或者error事件之后,订阅会自动的移除(一会就会学到)。手工的移除将会通过RACDisposable.
所有RACSignal的订阅方法都会返回一个RACDisposable实例,它允许你通过处置方法手动的移除订阅。下面是一个使用当前管道的快速的例子。
复制代码
RACSignal *backgroundColorSignal = [self.searchText.rac_textSignal
    map:^id(NSString *text) {
      return [self isValidSearchText:text] ? [UIColor whiteColor] : [UIColor yellowColor];
    }];
 
RACDisposable *subscription = [backgroundColorSignal
    subscribeNext:^(UIColor *color) {
      self.searchText.backgroundColor = color;
    }];
 
// at some point in the future ...
[subscription dispose];
复制代码

你不会经常做这些,但是你必须知道可能性的存在。

Note:作为这些的一个推论,如果你创建了一个管道,但是你不给他订阅,这个管道将不会执行,这些包括任何侧面的影响,例如doNext:blocks。

 

Avoiding Retain Cycles

当ReactiveCocoa在场景背后做了好多聪明的事情—这就意味着你不必要担心太多关于信号量的内存管理——这里有一个很重要的内存喜爱那个管的问你你需要考虑。

 如果你看到下面的响应式代码你仅仅加入:
复制代码
[[self.searchText.rac_textSignal
  map:^id(NSString *text) {
    return [self isValidSearchText:text] ?
      [UIColor whiteColor] : [UIColor yellowColor];
  }]
  subscribeNext:^(UIColor *color) {
    self.searchText.backgroundColor = color;
  }];
复制代码

 

subscribeNext:block使用self来获得一个textField的引用,Blocks在封闭返回内捕获并且持有了值。因此在self和这个信号量之间造成了强引用,造成了循环引用。这取决于对象的生命周期,如果他的生命周期是应用程序的生命周期,那这样是没关系的,但是在更复杂的应用中就不行了。
为了避免这种潜在的循环引用,苹果官方文档:Working With Blocks 建议捕捉一个弱引用self,当前的代码可以这样写:
 
复制代码
__weak RWSearchFormViewController *bself = self; // Capture the weak reference
 
[[self.searchText.rac_textSignal
  map:^id(NSString *text) {
    return [self isValidSearchText:text] ?
      [UIColor whiteColor] : [UIColor yellowColor];
  }]
  subscribeNext:^(UIColor *color) {
    bself.searchText.backgroundColor = color;
  }];
复制代码
在上面的代码中,bself就是self标记为__weak(使用它可以make一个弱引用)的引用,现在可以看到使用textField的时候使用bself代用的。这看起来并不是那么高雅。
ReactiveCocoa框架包含了一个小诀窍,你可以使用它代替上百年的代码。添加下面的引用:
#import "RACEXTScope.h"

@weakify(self)然后代码修改后如下:

复制代码
[[self.searchText.rac_textSignal
  map:^id(NSString *text) {
    return [self isValidSearchText:text] ?
      [UIColor whiteColor] : [UIColor yellowColor];
  }]
  subscribeNext:^(UIColor *color) {
    @strongify(self)
    self.searchText.backgroundColor = color;
  }];
复制代码

@weakify和@strongify语句是在Extended Objective-C库的宏定义,他们也包含在ReactiveCocoa中。@weakify 宏定义允许你创建一个若饮用的影子变量,@strongify宏定义允许你创建一个前面使用@weakify传递的强引用变量。

Note:如果你对@weakify和@strongify感兴趣,可以进入RACEXTSCope.h中查看其实现。
最后一个提醒,当在Blocks使用实例变量的时候要小心,这样也会导致block捕获一个self的强引用。你可以打开编译警告去告诉你你的代码有这个问题。
好了,你从理论中幸存出来了,恭喜。现在你变得更加明智,准备移步到有趣的环节:添加一些真实的函数到你的工程里面。
 
 
Requesting Access to Twitter
 为了在TwitterInstant 应用中去搜索Tweets,你将会用到社交框架(Social Framework)。为了访问Twitter你需要使用Accounts Framework。
在你添加代码之前,你需要到模拟器中输入你的账号:
 
设置好账号之后,然后你只需要在RWSearchFormViewController.m中导入以下文件即可:
#import <Accounts/Accounts.h>
#import <Social/Social.h>

然后在引入的头文件下面写如下的代码:

复制代码
typedef NS_ENUM(NSInteger, RWTwitterInstantError) {
    RWTwitterInstantErrorAccessDenied,
    RWTwitterInstantErrorNoTwitterAccounts,
    RWTwitterInstantErrorInvalidResponse
};
 
static NSString * const RWTwitterInstantDomain = @"TwitterInstant";
复制代码
你将会使用这些简单地鉴定错误。然后在interface和end之间声明两个属性: 
@property (strong, nonatomic) ACAccountStore *accountStore;
@property (strong, nonatomic) ACAccountType *twitterAccountType;

ACAccountsStore类提供访问你当前设备有的social账号,ACAccountType类代表指定类型的账户。

然后在viewDidLoad里面加入以下代码:
self.accountStore = [[ACAccountStore alloc] init];
self.twitterAccountType = [self.accountStore accountTypeWithAccountTypeIdentifier:ACAccountTypeIdentifierTwitter];

这些代码创建了账户存储和Twitter账号标示。在.m中添加如下方法:

复制代码
- (RACSignal *)requestAccessToTwitterSignal {
  // 1 - define an error
  NSError *accessError = [NSError errorWithDomain:RWTwitterInstantDomain
                                             code:RWTwitterInstantErrorAccessDenied
                                         userInfo:nil];
  // 2 - create the signal
  @weakify(self)
  return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
    // 3 - request access to twitter
    @strongify(self)
    [self.accountStore
       requestAccessToAccountsWithType:self.twitterAccountType
         options:nil
      completion:^(BOOL granted, NSError *error) {
          // 4 - handle the response
          if (!granted) {
            [subscriber sendError:accessError];
          } else {
            [subscriber sendNext:nil];
            [subscriber sendCompleted];
          }
        }];
    return nil;
  }];
}
复制代码

这个方法的作用是:

1、定义了如果用户拒绝访问的错误
2、根据第一个入门教程,类方法createSignal返回了一个RACSignal的实例。
3、通过账户存储请求访问Twitter。在这一点上,用户将看到一个提示,要求他们给予这个程序访问Twitter账户的弹框。
4、当用户同意或者拒绝访问,信号事件就会触发。如果用户同意访问,next事件将会紧随而来,然后是completed发送,如果用户拒绝访问,error事件会触发。
如果你回想其第一个入门教程,一个信号可以以三种不同的事件发出:
1、next
2、completed
3、error
超过了signal的生命周期,它将不会发出任何信号事件。
最后,为了充分利用信号,在viewDidLoad后面添加如下代码;
[[self requestAccessToTwitterSignal]
  subscribeNext:^(id x) {
    NSLog(@"Access granted");
  } error:^(NSError *error) {
    NSLog(@"An error occurred: %@", error);
  }];

 

 
如果你运行程序,将会看到一个弹出框:
提示是否允许访问权限,如果ok,则打印出来Access granted ,否则将会走error。
Accounts Framework会记住你的决定,因此如果想再次测试,你需要针对模拟机进行:Reset Contents and Settings。
 
Chaining Signals
一旦用户允许访问Twitter账户,为了执行twitter,程序将会不断监听搜索内容textField的变化.
程序需要等待信号,它请求访问Twitter去发出completed事件,然后订阅textField的信号。不同信号连续的链是一个共有的问题,但是ReactiveCocoa处理起来非常优雅。
 用下面的代码替换当前在viewDidLoad后面的管道:
复制代码
[[[self requestAccessToTwitterSignal]
  then:^RACSignal *{
    @strongify(self)
    return self.searchText.rac_textSignal;
  }]
  subscribeNext:^(id x) {
    NSLog(@"%@", x);
  } error:^(NSError *error) {
    NSLog(@"An error occurred: %@", error);
  }];
复制代码

then方法会一直等待,知道completed事件发出,然后订阅者通过自己的block参数返回,这有效地将控制从一个信号传递给下一个。

Note:上面已经写过了@weakly(self);所以这里就不用再写了。

then方法传递error事件。因此最后的subscribeNext:error: block还接收初始的访问请求错误。

当你运行的时候,然后允许访问,你应该可以在控制台看到打印出来的你输入的东西。
 
然后,添加filter操作到管道去移除无效的搜索字符串。在这个实例中,他们是不到三个字符的string:
复制代码
[[[[self requestAccessToTwitterSignal]
  then:^RACSignal *{
    @strongify(self)
    return self.searchText.rac_textSignal;
  }]
  filter:^BOOL(NSString *text) {
    @strongify(self)
    return [self isValidSearchText:text];
  }]
  subscribeNext:^(id x) {
    NSLog(@"%@", x);
  } error:^(NSError *error) {
    NSLog(@"An error occurred: %@", error);
  }];
复制代码
运行就可以在控制台看到只有三个以上的才能输出。 
 
图解一下上边的管道:
 
 
程序管道从requestAccessToTwitterSignal信号开始,然后转换到tac_textSignal。同事next事件通过filter,最后到达订阅block.你也可以看到任何通过第一步的error事件。
现在你有一个发出搜索text的信号,它可以用来搜索Twitter了。很有趣吧。
 
Searching Twitter
 Social Framework是一个访问Twitter 搜索API的选项。然而,它并无法响应搜索,下一步就是给信号包括API请求方法。在当前的控制器中,添加如下方法:
复制代码
- (SLRequest *)requestforTwitterSearchWithText:(NSString *)text {
  NSURL *url = [NSURL URLWithString:@"https://api.twitter.com/1.1/search/tweets.json"];
  NSDictionary *params = @{@"q" : text};
 
  SLRequest *request =  [SLRequest requestForServiceType:SLServiceTypeTwitter
                                           requestMethod:SLRequestMethodGET
                                                     URL:url
                                              parameters:params];
  return request;
}
复制代码

下一步就是创建一个基于request的信号量。添加如下方法:这创建了一个请求:搜索Twitter(V.1.1REST API)。这个是调用Twitter的api。

复制代码
- (RACSignal *)signalForSearchWithText:(NSString *)text {
 
  // 1 - define the errors
  NSError *noAccountsError = [NSError errorWithDomain:RWTwitterInstantDomain
                                                 code:RWTwitterInstantErrorNoTwitterAccounts
                                             userInfo:nil];
 
  NSError *invalidResponseError = [NSError errorWithDomain:RWTwitterInstantDomain
                                                      code:RWTwitterInstantErrorInvalidResponse
                                                  userInfo:nil];
 
  // 2 - create the signal block
  @weakify(self)
  return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
    @strongify(self);
 
    // 3 - create the request
    SLRequest *request = [self requestforTwitterSearchWithText:text];
 
    // 4 - supply a twitter account
    NSArray *twitterAccounts = [self.accountStore
      accountsWithAccountType:self.twitterAccountType];
    if (twitterAccounts.count == 0) {
      [subscriber sendError:noAccountsError];
    } else {
      [request setAccount:[twitterAccounts lastObject]];
 
      // 5 - perform the request
      [request performRequestWithHandler: ^(NSData *responseData,
                                          NSHTTPURLResponse *urlResponse, NSError *error) {
        if (urlResponse.statusCode == 200) {
 
          // 6 - on success, parse the response
          NSDictionary *timelineData =
             [NSJSONSerialization JSONObjectWithData:responseData
                                             options:NSJSONReadingAllowFragments
                                               error:nil];
          [subscriber sendNext:timelineData];
          [subscriber sendCompleted];
        }
        else {
          // 7 - send an error on failure
          [subscriber sendError:invalidResponseError];
        }
      }];
    }
 
    return nil;
  }];
}
复制代码
然后在viewDidLoad方法中进一步添加信号量:
复制代码
[[[[[self requestAccessToTwitterSignal]
  then:^RACSignal *{
    @strongify(self)
    return self.searchText.rac_textSignal;
  }]
  filter:^BOOL(NSString *text) {
    @strongify(self)
    return [self isValidSearchText:text];
  }]
  flattenMap:^RACStream *(NSString *text) {
    @strongify(self)
    return [self signalForSearchWithText:text];
  }]
  subscribeNext:^(id x) {
    NSLog(@"%@", x);
  } error:^(NSError *error) {
    NSLog(@"An error occurred: %@", error);
  }];
复制代码

运行:

即可在控制台里面打印出来筛选的数据。
 
Threading
 
我很确信你这会亟待把JSON数据放到UI里面,但是在放到UI里面之前你需要做最后一件事:找到他是什么,你需要做一些探索!
 
添加一个端点到subscribeNext:error:那个步,然后我们会看到Xcode左侧的Thread,我们发现如果想加载图片的话必须在主线程里面,但是他不在主线程中,所以我们就可以做如下操作:
复制代码
[[[[[[self requestAccessToTwitterSignal]
  then:^RACSignal *{
    @strongify(self)
    return self.searchText.rac_textSignal;
  }]
  filter:^BOOL(NSString *text) {
    @strongify(self)
    return [self isValidSearchText:text];
  }]
  flattenMap:^RACStream *(NSString *text) {
    @strongify(self)
    return [self signalForSearchWithText:text];
  }]
  deliverOn:[RACScheduler mainThreadScheduler]]
  subscribeNext:^(id x) {
    NSLog(@"%@", x);
  } error:^(NSError *error) {
    NSLog(@"An error occurred: %@", error);
  }];
复制代码

这样就会在主线程中运行。也就是更新了管道:添加了deliverOn:操作。

然后再次运行我们就会发现他是在主线程上执行了。这样你就可以更新UI了。
 
Updating the UI
这里用到了另一个库: LinqToObjectiveC。安装方式就不说了和ReactiveCocoa一样
我们在RWSearchFormViewController中导入:
#import "RWTweet.h"
#import "NSArray+LinqExtensions.h"
然后在输出json数据的地方修改如下:
复制代码
[[[[[[self requestAccessToTwitterSignal]
  then:^RACSignal *{
    @strongify(self)
    return self.searchText.rac_textSignal;
  }]
  filter:^BOOL(NSString *text) {
    @strongify(self)
    return [self isValidSearchText:text];
  }]
  flattenMap:^RACStream *(NSString *text) {
    @strongify(self)
    return [self signalForSearchWithText:text];
  }]
  deliverOn:[RACScheduler mainThreadScheduler]]
  subscribeNext:^(NSDictionary *jsonSearchResult) {
    NSArray *statuses = jsonSearchResult[@"statuses"];
    NSArray *tweets = [statuses linq_select:^id(id tweet) {
      return [RWTweet tweetWithStatus:tweet];
    }];
    [self.resultsViewController displayTweets:tweets];
  } error:^(NSError *error) {
    NSLog(@"An error occurred: %@", error);
  }];
复制代码

运行:

就可以看到右侧的详情页面加载到数据了。刚引入的类库其实就是将json数据转换成了model.加载数据的效果如下:
 
 
Asynchronous Loading of Images
 
现在内容都加载出来了,就差图片了。在RWSearchResultsViewController.m中添加如下方法:
复制代码
-(RACSignal *)signalForLoadingImage:(NSString *)imageUrl {
 
  RACScheduler *scheduler = [RACScheduler
                         schedulerWithPriority:RACSchedulerPriorityBackground];
 
  return [[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
    NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:imageUrl]];
    UIImage *image = [UIImage imageWithData:data];
    [subscriber sendNext:image];
    [subscriber sendCompleted];
    return nil;
  }] subscribeOn:scheduler];
 
}
复制代码

这会你一ing该就会很熟悉这种模式了。然后在tableview:cellForRowAtIndex:方法里面添加:

复制代码
cell.twitterAvatarView.image = nil;
 
[[[self signalForLoadingImage:tweet.profileImageUrl]
  deliverOn:[RACScheduler mainThreadScheduler]]
  subscribeNext:^(UIImage *image) {
   cell.twitterAvatarView.image = image;
  }];
复制代码

再次运行就可以出来效果了:

 
Throttling(限流)
你可能注意到这个问题:每次输入一个字符串都会立即执行然后导致刷新太快 ,导致每秒会显示几次搜索结果。这不是理想的状态。
一个好的解决方式就是如果搜索内容不变之后的时间间隔后在搜索比如500毫秒。
而ReactiveCocoa是这个工作变的如此简单。
打开RWSearchFormViewController.m然后更新管道,调整如下:
复制代码
[[[[[[[self requestAccessToTwitterSignal]
  then:^RACSignal *{
    @strongify(self)
    return self.searchText.rac_textSignal;
  }]
  filter:^BOOL(NSString *text) {
    @strongify(self)
    return [self isValidSearchText:text];
  }]
  throttle:0.5]
  flattenMap:^RACStream *(NSString *text) {
    @strongify(self)
    return [self signalForSearchWithText:text];
  }]
  deliverOn:[RACScheduler mainThreadScheduler]]
  subscribeNext:^(NSDictionary *jsonSearchResult) {
    NSArray *statuses = jsonSearchResult[@"statuses"];
    NSArray *tweets = [statuses linq_select:^id(id tweet) {
      return [RWTweet tweetWithStatus:tweet];
    }];
    [self.resultsViewController displayTweets:tweets];
  } error:^(NSError *error) {
    NSLog(@"An error occurred: %@", error);
  }];
 
复制代码

你会发现这样就可以了。throttle操作只是发送一个操作,这个操作在时间到之后继续进行。

 
Wrap Up
现在我们知道ReactiveCocoa是多么的优雅。
相关文章
|
1月前
|
存储 运维 JavaScript
vue读书笔记开发环境搭建
vue读书笔记开发环境搭建
|
8月前
|
JSON 自然语言处理 JavaScript
TypeChat 入门指南
TypeChat 是一个革命性的库,它简化了使用 TypeScript 构建自然语言模型界面的过程。 它抹平了自然语言和结构化数据之间的差距,使开发人员更容易将自然语言界面集成到他们的应用程序中。
320 0
|
9月前
|
JSON Dart 前端开发
《深入浅出Dart》序言
序言 在线阅读 全面介绍Dart编程语言的实用指南,适合初学者和有一定经验的开发者。通过深入的解释和丰富的代码示例,读者将快速掌握Dart的核心概念和语法。 包括面向对象编程和异步操作等重要内容。通过丰富的代码示例和清晰的解释,你将能够迅速掌握Dart的特性,并将其应用于实际项目中。
60 0
《Three.js开发指南第2版》读书笔记1
《Three.js开发指南第2版》读书笔记1!!!《Three.js开发指南第2版》读书笔记1!!!
|
10月前
|
缓存 前端开发 JavaScript
《Three.js开发指南第2版》读书笔记2
《Three.js开发指南第2版》读书笔记2!!!《Three.js开发指南第2版》读书笔记2!!!
|
设计模式 JavaScript 前端开发
手把手教你Vue从零撸一个迷你版MVVM框架(二)
手把手教你Vue从零撸一个迷你版MVVM框架
71 0
|
存储 Web App开发 移动开发
Day 19: EmberJS 入门指南
到目前为止,我们这一系列文章涉及了Bower、AngularJS、GruntJS、PhoneGap和MeteorJS 这些JavaScript技术。今天我打算学习一个名为Ember的框架。本文将介绍如何用Ember创建一个单页面的社交化书签应用。本教程将包括两篇:第1篇介绍客户端代码和用HTML 5本地存储持久保存数据,第2篇中我们将使用一个部署在OpenShift上的REST后端。过几天我会写第2篇。
283 0
Day 19: EmberJS 入门指南
|
设计模式 JavaScript API
【Vue3官方教程】🎄万字笔记 | 同步导学视频(上)
【Vue3官方教程】🎄万字笔记 | 同步导学视频
148 0
|
前端开发 JavaScript
【Vue3官方教程】🎄万字笔记 | 同步导学视频(下)
【Vue3官方教程】🎄万字笔记 | 同步导学视频(上)
147 0
ReactiveCocoa(FRP)-进阶篇(下)
ReactiveCocoa(FRP)-进阶篇(下)
118 0
ReactiveCocoa(FRP)-进阶篇(下)