首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

iOS架构思想——继承和面向接口

前言

在开篇之前思考几个问题?

1、继承最大的缺点是什么?

2、为什么说耦合也可能是一种需求?

3、有哪些场景不适合使用继承?

4、继承本身就具有高耦合性,但却可以实现代码复用,有哪些替代方案可以去除高耦合性并实现代码的复用?

5、iOS 开发中有否有必要同一派生 ViewController?

6、什么是面向切面编程思想?

7、为什么Swift着力宣传面向协议的思想,而OC 中面向协议的思想为什么不能像Swift那样得以普及?

8、函数式链式编程中如何对外控制函数调用的先后顺序?如:Masonry (面向接口解决问题)

在接下来的分析中,这些问题都会一一得到解答,保证干货满满。笔者原本想着围绕继承和面向接口各写一片文章,但实际继承和面向接口在某些方面还有很多的关联性,因此这里索性合二为一。

一、继承 (优缺点、使用原则、替代方案)

二、ViewController是否应统一继承

三、面向接口思想

四、多态和面向接口的选择

五、面向接口实现顺序控制

一、继承

1.1 继承的优缺点

继承、封装、多态是面向对象的三大支柱。关于继承毫无疑问最大的优点是代码复用。但是很多时候继承也可能会被无止境的滥用,造成代码结构散乱,后期维护困难等,其中有可能带来最大的问题是。

1.2 继承的使用的原则

假设你的代码是针对多平台多版本的,并且你需要针对每个平台每个版本写一些代码。

这时候更合理的做法可能是创建一个 OBJDevice 类,让一些子类如 OBJIPhoneDevice 和 OBJIPadDevice ,甚至更深层的子类如 OBJIPhone5Device 来继承,并让这些子类重写特定的方法。关于这个场景就非常适合使用继承,因为总的来说它满足如下条件:

父类OBJDevice只是给其他派生的子类提供服务,OBJDevice只做自己分内的事情,并不涉及子类的业务逻辑。不同的业务逻辑由不同的子类自己去完成。子类和父类各做自身的事情,互不影响和干扰。

父类OBJDevice 的变化要在所有子类中得以体现。也就是说父类牵一动发全部子类,可以理解为此时的高耦合是一种需求,而不是一种缺点。如果满足上述两种条件,可以考虑使用继承。另外,实际开发中如果继承超过2层的时候,就要慎重这个继承的方案了,因为这可能是滥用继承的开始。

1.3 替代继承的方式

针对不适合用继承来做的事,或不想用继承来做的,还有如下几种备选方案可以适合不同的场景,有利于打开你的思路。

1.3.1 协议

假设原本已经开发了一个继承NSObject的音频播放器VoicePlayer,但此时想支持OGG格式的音频。而实际上之前的VoicePlayer和现在想要开发的音频播放器类,只是对外提供的API类似,内部实现代码却差别很大。

这里简单说明一下OGG格式音频在游戏开发中用的比较普遍,笔者之用原生开发一款游戏应用时,就曾使用过OGG格式音频,相比于其他音频而言,OGG最大的特点是体积更小。

一段音频中,没有声音的那一部分将不暂用任何体积,而类似MP3格式则不同,即使是没声音,依然会存在体积占用。参照上面关于继承的使用原则可知,此时继承并不适合这种场景。

笔者给出的答案是通过协议提供相同的接口,代码结构如下:

@protocolVoicePlayerProtocol

- (void)play;

- (void)pause;

@end

@classNormalVoicePlayer:NSObject

@end

@classOGGVoicePlayer:NSObject

@end

1.3.2 用组合替代继承

如果想重用已有的代码而不想共享同样的接口,组合便是首选。

假如:A界面有个输入框,会根据服务器上用户的输入历史来自动补全,叫。

后来某天来了个需求,在另外一个界面中,也用到这个输入框,除了根据输入历史补全,增加一个自动补全邮箱的功能,就是用户输入@后,我们自动补全一些域名。这个功能很简单,结构如下:

@interfaceAutoCompleteTextField:UITextField

- (void)autoCompleteWithUserInfo;

@end

@interfaceAutoCompleteMailTextField:AutoCompleteTextField

- (void)autoCompleteWithMail;

@end

