iOS架构实践干货:AOP替代基类 + MVVM + ReactiveObjC + JLRoutes组件化

·  阅读 5711

前言:最近公司应用架构重构,受到反革命工程师去基类,去Model等影响,将一些已经做的架构工作和思想稍微做一些总结,故此有了这篇文章,如有错误,漏洞,或者傻x之出,请包涵一笑置之,或请留言

概览:文章主要内容如下:

  1. 采用AOP思想,使用 Aspects 来完成替换 Controller ,View,ViewModel基类,和基类说拜拜
  2. View层采用 MVVM 设计模式,使用 ReactiveObjC 进行数据绑定
  3. 网络层使用 YTKNetwork 配合 ReactiveCocoa 封装网络请求,解决如何交付数据,交付什么样的数据(去Model化)等问题
  4. 采用 JLRoutes 路由 对应用进行组件化解耦

Demo 代码github地址 :FxxkBaseClass-MVVM-ReactiveObjc 比较完整可以直接拿来写项目,如果觉得有用的话请点个star 感激不尽

一、采用AOP思想,使用 Aspects 来完成替换 Controller ,View,ViewModel基类,和基类说拜拜

Casa反革命工程师 iOS应用架构谈 view层的组织和调用方案 博客中提到一个疑问

是否有必要让业务方统一派生ViewController

Casa大神回答是NO,原因如下

  1. 使用派生比不使用派生更容易增加业务方的使用成本
  2. 不使用派生手段一样也能达到统一设置的目的

对于第一点,从 集成成本上手成本 ,__架构维护成本__等因素入手,大神博客中也已经很详细。

框架不需要通过继承即能够对ViewController进行统一配置。业务即使脱离环境,也能够跑完代码,ViewController一旦放入框架环境,不需要添加额外的或者只需添加少量代码,框架也能够起到相应的作用 对于本人来说 ,具备这点的吸引力,已经足够让我有尝试一番的心思了。

对于OC来说,方法拦截很容易就想到自带的黑魔法方法调配 Method Swizzling, 至于为ViewController做动态配置,自然非__Category__莫属了

Method Swizzling 业界已经有非常成熟的三方库 Aspects, 所以Demo代码采用 Aspects 做方法拦截

ViewController拦截器部分示例代码 (与Casa大神中的如出一辙):

/* FKViewControllerIntercepter.m */

+ (void)load
{
    [super load];
    [FKViewControllerIntercepter sharedInstance];
}
// .... 单例初始化代码

- (instancetype)init
{
    self = [super init];
    if (self) {
        /* 方法拦截 */
        
        // 拦截 viewDidLoad 方法
        [UIViewController aspect_hookSelector:@selector(viewDidLoad) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo>aspectInfo){
            [self _viewDidLoad:aspectInfo.instance];
        }  error:nil];
        
        // 拦截 viewWillAppear:
        [UIViewController aspect_hookSelector:@selector(viewWillAppear:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo, BOOL animated){
            [self _viewWillAppear:animated controller:aspectInfo.instance];
        } error:NULL];
    }
    return self;
}
复制代码

至于Category已经非常熟悉了

@interface UIViewController (NonBase)

/**
 去Model&&表征化参数列表
 */
@property (nonatomic, strong) NSDictionary *params;

/**
 ViewModel 属性
 */
@property (nonatomic, strong) id <FKViewControllerProtocol> viewModel;

#pragma mark - 通用类

/**
 返回Controller的当前bounds
 
 @param hasNav 是否有导航栏
 @param hasTabBar 是否有tabbar
 @return 坐标
 */
- (CGRect)fk_visibleBoundsShowNav:(BOOL)hasNav showTabBar:(BOOL)hasTabBar;

/**
 隐藏键盘
 */
- (void)fk_hideKeyBoard;
@end
复制代码

可以自行添加一些配置方法,但是建议是:方法名前添加自定义前缀以示区分,老手请自动忽略

至此,我们已经实现了不继承基类来实现对ViewController的配置

到这时,我想是否也能够实现 ViewViewModel 的去基类 ?

我觉得可以,但是我觉得有必要牺牲一些动态性,来保证框架的稳定性,例如:我们 ViewModel 一般继承自 NSObject, 我们对 NSObject 进行拦截难免会拦截到一些不想要的东西,而且侵入性太强,容易出错。

