• iOS开发 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:里面是包括的三方类库

    运行一下程序,你会看到如下的页面:

    Simulator Screen Shot 2016年1月16日 上午11.28.37.png

    花费一会时间让你自己熟悉一下整个工程。它就是一个简单的split viewController app.左边的是RWSearchFormViewController,右边的是:RWSearchResultsViewController。

    自己说:原文简单介绍了一下该工程,就不在介绍看一下就可以了。

    验证搜索文本

    你第一件要做的事情就是去验证一下搜索文本,让它确保大于两个字符串。如果你看了第一篇文章,这个将会很简单。

    在RWSearchFormViewController.m中添加方法如下:

    1
    2
    3
    - (BOOL)isValidSearchText:(NSString *)text {
      return text.length > 2;
    }

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

    当前的逻辑很简单,但是如果后面这个会更复杂呢?在上面的例子中,你只需要修改一个地方。此外,上面的写法让你的代码更有表现力,它告诉你为什么要检查string的长度。我们应该遵守好的编码习惯,不是么?

    然后,我们导入头文件:

    1
    #import

    然后在导入该头文件的文件里面的viewDidLoad后面写上如下代码:

    1
    2
    3
    4
    5
    [[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然后运行我们就会发现当搜索有效的时候就会是白色,搜索字符串无效的时候就是黄色。

    下面是图解,这个简单的反应传输看起来如下:

    TextValidationPipeline.png

    ran_textSignal发出包含当前文本框每次改变内容的next事件。map那个步骤转换text value,将其转换成了color,subscribeNext那一步将这个value提供给了textField的background。

    当然了,你从第一个教程一定记得这些,对吧?如果你不记得的话,你也许想在这里停止阅读,至少读了整个测试工程。

    在添加Twitter 搜索逻辑之前 ,这里有一些更有趣的话题。

    Formatting of Pipelines

    当你正在钻研格式化的ReactiveCocoa代码的时候,普遍接受的惯例就是:每一个操作在一个新行,和所有步骤垂直对齐的。

    在下面的图片,你会看到更复杂的对齐方式,从上一个教程拿来的图片:

    PipelineFormatting.png

    这样你会更容易看到该组成管道的操作。另外,在每个block中用最少的代码任何超过几行的都应该拆分出一个私有的方法。

    不幸的是,Xcode真的不喜欢这种格式类型的代码,因此你可能需要找到自己调整。

    Memory Management

    思考一下你刚才加入到TwitterInstant的代码。你是否想过你刚才创建的管道式如何保留的呢?无疑地,是否是它没有赋值为一个变量或者属性他就不会有自己的引用计数,注定会消亡呢?

    其中一个设计目标就是ReactiveCocoa允许这种类型的编程,这里管道可以匿名形式。所有你写过的响应式代码都应该看起来比较直观。

    为了支持这种模型,ReactiveCocoa维持和保留自己全局的信号。如果它有一个或者多个subscribers(订阅者),信号就会活跃。如果所有的订阅者都移除掉了,信号就会被释放。想了解更多关于ReactiveCocoa管理进程,可以参看Memory Management 文档。

    这就剩下了最后的问题:你如何从一个信号取消订阅?当一个completed或者error事件之后,订阅会自动的移除(一会就会学到)。手工的移除将会通过RACDisposable.

    所有RACSignal的订阅方法都会返回一个RACDisposable实例,它允许你通过处置方法手动的移除订阅。下面是一个使用当前管道的快速的例子。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    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在场景背后做了好多聪明的事情—这就意味着你不必要担心太多关于信号量的内存管理——这里有一个很重要的内存喜爱那个管的问你你需要考虑。

    如果你看到下面的响应式代码你仅仅加入:

    1
    2
    3
    4
    5
    6
    7
    8
    [[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,当前的代码可以这样写:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    __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框架包含了一个小诀窍,你可以使用它代替上百年的代码。添加下面的引用:

    1
    #import "RACEXTScope.h"

    然后代码修改后如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @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的强引用。你可以打开编译警告去告诉你你的代码有这个问题。

    AvoidRetainSelf.png

    好了,你从理论中幸存出来了,恭喜。现在你变得更加明智,准备移步到有趣的环节:添加一些真实的函数到你的工程里面。

    Requesting Access to Twitter

    为了在TwitterInstant 应用中去搜索Tweets,你将会用到社交框架(Social Framework)。为了访问Twitter你需要使用Accounts Framework。

    在你添加代码之前,你需要到模拟器中输入你的账号:

    Simulator Screen Shot 2016年1月16日 下午2.19.19.png

    设置好账号之后,然后你只需要在RWSearchFormViewController.m中导入以下文件即可:

    1
    #import #import

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

    1
    2
    3
    4
    5
    typedef NS_ENUM(NSInteger, RWTwitterInstantError) {
        RWTwitterInstantErrorAccessDenied,
        RWTwitterInstantErrorNoTwitterAccounts,
        RWTwitterInstantErrorInvalidResponse}; 
    static NSString * const RWTwitterInstantDomain = @"TwitterInstant";

    你将会使用这些简单地鉴定错误。然后在interface和end之间声明两个属性:

    1
    2
    @property (strong, nonatomic) ACAccountStore *accountStore;
    @property (strong, nonatomic) ACAccountType *twitterAccountType;

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

    然后在viewDidLoad里面加入以下代码:

    1
    2
    self.accountStore = [[ACAccountStore alloc] init];
    self.twitterAccountType = [self.accountStore accountTypeWithAccountTypeIdentifier:ACAccountTypeIdentifierTwitter];

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    - (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 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后面添加如下代码;

    1
    2
    3
    4
    [[self requestAccessToTwitterSignal]
      subscribeNext:^(id x) {
        NSLog(@"Access granted");  } error:^(NSError *error) {
        NSLog(@"An error occurred: %@", error);  }];

    如果你运行程序,将会看到一个弹出框:

    Simulator Screen Shot 2016年1月16日 下午2.59.49.png

    提示是否允许访问权限,如果ok,则打印出来Access granted ,否则将会走error。

    Accounts Framework会记住你的决定,因此如果想再次测试,你需要针对模拟机进行:Reset Contents and Settings。

    Chaining Signals

    一旦用户允许访问Twitter账户,为了执行twitter,程序将会不断监听搜索内容textField的变化.

    程序需要等待信号,它请求访问Twitter去发出completed事件,然后订阅textField的信号。不同信号连续的链是一个共有的问题,但是ReactiveCocoa处理起来非常优雅。

    用下面的代码替换当前在viewDidLoad后面的管道:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    [[[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:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    [[[[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);  }];

    运行就可以在控制台看到只有三个以上的才能输出。

    图解一下上边的管道:

    PipelineWithThen.png

    程序管道从requestAccessToTwitterSignal信号开始,然后转换到tac_textSignal。同事next事件通过filter,最后到达订阅block.你也可以看到任何通过第一步的error事件。

    现在你有一个发出搜索text的信号,它可以用来搜索Twitter了。很有趣吧。

    Searching Twitter

    Social Framework是一个访问Twitter 搜索API的选项。然而,它并无法响应搜索,下一步就是给信号包括API请求方法。在当前的控制器中,添加如下方法:

    1
    2
    3
    4
    5
    - (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;
     }

    这创建了一个请求:搜索Twitter(V.1.1REST API)。这个是调用Twitter的api。

    下一步就是创建一个基于request的信号量。添加如下方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    - (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 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方法中进一步添加信号量:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    [[[[[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,我们发现如果想加载图片的话必须在主线程里面,但是他不在主线程中,所以我们就可以做如下操作:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    [[[[[[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中导入:

    1
    2
    #import "RWTweet.h"
    #import "NSArray+LinqExtensions.h"

    然后在输出json数据的地方修改如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    [[[[[[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.加载数据的效果如下:

    Simulator Screen Shot 2016年1月16日 下午4.28.10.png

    Asynchronous Loading of Images

    现在内容都加载出来了,就差图片了。在RWSearchResultsViewController.m中添加如下方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    -(RACSignal *)signalForLoadingImage:(NSString *)imageUrl {
      
      RACScheduler *scheduler = [RACScheduler
                             schedulerWithPriority:RACSchedulerPriorityBackground];
      
      return [[RACSignal createSignal:^RACDisposable *(id 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:方法里面添加:

    1
    2
    3
    4
    5
    6
    7
    cell.twitterAvatarView.image = nil;
      
    [[[self signalForLoadingImage:tweet.profileImageUrl]
      deliverOn:[RACScheduler mainThreadScheduler]]
      subscribeNext:^(UIImage *image) {
       cell.twitterAvatarView.image = image;
      }];

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

    Simulator Screen Shot 2016年1月16日 下午5.07.35.png

    Throttling(限流)

    你可能注意到这个问题:每次输入一个字符串都会立即执行然后导致刷新太快 ,导致每秒会显示几次搜索结果。这不是理想的状态。

    一个好的解决方式就是如果搜索内容不变之后的时间间隔后在搜索比如500毫秒。

    而ReactiveCocoa是这个工作变的如此简单。

    打开RWSearchFormViewController.m然后更新管道,调整如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    [[[[[[[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操作只是发送一个操作,这个操作在时间到之后继续进行。

  • 相关阅读:
    [日常摸鱼]bzoj1470[noi2002]Savage
    [日常摸鱼][POI2000]病毒-Tire图(AC自动机)+dfs
    [日常摸鱼]luogu3398仓鼠找sugar-树链剖分
    [日常摸鱼]luogu1613跑路
    [日常摸鱼]bzoj4802 欧拉函数-PollardRho大整数分解算法
    [日常摸鱼]bzoj1444 [JSOI2009]有趣的游戏——AC自动机+矩阵
    [日常摸鱼]bzoj1038 [ZJOI2008]瞭望塔-模拟退火/几何
    [日常摸鱼]poj2420 A Star not a Tree?
    [日常摸鱼]字符串相关
    图论-拓扑排序-应用
  • 原文地址:https://www.cnblogs.com/diweinan/p/6214189.html
Copyright © 2020-2023  润新知