过两天,产品经理希望有个本地输入框能够根据本地用户信息来补全,而不是根据服务器的信息来自动补全,我们可以轻松通过覆盖来实现:

app上线一段时间之后,UED不知哪根筋搭错了,决定要修改搜索框的UI,于是添加个初始化函数得以解决。

重点来了,但是有一天,隔壁项目组的哥们想把我们的本地补全输入框移植到他们的项目中。这个可就麻烦了,因为使用要引入,而本身也带着API相关的对象,同时还有数据解析的对象。 也就是说,要想给另外一个TEAM,差不多整个网络层框架都要移植过去。

上面这个问题总结来说是两种类型问题:第一种类型问题是改了一处,其他都要改,但是勉强还能接受;第二种类型就是代码服用的时候,要把所有相关依赖都拷贝过去才能解决问题;两种类型的问题都说明了继承的高耦合性,牵一而动全身的特性。

关于上述问题最佳的解决方案,笔者认为是通过组合的形式,区分不同的模块来处理,输入框本身的UI可以作为一个模块,本地搜索提示和服务器搜索提示可以作为不同的模块分别处理。实际使用中可以通过不同的模块组合,实现不同的功能。

1.3.3 类别

有时可能会想在一个对象的基础上增加额外的功能形成另外一个对象,继承是一种很容易想到的方法。还有另外一种比较好的方案是通过类别。为该对象扩展方法,按需调用,比如为NSArray增加一个移除第一个元素的方法:

@interfaceNSArray (OBJExtras)- (void)removingFirstObject;@end

1.3.4 配置对象

假设某个app中有主题切换,其中每种主题都对应和两个属性。按照继承的思路我们很有可能会先写一个父类,为这个父类实现一个空的setupStyle方法,然后各种不同风格的主题分别是一个子类,重写父类的方法。

其实大可不必这样做,完全可以创建一个的类,该类中具有和属性。可以事先创建几种主题, Theme 在其初始化函数中获取一个配置类 ThemeConfiguration 的值即可。相比继承而言,就不用创建那么多文件,以及父类中还要写一个空方法。

二、ViewController是否应统一继承

2.1 不统一继承的理由

如果ViewController统一继承了父类控制器,首先可能会涉及到上面说到的高耦合的一个项目,缺点;除此之外,还会涉及上手接受成本问题,新手接受需要对父类控制器的使用有一定的了解;

另外,如果涉及项目迁移问题,在迁移子类控制器的同时还要将父类控制器也迁移出去。最后一个理由是,即使不通过继承,同样能达到对项目控制器进行统一配置。

2.2 面向切面(AOP)思想简介

上面也说了几种替代继承的方法,如果ViewController不通过继承的方式实现,那么首选的替代方式是什么?这里我们可以采用面向切面的编程思想和分类结合的方式替代控制器的继承。

首先简单说下面向切面的编程思想(AOP),听起来很高大上,实际上很多iOS开发者应该都用过,在iOS中最直接的体现就是借助 Method Swizzling 实现方法替换。

一般,主要的功能是日志记录,性能统计,安全控制,事务处理,异常处理等等。主要的意图是:将日志记录,性能统计,安全控制,事务处理,异常处理等代码从业务逻辑代码中划分出来,通过对这些行为的分离,我们希望可以将它们独立到非指导业务逻辑的方法中,进而改 变这些行为的时候不影响业务逻辑的代码。

可以通过预编译方式和运行期动态代理实现在不修改源代码的情况下给程序动态统一添加功能的一种技术。

假设把应用程序想成一个立体结构的话,OOP的利刃是纵向切入系统,把系统划分为很多个模块(如:用户模块,文章模块等等),而AOP的利刃是横向切入系统,提取各个模块可能都要重复操作的部分(如:权限检查,日志记录等等)。

2.3 方案实现

面向切面的思想可以实现系统资源的统一配置,iOS 中的替换系统方法可达到同样的效果。这里笔者更为推荐使用第三方开源库Aspects去拦截系统方法。

我们可以创建一个叫做ViewControllerConfigure的类,实现如下代码。

//.h文件@interface ViewControllerConfigure : NSObject@end//.m文件#import "ViewControllerConfigure.h"#import #import @implementation ViewControllerConfigure+ (void)load