我的做法是 :

/*     FKViewModelProtocol.h      */
@protocol FKViewModelProtocol <NSObject>

@required

/**
 viewModel 初始化属性
 */
- (void)fk_initializeForViewModel;

@end
复制代码
/*     FKLoginViewModel.h      */
@interface FKLoginViewModel : NSObject<FKViewModelProtocol>
复制代码
/*     FKViewModelIntercepter.m      */
#pragma mark - Hook Methods
- (void)_initWithInstance:(NSObject <FKViewModelProtocol> *)viewModel
{
    if ([viewModel respondsToSelector:@selector(fk_initializeForViewModel)]) {
        [viewModel fk_initializeForViewModel];
    }
}
复制代码

通过协议判断是否需要执行hook方法,这样即使对 NSObject 也能够一定程度上降低风险,而我们需要做的仅仅是遵守协议,并且实现协议,兼顾迁移成本和风险

最终我们 .h 代码会变成如下,具体协议实现在 .m 中自行实现

@interface FKLoginViewModel : NSObject<FKViewModelProtocol>
@interface FKLoginViewController : UIViewController <FKViewControllerProtocol>
@interface FKLoginButton : UIButton <FKViewProtocol>
@interface FKLoginAccountInputTableViewCell : UITableViewCell <FKViewProtocol>
复制代码

具体详细代码请见文章开头Demo

总结,我们通过 Category方法拦截 实现了方法的动态配置,对于 View ViewModel __ViewController__的基类,基本上可以说一句 Fxxk Off了

二、View层采用 MVVM 设计模式,使用 ReactiveObjC 进行数据绑定

前提,适合自己的才是最好的

MVC

作为老牌思想MVC,大家早已耳熟能详,MVC素有 Massive VC之称,随着业务增加,Controller将会越来越复杂,最终Controller会变成一个"神类", 即有网络请求等代码,又充斥着大量业务逻辑,所以为Controller减负,在某些情况下变得势在必行

MCVMVMV.gif

MVVM是基于胖Model的架构思路建立的,然后在胖Model中拆出两部分:Model和ViewModel (:胖Model 是指包含了一些弱业务逻辑的Model)

胖Model__实际上是为了减负 Controller 而存在的,而 MVVM 是为了拆分__胖Model , 最终目的都是为了减负__Controller__

我们知道,苹果MVC并没有专门为网络层代码分专门的层级,按照以往习惯,大家都写在了__Controller__ 中,这也是__Controller__ 变Massive得元凶之一,现在我们可以将网络请求等诸如此类的代码放到__ViewModel__中了 (文章后半部分将会描述ViewModel中的网络请求)

那么,ReactiveCocoa 是实现MVVM必要的吗 ?

NO, MVVM 重在是存在 ViewModel 而不是 使用了__ReactiveCocoa__

数据流向

正常的网络请求获取数据,然后更新View自然不必多说,那么如果View产生了数据要怎么把数据给到Model,由于View不直接持有ViewModel,所以我们需要有个桥梁 ReactiveCocoa, 通过 Signal 来和 ViewModel 通信,这个过程我们使用 通知 或者 Target-Action也可以实现相同的效果,只不过没有 ReactiveCocoa 如此方便罢了

/*  View -> ViewModel 传递数据示例   */
#pragma mark - Bind ViewModel
- (void)bindViewModel:(id<FKViewModelProtocol>)viewModel withParams:(NSDictionary *)params
{
    
    if ([viewModel isKindOfClass:[FKLoginViewModel class]]){
        
        FKLoginViewModel *_viewModel = (FKLoginViewModel *)viewModel;
        // 绑定账号 View -> ViewModel 传递数据 
        @weakify(self);
        RAC(_viewModel, userAccount) = [[self.inputTextFiled.rac_textSignal takeUntil:self.rac_prepareForReuseSignal] map:^id _Nullable(NSString * _Nullable account) {
            
            @strongify(self);
            // 限制账号长度
            if (account.length > 25) {
                self.inputTextFiled.text = [account substringToIndex:25];
            }
            return self.inputTextFiled.text;
        }];
    }
}
复制代码

上面代码给出了__View__ -> ViewModel 绑定的一个例子 具体一些详情,可以直接看Demo