{

[superload];

[ViewControllerConfigure sharedInstance];

}

+ (instancetype)sharedInstance

{staticdispatch_once_tonceToken;staticViewControllerConfigure *sharedInstance;dispatch_once(&onceToken, ^{

sharedInstance = [[ViewControllerConfigure alloc] init];

});returnsharedInstance;

}

- (instancetype)init

{self= [superinit];if(self) {/* 在这里做好方法拦截 */

[UIViewControlleraspect_hookSelector:@selector(loadView) withOptions:AspectPositionAfter usingBlock:^(idaspectInfo){

[selfloadView:[aspectInfo instance]];

} error:NULL];

[UIViewControlleraspect_hookSelector:@selector(viewWillAppear:) withOptions:AspectPositionAfter usingBlock:^(id aspectInfo,BOOLanimated){

[selfviewWillAppear:animated viewController:[aspectInfo instance]];

} error:NULL];

}returnself;

}/*

下面的这些方法中就可以做到自动拦截了。

所以在你原来的架构中,大部分封装UIViewController的基类或者其他的什么基类,都可以使用这种方法让这些基类消失。

*/#pragma mark - fake methods- (void)loadView:(UIViewController *)viewController

{NSLog(@" loadView");

}

- (void)viewWillAppear:(BOOL)animated viewController:(UIViewController*)viewController

{/* 你可以使用这个方法进行打日志,初始化基础业务相关的内容 */

NSLog(@"viewWillAppear");

}@end

关于上面的代码主要说三点:

1、借助 load 方法,实现代码无任何入性型。

当类被引用进项目的时候就会执行函数(在main函数开始执行之前),与这个类是否被用到无关,每个类的函数只会自动调用一次。除了这个案列,在实际开发中笔者曾这么用过load方法,将app启动后的广告逻辑相关代码全部放到一个类中的load方法,实现广告模块对项目的无入侵性。在类或者其子类的第一个方法被调用前调用。即使类文件被引用进项目,但是没有使用,initialize不会被调用。由于是系统自动调用,也不需要再调用 [super initialize] ,否则父类的initialize会被多次执行。

2、不单单可以替换和方法,还可以替换控制器其他生命周期相关方法,在这些方法中实现对控制器的统一配置。如view背景颜色、统计事件等。

3、控制器中避免不了还会拓展一些方法,如无网络数据提示图相关方法,此时可以借助实现,在无法避免使用属性的情况下,可以借助运行时添加属性。

关于控制器的集成问题就先说到这,接下来看看面向接口的思想。

三、面向接口思想

对于接口这一概念的支持,不同语言的实现形式不同。Java中,由于不支持多重继承,因此提供了一个Interface关键词。而在C++中,通常是通过定义抽象基类的方式来实现接口定义的。

Objective-C既不支持多重继承,也没有使用Interface关键词作为接口的实现(Interface作为类的声明来使用),而是通过抽象基类和协议(protocol)来共同实现接口的。OC中接口可以理解为Protocol,面向接口编程可以理解为面向协议编程。先看如下两端代码:

ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:url];

[request setDidFinishSelector:@selector(requestDone:)];

[request setDidFailSelector:@selector(requestWrong:)];

[request startAsynchronous];

观察上述两段代码,是否发现第二段网络请求代码相比第一段更容易使用。因为第二段代码只需初始化对象,然后调用方法传参即可,而第一段代码要先初始化,然后设置一堆属性,最终才能发起网络请求。如果让一个新手上手,毫无疑问更喜欢采用第二种方式调用方法,因为无需对AFN掌握太多,仅记住这一个方法便可发起网络请求。

反观 ASI 要先了解并设置各种属性参数,最终才能发起网络请求。上面的两端代码并不是为了说明ASI和AFN熟好熟劣,只是想借此引出面向接口的思想。

所以,通过接口的定义,调用者可以忽略对象的属性,聚焦于其提供的接口和功能上。程序猿在首次接触陌生的某个对象时,接口往往比属性更加直观明了,抽象接口往往比定义属性更能描述想做的事情。

相比于OC,Swift 可以做到协议方法的具体实现,而 OC 则不行。