MVVM一些总结:

  1. View <-> C <-> ViewModel <-> Model 实际上应该称之为MVCVM
  2. Controller 将不再直接和 Model 进行绑定,而通过桥梁ViewModel
  3. 最终 Controller 的作用变成一些UI的处理逻辑,和进行View和__ViewModel__的绑定
  4. MVVMMVC 兼容
  5. 由于多了一层 ViewModel, 会需要写一些胶水代码,所以代码量会增加

三、网络层使用 YTKNetwork 配合 ReactiveCocoa 封装网络请求,解决如何交付数据,交付什么样的数据(去Model化)等问题

YTKNetwork 是猿题库 iOS 研发团队基于 AFNetworking 封装的 iOS 网络库,其实现了一套 High Level 的 API,提供了更高层次的网络访问抽象。

笔者对 YTKNetwork 进行了一些封装,结合 ReactiveCocoa,并提供 reFormatter 接口对服务器响应数据重新处理,灵活交付给业务层

接下来,本文会回答两个问题

  1. 以什么方式将数据交付给业务层?
  2. 交付什么样的数据 ?

对于第一个问题

一、以什么方式将数据交付给业务层?

虽然 iOS应用架构谈 网络层设计方案 中 Casa大神写到 尽量不要用block,应该使用代理

的确,Block难以追踪和定位错误,容易内存泄漏, YTKNetwork 也提供代理方式回调

@protocol YTKRequestDelegate <NSObject>

@optional
///  Tell the delegate that the request has finished successfully.
///
///  @param request The corresponding request.
- (void)requestFinished:(__kindof YTKBaseRequest *)request;

///  Tell the delegate that the request has failed.
///
///  @param request The corresponding request.
- (void)requestFailed:(__kindof YTKBaseRequest *)request;

@end
复制代码

前文有说过,MVVM 并不等于 ReactiveCocoa , 但是想要体验最纯正的 ReactiveCocoa 还是__Block__较为酸爽,Demo中笔者两者都给出了代码, 大家可以自行选择和斟酌哈

我们看一下 YTKNetworkReactiveCocoa 结合的代码

- (RACSignal *)rac_requestSignal
{
    [self stop];
    RACSignal *signal = [[RACSignal createSignal:^RACDisposable * _Nullable(id<RACSubscriber>  _Nonnull subscriber) {
        // 请求起飞
        [self startWithCompletionBlockWithSuccess:^(__kindof YTKBaseRequest * _Nonnull request) {
            // 成功回调
            [subscriber sendNext:[request responseJSONObject]];
            [subscriber sendCompleted];
            
        } failure:^(__kindof YTKBaseRequest * _Nonnull request) {
            // 错误回调
            [subscriber sendError:[request error]];
        }];
        
        return [RACDisposable disposableWithBlock:^{
            // Signal销毁 停止请求
            [self stop];
        }];
    }] takeUntil:[self rac_willDeallocSignal]];
    
    //设置名称 便于调试
    if (DEBUG) {
        [signal setNameWithFormat:@"%@ -rac_xzwRequest",  RACDescription(self)];
    }
    
    return signal;
}
复制代码

写了一个简单的 Category FKBaseRequest+Rac.h

__ViewModel__中使用 RACCommand 封装调用:

- (RACCommand *)loginCommand
{
    if (!_loginCommand) {
        @weakify(self);
        _loginCommand = [[RACCommand alloc] initWithSignalBlock:^RACSignal * _Nonnull(id  _Nullable input) {
            @strongify(self);
      
            return [[[FKLoginRequest alloc] initWithUsr:self.userAccount pwd:self.password] rac_requestSignal];
        }];
    }
    return _loginCommand;
}
复制代码

Block方式交付业务

FKLoginRequest *loginRequest = [[FKLoginRequest alloc] initWithUsr:self.userAccount pwd:self.password];
return [[[loginRequest rac_requestSignal] doNext:^(id  _Nullable x) {
    
    // 解析数据
    [[NSUserDefaults standardUserDefaults] setObject:@(YES) forKey:@"isLogin"];
    
}] materialize];
复制代码

Delegate方式交付业务

FKLoginRequest *loginRequest = [[FKLoginRequest alloc] initWithUsr:self.userAccount pwd:self.password];
// 数据请求响应代理 通过代理回调
loginRequest.delegate = self;
return [loginRequest rac_requestSignal];