面向对象编程和面向协议编程最明显的区别在于程序设计过程中对数据类型的抽取(抽象)上,面向对象编程使用类和继承的手段,数据类型是引用类型;而面向协议编程使用的是遵守协议的手段,数据类型是值类型(Swift中的结构体或枚举)。

看一个简单的swift版面向协议范例,加入想为若干个继承自UIView的控件扩展一个抖动动画方法,可以按照如下代码实现:

// Shakeable.swift

importUIKit

protocol Shakeable { }

extension Shakeable where Self: UIView {

funcshake(){

// implementation code

}

}

如果想实现这个shake动画,相关控件只要遵守这个协议就可以了。

classCustomImageView:UIImageView,Shakeable {

}classCustomButton:UIButton,Shakeable {

}

可能有的人就会问了,直接通过实现不就可以了,这种方案是可以的。

但是,如果使用方式对于和,根本看不出来任何抖动的意图,整个类里面没有任何东西能告诉你它需要抖动。相反,通过协议可以很直白的看出抖动的意图。

这仅仅是面向协议的一个小小好处,除此之外在Swift中还有很多巧妙的用法。

importUIKit

extension UIView {

funcshake(){

}

}

四、多态和面向接口的选择

4.1 多态

不同对象以自己的方式响应相同的消息的能力叫做多态。OC中最直接的体现就是父类指针指向子类对象,如:。

4.2 多态和面向接口的对比

前段时间看了Casa大神的跳出面向对象思想受益不少。所以想把自己所理解的用文字的形式记录下来。以一个文件解析类为例,文件解析的过程中主要有两个步骤:读取文件和解析文件。

假如实际中可能会有一些格式十分特殊的文件,所用到的文件读取方式和解析方式不同于常规方式。通常按照继承的写法可能会是下面这样。

//.h文件

@interfaceFileParseTool:NSObject

- (void)parse;

- (void)analyze;

@end

//.m文件

@implementationFileParseTool

- (void)parse {

[selfreadFile];

[selfanalyze];

}

- (void)readFile {

//实现代码

....

}

- (void)analyze {

//子类要重写该方法

}

@end

如果想实现对特殊格式文件的解析,此时可能会重写父类的方法。

@interfaceSpecialFileParseTool:FileParseTool

@end

@implementationSpecialFileParseToll

- (void)analyze {

NSLog(@"%@:%s",NSStringFromClass([selfclass]), __FUNCTION__);

}

@end

按照继承的写法,会存在以下问题:

父类中的会有空方法挂在那里,对于父类而言没有任何实际意义。

如果架构工程师写父类,业务工程师实现子类。那么业务工程师很可能不清楚:哪些方法需要被覆盖重载,哪些不需要。如果子类没有覆重方法,而父类提供的只是空方法,就很容易出问题。如果子类在覆重的时候引入了其他不相关逻辑,那么子类对象就显得不够单纯,角色复杂了。

使用面向接口的方式实现代码如下:

//父类.h文件

@protocolFileParseProtocol

- (void)readFile;

- (void)analyze;

@end

@interfaceFileParseTool:NSObject

@property(nonatomic,weak)id assistant;

- (void)parse;

@end

// FileParseToolt.m

@implementationFileParseTool

- (void)parse {

[self.assistant readFile];

[self.assistant analyze];

}

@end

// SpecialFileParseTool.h

@interfaceSpecialFileParseTool:FileParseTool

@end

//SpecialFileParseTool.m

@implementationSpecialFileParseTool

- (instancetype)init {

self= [superinit];

if(self) {

self.assistant =self;

}

returnself;

}

- (void)analyze {

NSLog(@"analyze special file");

}

- (void)readFile {

NSLog(@"read special file");

}

@end

相比较于继承的写法,面向接口的写法恰好能弥补上述三个缺陷:

父类中将不会再用空方法挂在那里。

原本需要覆盖重载的方法,不放在父类的声明中,而是放在接口中去实现。基于此,公司内部可以规定:不允许覆盖重载父类中的方法、子类需要实现接口协议中的方法,可以避免继承上带来的困惑。

子类中如果引入了父类的外部逻辑,此时通过协议的控制,原本引入了不相关的逻辑也很容易被剥离。

4.3 面向接口如何解决case大神的四个问题

casa提出使用多态面临的四个问题:

父类有部分public的方法是不需要,也不允许子类覆重。

父类有一些特别的方法是必须要子类去覆重的,在父类的方法其实是个空方法。

父类有一些方法即便被覆重,父类原方法还是要执行的。

父类有一些方法是可选覆重的,一旦覆重,则以子类为准。

接着结合上述第二种方式,说说是如何解决这四个问题的。

关于第一个问题,在利用面向接口的方案中,公司内部可以规定:、。

关于第二个问题,第二个方案中父类的文件中不再存在空的方法。

关于第三个问题,显然能在解答第一个问题中找到答案。

关于第四个问题,可能需要再补充一些代码来解决这个问题。主要思路是:通过在接口中设置哪些方法是必须要实现,哪些方法是可选实现的来处理对应的问题,由子类根据具体情况进行覆重。代码如下:

//父类.h文件

//流程管理相关接口,该协议可以定义子类必须实现的方法

@protocolFileParseProtocol

- (void)readFile;

- (void)analyze;

@end

//拦截相关接口,该协议可以定义可选的方法,子类可以根据实现情况选择是否重载父类方法

@protocolInterceptorProtocol

- (void)willBeginAnalyze;

- (void)didFinishAnalyze;

@end

@interfaceFileParseTool:NSObject

@property(nonatomic,weak)id assistant;

@property(nonatomic,weak)id interceptor;

- (void)parse;

@end

// FileParseToolt.m

@implementationFileParseTool

- (void)parse {

[self.assistant readFile];

if([self.interceptor respondsToSelector:@selector(willBeginAnalyze)]) {

[self.interceptor willBeginAnalyze];

}

[self.assistant analyze];

if([self.interceptor respondsToSelector:@selector(didFinishAnalyze)]) {

[self.interceptor didFinishAnalyze];

}

}

@end

// SpecialFileParseTool.h

@interfaceSpecialFileParseTool:FileParseTool

@end

//SpecialFileParseTool.m

@implementationSpecialFileParseTool

- (instancetype)init {

self= [superinit];

if(self) {

self.assistant =self;

self.interceptor =self;

}

returnself;

}

- (void)analyze {

NSLog(@"analyze special file");

}

- (void)readFile {

NSLog(@"read special file");

}

@end

4.4 何时使用多态

1、如果在子类中可能被外界使用到,则应该采用多态的形式,对外提供接口类私有要更改的方法,则应该采用IOP更为合理。

2、如果引入多态之后导致对象角色不够单纯,那就不应当引入多态,如果引入多态之后依旧是单纯角色,那就可以引入多态。

五、面向接口实现顺序控制

5.1 函数式和链式编程思想

在次之前先简单说下类似框架的函数式和链式编程的实现思路。

链式编程:只需牢记方法调用完成后返回对象本身即可,返回的对象本身可以继续调用之后的其它方法,因此可以形成链条,无止境的调用后续方法。

函数式编程:OC中主要借助block实现,通过声明一个block,类似于定义了一个“函数”,再将这个“函数”传递给调用的方法,以此来实现对调用该方法时中间过程或者对结果处理的“自定义”,内部的其他环节完全不需要暴露给调用者。实际上,调用者也根本不需要知道。

5.2 函数式和链式实现

假如封装一个数据库管理工具类,借助和编程思想,外部的调用形式可以是这样:

NSString *sql = [SQLTool makeSQL:^(SQLTool *tool) {

tool.select(nil).from(@"").where(@"");

}];

代码的实现可以是这样:

//.h文件

#import

@classSQLTool;

//定义select的block

typedefSQLTool *(^Select)(NSArray *columns);

typedefSQLTool *(^From) (NSString*tableName);

typedefSQLTool *(^Where)(NSString*conditionStr);

@interfaceSQLTool:NSObject

@property(nonatomic,strong,readonly) Select select;

@property(nonatomic,strong,readonly) From from;

@property(nonatomic,strong,readonly) Where where;

//添加这个方法,参数是一个block,传递一个SQLTool的实例

+ (NSString*)makeSQL:(void(^)(SQLTool *tool))block;

@end

//.m文件

#import"SQLTool.h"

@interfaceSQLTool()