#pragma mark - YTKRequestDelegate
- (void)requestFinished:(__kindof YTKBaseRequest *)request
{
    // 解析数据
    [[NSUserDefaults standardUserDefaults] setObject:@(YES) forKey:@"isLogin"];
}
复制代码

至此我们解决了 __如何交付数据__的问题

二、交付什么样的数据 ?

现在诸如 JSONModelYYModel 之类的Json转Model的库也非常多,大多数Json对象,网络请求成功直接就被转成Model了

然而 iOS应用架构谈 网络层设计方案 中给出了两种有意思的交付思路

  1. 使用 reformer 对数据进行清洗
  2. 去特定对象表征 (去Model)
  1. 使用 reformer 对数据进行清洗

Casa文章中好处已经写得很详细了,通过不同的 reformer 来重塑和交付不同的业务数据,可以说是非常灵活了

reformer

笔者在网络层封装 FKBaseRequest.h 中 给出了 FKBaseRequestFeformDelegate 接口来重塑数据

@protocol FKBaseRequestFeformDelegate <NSObject>

/**
 自定义解析器解析响应参数

 @param request 当前请求
 @param jsonResponse 响应数据
 @return 自定reformat数据
 */
- (id)request:(FKBaseRequest *)request reformJSONResponse:(id)jsonResponse;

@end
复制代码

然后在对应的 reformer 对数据进行重塑

#pragma mark - FKBaseRequestFeformDelegate
- (id)request:(FKBaseRequest *)request reformJSONResponse:(id)jsonResponse
{
    if([request isKindOfClass:FKLoginRequest.class]){
        // 在这里对json数据进行重新格式化
    }
    return jsonResponse;
}
复制代码

也可以直接在子类的 RequestManager 中覆盖父类方法达到一样的效果

/* FKLoginRequest.m */

// 可以在这里对response 数据进行重新格式化, 也可以使用delegate 设置 reformattor
- (id)reformJSONResponse:(id)jsonResponse
{

}
复制代码

2. 去特定对象表征 (去Model)

这思路可以说是业界的泥石流了

去Model也就是说,使用NSDictionary形式交付数据,对于网络层而言,只需要保持住原始数据即可,不需要主动转化成数据原型

但是会存在一些小问题

  1. 去Model如何保持可读性?
  2. 复杂和多样的数据结构如何解析?

Casa大神 提出了 使用__EXTERN__ + Const 字符串形式,并建议字符串跟着reformer走,个人觉得很多时候API只需要一种解析格式,所以Demo跟着 APIManager 走,其他情况下常量字符串建议听从 Casa大神 的建议,

常量定义:

/* FKBaseRequest.h */
// 登录token key
FOUNDATION_EXTERN NSString *FKLoginAccessTokenKey;

/* FKBaseRequest.m */
NSString *FKLoginAccessTokenKey = @"accessToken";
复制代码

个人觉得 在 .h.m 文件中要同时写太多代码,我们也可以使用局部常量的形式,只要在 .h 文件中定义即可

// 也可以写成 局部常量形式
static const NSString *FKLoginAccessTokenKey2 = @"accessToken";
复制代码

最终那么我们的reformer可能会变成这样子

- (id)request:(FKBaseRequest *)request reformJSONResponse:(id)jsonResponse
{
    if([request isKindOfClass:FKLoginRequest.class]){
        // 在这里对json数据进行重新格式化
        
        return @{
                 FKLoginAccessTokenKey : jsonResponse[@"token"],
                 };
    }
    return jsonResponse;
}
复制代码

复杂和多样的数据结构如何解析? 有时候,reformer 交付过来的数据,我们需要解析的可能是字符串类型,也可能是NSNumber类型,也有可能是数组 为此,笔者提供了一系列 __Encode Decode__方法,来降低解析的复杂度和安全性