@property(nonatomic,strong)NSString*sql;

@end

@implementationSQLTool

+ (NSString*)makeSQL:(void(^)(SQLTool *tool))block {

if(block) {

SQLTool *tool = [[SQLTool alloc] init];

block(tool);

returntool.sql;

}

returnnil;

}

- (Select)select {

return^(NSArray *columns) {

self.sql =@"select 筛选的结果";

//这里将自己返回出去

returnself;

};

}

- (From)from{

return^(NSString*tableName) {

self.sql =@"from 筛选的结果";

returnself;

};

}

- (Where)where{

return^(NSString*conditionStr){

self.sql =@"where 筛选的结果";

returnself;

};

}

@end

虽然实现了函数式和链式编程思想,但是如果想让外界调用者严格按照select、from、where的顺序去掉用,而不是毫无顺序的胡乱调用,请问这种情况该如何处理?下面会借助面向协议编程思想给出答案。

5.3 实现顺序控制

关于上面的顺序调用的问题,我们可以这样想:某个类遵从了某个协议,从一定程度上讲就等同于这个类就有了协议中声明的方法可供外界调用。如果反过来,如果没有遵从协议就无法调用了。

ps:此处所说的调用,只是从编译的角度出发。具体实现请看下面代码,总的来说没有太高深的语法相关问题。

//.h文件

#import

@classSQLToolTwo;

@protocolISelectable;//1、

@protocolIFromable;//2、

@protocolIWhereable;//3、

typedefSQLToolTwo*(^SelectTwo)(NSArray *columns);

typedefSQLToolTwo *(^FromTwo)(NSString*tableName);

typedefSQLToolTwo *(^WhereTwo) (NSString*conditionStr);

@protocolISelectable

@property(nonatomic,copy,readonly) SelectTwo selectTwo;

@end

@protocolIFromable

@property(nonatomic,copy,readonly) FromTwo fromTwo;

@end

@protocolIWhereable

@property(nonatomic,copy,readonly) WhereTwo whereTwo;

@end

@interfaceSQLToolTwo:NSObject

+ (NSString*)makeSQL:(void(^)(SQLToolTwo *tool))block;

@end

//.m文件

#import"SQLToolTwo.h"

@interfaceSQLToolTwo()

@property(nonatomic,strong)NSString*sql;

@end

@implementationSQLToolTwo

+ (NSString*)makeSQL:(void(^)(SQLToolTwo *tool))block {

if(block) {

SQLToolTwo*tool = [[SQLToolTwo alloc] init];

block(tool);

returntool.sql;

}

returnnil;

}

- (SelectTwo)selectTwo {

return^(NSArray *columns) {

self.sql =@"select 筛选的结果";

returnself;

};

}

- (FromTwo)fromTwo{

return^(NSString*tableName) {

self.sql =@"from 筛选的结果";

returnself;

};

}

- (WhereTwo)whereTwo{

return^(NSString*conditionStr){

self.sql =@"where 筛选的结果";

returnself;

};

}

@end

按照上述实现代码,你将只能严格按照selectTwo、fromTwo、whereTwo的顺序执行代码。这是因为美调用一次相关的block,返回的SQLToolTwo实例对象遵守不同的协议。

NSString*sql2 = [SQLToolTwo makeSQL:^(SQLToolTwo *tool) {

tool.selectTwo(nil).fromTwo(@"").whereTwo(@"");

}];

六、总结

文章的第一部分首先说了继承的代码复用性和高耦合性,然后总结了继承应当在何时使用,最后有说了四种替代继承的方案(协议组合类别配置对象);

第二部分利用面向切面的思想,解决了iOS开发中关于ViewController继承的问题;

第三部分简单介绍了面向接口的思想,以及和面向对象思想的比较;

第四部分涉及多态和面向接口的抉择问题;

第五部分的实现代码中包含函数式、链式以及面向接口的思想,其中重点说明了如何利用面向接口的思想控制函数的执行流程顺序问题。

作者:ZhengYaWei

链接:https://www.jianshu.com/p/39e6a8409476

ABOUT US

开发 · 干货 · 生活

带你了解IT世界的根源

投稿/合作wechat:yx1994119

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20180702B0LSOC00?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券