#pragma mark - Encode Decode 方法
// NSDictionary -> NSString
FK_EXTERN NSString* DecodeObjectFromDic(NSDictionary *dic, NSString *key);
// NSArray + index -> id
FK_EXTERN id        DecodeSafeObjectAtIndex(NSArray *arr, NSInteger index);
// NSDictionary -> NSString
FK_EXTERN NSString     * DecodeStringFromDic(NSDictionary *dic, NSString *key);
// NSDictionary -> NSString ? NSString : defaultStr
FK_EXTERN NSString* DecodeDefaultStrFromDic(NSDictionary *dic, NSString *key,NSString * defaultStr);
// NSDictionary -> NSNumber
FK_EXTERN NSNumber     * DecodeNumberFromDic(NSDictionary *dic, NSString *key);
// NSDictionary -> NSDictionary
FK_EXTERN NSDictionary *DecodeDicFromDic(NSDictionary *dic, NSString *key);
// NSDictionary -> NSArray
FK_EXTERN NSArray      *DecodeArrayFromDic(NSDictionary *dic, NSString *key);
FK_EXTERN NSArray      *DecodeArrayFromDicUsingParseBlock(NSDictionary *dic, NSString *key, id(^parseBlock)(NSDictionary *innerDic));

#pragma mark - Encode Decode 方法
// (nonull Key: nonull NSString) -> NSMutableDictionary
FK_EXTERN void EncodeUnEmptyStrObjctToDic(NSMutableDictionary *dic,NSString *object, NSString *key);
// nonull objec -> NSMutableArray
FK_EXTERN void EncodeUnEmptyObjctToArray(NSMutableArray *arr,id object);
// (nonull (Key ? key : defaultStr) : nonull Value) -> NSMutableDictionary
FK_EXTERN void EncodeDefaultStrObjctToDic(NSMutableDictionary *dic,NSString *object, NSString *key,NSString * defaultStr);
// (nonull Key: nonull object) -> NSMutableDictionary
FK_EXTERN void EncodeUnEmptyObjctToDic(NSMutableDictionary *dic,NSObject *object, NSString *key);
复制代码

我们的reformer可以写成这样子

#pragma mark - FKBaseRequestFeformDelegate
- (id)request:(FKBaseRequest *)request reformJSONResponse:(id)jsonResponse
{
    if([request isKindOfClass:FKLoginRequest.class]){
        // 在这里对json数据进行重新格式化
        
        return @{
                 FKLoginAccessTokenKey : DecodeStringFromDic(jsonResponse, @"token")
                 };
    }
    return jsonResponse;
}
复制代码

解析有可能是这样子

NSString *token = DecodeStringFromDic(jsonResponse, FKLoginAccessTokenKey)
复制代码

好了,至此我们解决了两个问题

  1. 以什么方式将数据交付给业务层 答:delegate 最佳,block为次
  2. 交付什么样的数据 答:纯字典,去Model

四、采用 JLRoutes 路由 对应用进行组件化解耦

iOS应用架构谈 组件化方案 一文中 Casa 针对 蘑菇街组件化 提出了质疑,质疑点主要在这几方面

  1. App启动时组件需要注册URL
  2. URL调用组件方式不太好传递类似 UIImage 等非常规对象
  3. URL需要添加额外参数可读性差,所以没必要使用URL

对于 App启动时组件需要注册URL 顾虑主要在于,注册的URL需要在应用生存周期内常驻内存,如果是注册__Class__还好些,如果注册的是实例,消耗的内存就非常可观了 至于后面几点,大家就仁者见仁,智者见智了

依小的拙见,URL方式,还是利大于弊的,Demo中采用了 JLRoutes 三方库进行路由注册,仅仅注册路由表规则,不注册 Class,不注册实例,内存消耗并不高

#pragma mark - 路由表
NSString *const FKNavPushRoute = @"/com_madao_navPush/:viewController";
NSString *const FKNavPresentRoute = @"/com_madao_navPresent/:viewController";
NSString *const FKNavStoryBoardPushRoute = @"/com_madao_navStoryboardPush/:viewController";
NSString *const FKComponentsCallBackRoute = @"/com_madao_callBack/*";
复制代码

而且__JLRoutes__ 还支持 * 来进行通配,路由表如何编写大家可以自由发挥

对应的路由事件 handler

// push
// 路由 /com_madao_navPush/:viewController
[[JLRoutes globalRoutes] addRoute:FKNavPushRoute handler:^BOOL(NSDictionary<NSString *,id> * _Nonnull parameters) {
    
    dispatch_async(dispatch_get_main_queue(), ^{
        [self _handlerSceneWithPresent:NO parameters:parameters];
        
    });
    return YES;
}];

// present
// 路由 /com_madao_navPresent/:viewController
[[JLRoutes globalRoutes] addRoute:FKNavPresentRoute handler:^BOOL(NSDictionary<NSString *,id> * _Nonnull parameters) {
    
    dispatch_async(dispatch_get_main_queue(), ^{
        [self _handlerSceneWithPresent:YES parameters:parameters];
        
    });
    return YES;
}];

#pragma mark - Private
/// 处理跳转事件
- (void)_handlerSceneWithPresent:(BOOL)isPresent parameters:(NSDictionary *)parameters
{
    // 当前控制器
    NSString *controllerName = [parameters objectForKey:FKControllerNameRouteParam];
    UIViewController *currentVC = [self _currentViewController];
    UIViewController *toVC = [[NSClassFromString(controllerName) alloc] init];
    toVC.params = parameters;
    if (currentVC && currentVC.navigationController) {
        if (isPresent) {
            [currentVC.navigationController presentViewController:toVC animated:YES completion:nil];
        }else
        {
            [currentVC.navigationController pushViewController:toVC animated:YES];
        }
    }
}
复制代码

通过URL中传入的组件名动态注册,处理相应跳转事件,并不需要每个组件一一注册

使用URL路由,必然URL会散落到代码各个地方

NSString *key = @"key";
NSString *value = @"value";
NSString *url = [NSString stringWithFormat:@"/com_madao_navPush/%@?%@=%@", NSStringFromClass(ViewController.class), key, value];
[[UIApplication sharedApplication] openURL:[NSURL URLWithString:url]];
复制代码

诸如此类丑陋的代码,散落在各个地方的话简直会让人头皮发麻, 所以笔者在 JLRoutes+GenerateURL.h 写了一些 __Helper__方法

/**
 避免 URL 散落各处, 集中生成URL
 
 @param pattern 匹配模式
 @param parameters 附带参数
 @return URL字符串
 */
+ (NSString *)fk_generateURLWithPattern:(NSString *)pattern parameters:(NSArray *)parameters;

/**
 避免 URL 散落各处, 集中生成URL
 额外参数将被 ?key=value&key2=value2 样式给出
 
 @param pattern 匹配模式
 @param parameters 附加参数
 @param extraParameters 额外参数
 @return URL字符串
 */
+ (NSString *)fk_generateURLWithPattern:(NSString *)pattern parameters:(NSArray *)parameters extraParameters:(NSDictionary *)extraParameters;

/**
 解析NSURL对象中的请求参数
 http://madao?param1=value1&param2=value2 解析成 @{param1:value1, param2:value2}
 @param URL NSURL对象
 @return URL字符串
 */
+ (NSDictionary *)fk_parseParamsWithURL:(NSURL *)URL;

/**
 将参数对象进行url编码
 将@{param1:value1, param2:value2} 转换成 ?param1=value1&param2=value2
 @param dic 参数对象
 @return URL字符串
 */
+ (NSString *)fk_mapDictionaryToURLQueryString:(NSDictionary *)dic;

复制代码

宏定义Helper

#undef JLRGenRoute
#define JLRGenRoute(Schema, path) \
([NSString stringWithFormat: @"%@:/%@", \
Schema, \
path])

#undef JLRGenRouteURL
#define JLRGenRouteURL(Schema, path) \
([NSURL URLWithString: \
JLRGenRoute(Schema, path)])
复制代码

最终我们的调用可以变成

NSString *router = [JLRoutes fk_generateURLWithPattern:FKNavPushRoute parameters:@[NSStringFromClass(ViewController.class)] extraParameters:nil];
[[UIApplication sharedApplication] openURL:JLRGenRouteURL(FKDefaultRouteSchema, router)];
复制代码

虽然还是有点长,但是是不是好多了

总结与反思

  1. 适合自己的才是最好的,没最好,要刚好
  2. 脱离业务谈架构都是耍流氓,(请叫我小流氓 = =,逃)
  3. 自身功力还是不够,都是在他人基础上衍生或者照搬,惭愧惭愧,希望有自己变成大牛的那一天
  4. iOS寒冬路难且艰,大家且行且珍惜

Demo代码github地址 :FxxkBaseClass-MVVM-ReactiveObjc 大家按需取用

分类:
iOS
分类:
iOS
收藏成功!
已添加到「」, 点击更改