代码重构之道
1. 什么是重构
1.1 重构(Refactoring)
重构这个概念对于当代的开发人员来说已经不陌生,它最早来自 smalltalk 圈子,之后非正式的使用了很多年,而直到 1993 年,William Opdyke 在他的博士论文发表了第一篇著名的关于重构的文章,系统的提出了重构的理论以及其研究成果。
定义
名词:
对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本
动词:
使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构
1.2 重构与设计模式的关系
设计模式是理想主义
为理想的软件设计提供目标,但是达到这个目标通常很困难,很有可能存在过度设计
重构是实用主义
满足当前需求设计同时,在版本迭代中不断改进当前的设计,尽量靠近设计模式的理想国度
相辅相成的关系,设计模式为重构提供目标,在不断的重构中达到理想状态
2. 重构的心态
2.1 不愿意重构
大多数人都不喜欢重构,就像没有人愿意给别人“擦屁股”一样,特别是对于新手来说需要克服不少障碍,主要是以下几点是我们所担忧的:
- 不知道如何重构,缺乏重构的经验
- 很难看到短期收益,如果这些利益是长远的,何必现在就付出这些努力呢?长远看来,说不定当项目收获这些利益时,你已经不在职位上
- 代码重构是一项额外工作,老板付钱给你,主要是让你编写新功能,实现新需求
- 重构可能破坏现有程序,带来预想不到的 bug
2.2 过度重构(重写)
在软件开发的初始阶段,没有人能够详细预测将来的需求变化,所以最初的软件设计肯定会有考虑不周或者不全面的地方。而一个项目几乎不可能一个人从头写到尾,在接手别人代码的时候,往往可能面对扩展难,实现难。甚至对于不断堆砌而形成的糟糕代码,往往不知该如何下手。这时对于继任者来说,常常有一股彻底推倒重建的冲动,特别是对于有代码洁癖的程序员来说更不能忍受。面对这种情况,其实更应该抑制这股冲动,冷静下来想一想,有没有更好的方案可以循序渐进的改善现有代码的设计,因为主要有几点你应该考虑:
- 重写耗时耗力,测底抛弃了原始积累
- 理想的状态有可能立马改善现有代码设计,却也会引入不少 bug,或者掉入坑里
- 比较坏的情况下,为了兼容各个时期的业务需求,重写之后的整体设计不一定会比原有设计好
- 原有设计至少在多轮或者持续强度 QA 测试环境下能够稳定运行,完全推倒重建是否能保持原有代码的稳定性
3.重构的意义
3.1 保持良好的代码设计
能够保持良好的设计,不仅可以经历长期的版本迭代和不同开发者的洗礼,更能够强化原有设计
3.2 改善原有代码质量
能够将原有糟糕或者混乱的代码,逐渐改善为设计良好的代码,提高其可读性,更易于扩展
3.3 减少包大小
能够去除重复代码,减少包大小,特别是对于 SDK 来说意义重大,更易于第三方接入
4. 什么时候该开始重构
什么时候该开始重构?当闻到代码的“坏味道”,便可以开始重构。而具体的开始时间并没有完全的标准可以衡量,这完全取决于你自己,可以随时随地,或者是将来的某一个时间。重构不分大小,小到修改一个函数名字,也是很好的一个开始 。
代码“坏味道”的常见形式:
4.1 重复代码
重复代码是开发过程中最常见最明显的“信号”,当发现工程里面有两处完全一样或者相似的代码就要考虑是否需要整合,当发现三处及以上的重复代码时,就应该毫不犹豫的进行重构。
4.2 复杂的逻辑嵌套
复杂的逻辑嵌套,最常见莫过于 if else 这对兄弟,通常多于三层以上的 if 嵌套,就会开始影响代码的可读性,甚至让阅读者迷失在无尽嵌套的丛林里。
4.3 超长的函数
超长的函数往往意味着逻辑复杂,函数越长,代码的可读性和扩展性越差。对于正常的程序员来说,看到又臭又长的函数一般都有抵触感,在心理上首先产生莫名的恐惧。而小而美的函数则能够立马让人心旷神怡,甚至不需要注释,也能够毫无障碍的快速理解设计者的意图。
4.4 超级类
超级类通常构造庞大,逻辑复杂,肩负的职责众多,俗称“God Class”,而不管是大型或者是小型项目,都会存在类似的超级类。这种超级类直接导致的后果是可读性差,耦合度高,封装性弱,难于维护,所以超级类也是重构的强烈信号。
<img src="http://bit.ly/2DeV1Gw" width="100" />
4.5 过长的参数列表
过长的参数列表指的是函数体定义了过多的参数,使得外部调用该函数时,变的难以使用。因为太长的参数列表会变得难以理解,太多的参数容易造成前后不一致,并且不易使用。在面向对象编程中,可以使用成员变量和对象的形式减少参数的传递,也能够避免由于参数的变化,而频繁修改函数的定义。
4.6 其他的坏味道
前面列举了几种比较常见的代码“坏味道”,实际开发中当然还会存在其他的一些“坏味道”,比如代码混乱,逻辑不清晰,类关系错综复杂,当闻到这些不同的“坏味道”时,都应该尝试去重构。
5. 重构的步骤
5.1 主动模式
主动模式,通过阅读项目代码,或者依赖一些自动化的代码检测工具,挖掘重构目标;
5.2 被动模式
被动模式,在项目开发中,为了满足现有需求,重构当前模块,改善原有代码设计;
5.3 整体评估
主要包括这么几个方面:
- 明确重构的最终目标,做到对重构的整个方向心里有数
- 完成这个目标需要花多少时间以及人力成本
- 重构之后可以带来哪些收益,比如改善代码质量,减少 bug 率等
- 重构之后会带来哪些风险,对原有功能或者其他模块是否会造成影响
综合考虑时间与人力成本,评估风险收益比后,做出最终的决策
5.4 列出分阶段的目标和列表
列出当前的重构目标,重构列表,分阶段,小步伐的进行重构,最终达成理想的状态;
5.5 构造测试环境
构造测试环境,编写测试用例和单元测试,分阶段的对重构成果进行测试,确认没有问题之后再进行下一阶段的重构;
5.6 团队协助
团队协作,最好是能够结对重构,进行 code review,有其他成员互相监督和把控整个过程和结果;
6. 重构方法
6.1 重新组织函数
函数是程序设计的基本单元,所以重构很大一部分是对函数进行整理,改善整个函数的组织形式,特别是针对前面提到的超长函数。
下面介绍几种比较常见的针对函数的重构方式:
(1). 修改函数名词
人通过名字可以分辨彼此,不会因为张三找到李四。同样一个好的函数名字也同等重要,通过名字便能知道函数的基本用意,不需借助过多的注释,所以重构很重要的一点就是取名字:)
(2). 简化参数
主要有两个方面,一个是去除多余的参数,有些参数可能是历史遗留或者是为将来预留的,对于这种目前派不上用场,果断先去掉。第二种是确实函数需要,但是导致参数列表过长的情况,可以考虑增加对象或者成员变量的方式去除过多的参数。
(3). 提炼函数
将复杂的函数,单独提炼出若干个粒度更小的函数,并赋予体现单一功能的函数名字
- (void)handleUrl:(NSURL *)url fromViewController:(UIViewController *)sourceViewController withUserInfo:(NSDictionary *)userInfo
{
NSString *host = [url host];
NSString *path = [url path];
if (![host isEqualToString:@"www.meituan.com"]) {
return;
}
if ([path isEqualToString:@"/chat"]) {
[self launchToChat];
} else if ([path isEqualToString:@"/placard"]) {
[self launchToPlacard];
} else if ([path isEqualToString:@"/help"]) {
[self launchToHelp];
} else if ([path isEqualToString:@"/pan/action"]) {
[self launchToPanAction];
} else if ([path isEqualToString:@"/receipt"]) {
[self launchToReceipt];
} else if ([path isEqualToString:@"/invokeForwardMessageView"]){
[self launchToInvokeForward];
} else if ([path isEqualToString:@"/microapp/calendar/profile"]){
[self launchToProfile];
} else if ([path isEqualToString:@"/rps/api/take"]){
[self launchToRps];
} else if ([path isEqualToString:@"/co/edit"]) {
[self launchToEdit];
} else {
}
}
(4). 消除多余的临时变量
有些函数中的临时变量,只是简单的获取函数的返回值,可以考虑直接去除,不仅节省代码空间,也有利于之后的重构。但是要注意有些特殊情况可能并不适合,比如函数返回值的获取比较耗时,而函数 Get 的次数比较多。
uint64_t myuid = [UISDKManager sharedManager].myUid;
NSString *ck = [UISDKManager sharedManager].xsid;
NSDictionary *userInfo = @{@"u" : @(myuid), @"ck" : ck};
重构后
NSDictionary *userInfo = @{@"u" : @([UISDKManager sharedManager].myUid), @"ck" : [UISDKManager sharedManager].xsid};
(5). 加入解释性的临时变量
将复杂表达式的结果放进一个临时变量,以此表达式的名称来解释表达式的作用。表达式有可能非常复杂而难以理解,这种情况下临时变量可以帮助你将表达式分解为比较容易管理的形式。
下面是一段广告显示的逻辑代码:
DXAdvertisementModel *adModel = [[DXAdvertisementManager sharedInstance] getNeedShowAdvertisement];
if (adModel && ![DXGuideViewController shouldShow] && [UISDKManager sharedManager].connectStatus == UIConnectStatusLogined && [UISDKManager sharedManager].callSDK.getCallStatus == DXCallStatusIdle) {
// 实现代码......
}
重构后
DXAdvertisementModel *showModel = [[DXAdvertisementManager sharedInstance] getNeedShowAdvertisement];
BOOL showADEnabled = ![DXGuideViewController shouldShow];
BOOL isLogined = [UISDKManager sharedManager].connectStatus == UIConnectStatusLogined;
BOOL callIdle = [UISDKManager sharedManager].callSDK.getCallStatus == DXCallStatusIdle;
BOOL meetingIdle = [UISDKManager sharedManager].callMeetingSDK.getCallStatus == DXCallStatusIdle;
if (showADModel && showEnabled && isLogined && callIdle && meetingIdle) {
// 实现代码......
}
(6). 将查询和修改分离
在开发中很常见的一种函数是不仅返回一个对象值,内部又存在对状态的修改。显然外界只是想 Get,确又产生了一些 Set 的”副作用”,对于这种情况我们的做法是对其进行分离。
- (NSString *)loginURLString { NSString *deviceId = [MTDXLoginMyInfo instance].deviceId; if (deviceId == nil) { [MTDXLoginMyInfo instance].deviceId = [[NSUUID UUID] UUIDString]; deviceId = [MTDXLoginMyInfo instance].deviceId; }
NSString *urlString = kProdLoginURL; if (DX_Dev) { urlString = kDevLoginURL; } urlString = [urlString stringByAppendingFormat:@"&redirect_uri=%@&tenant=%@&uuid=%@", kRedirectURI, @"meituan.com",deviceId]; return urlString;
}
重构后
- (NSString *)getDeviceId { NSString *deviceId = [MTDXLoginMyInfo instance].deviceId; if (deviceId == nil) { [MTDXLoginMyInfo instance].deviceId = [[NSUUID UUID] UUIDString]; deviceId = [MTDXLoginMyInfo instance].deviceId; } return deviceId; }
(NSString *)loginURLString:(NSString *)deviceId
{ NSString *urlString = kProdLoginURL; if (DX_Dev) { urlString = kDevLoginURL; } urlString = [urlString stringByAppendingFormat:@"&redirect_uri=%@&tenant=%@&uuid=%@", kRedirectURI, @"meituan.com",deviceId]; return urlString; }
6.2 类的处理
(1). 搬移函数
前面有介绍过函数是程序处理的基本单元,而对于各个类之间函数的迁移也是重构的常用手段。一个类如果有太多的行为,或者类与类之间有太多的耦合,这个时候就可以考虑进行相互间函数的搬移。最终目标是使类变的更加简单,所肩负的职责更加清晰。对于这个重构方法,比较常见的就是子类与子类之间,子类与父类之间相互函数的搬移。
@interface Employee : NSObject - (void)fire:(NSString *)name; @end
@interface Manager : Employee (void)setName:(NSString *)name; @end @interface Sale : Employee (void)setName:(NSString *)name; @end
重构后
@interface Employee : NSObject - (void)setName:(NSString *)name; @end
@interface Manager : Employee (void)fire:(NSString *)name; @end
(2). 搬移字段
一个类的某个字段,可能更多的被其他的类使用,或者在重构一个新的类时,这个字段可能更多的给新的类使用,这个时候就可以考虑是否需要搬移字段
@interface DXCallManager : NSObject
@property (nonatomic, assign) DXCallType callType;
@end
重构后
@interface DXCallSession : NSObject
// ......
@property (nonatomic, assign) DXCallType callType;
@end
(3). 类的拆解
类的拆解,指的是一个类太过复杂,可以将其一部分函数和字段提炼出一个新的类,使原来的类尽可能简单,满足类的单一职责的原则
@interface Employee : NSObject
- (void)fire:(NSString *)name;
- (void)program;
@end
重构后
@interface Manager : Employee - (void)fire:(NSString *)name; @end
@interface Engineer : Employee (void)program; @end
(4). 类的整合
类的整合和类的拆解有点相反,即两个类之间并无太大区别,存在比较多的重复代码,比如子类和父类并无太大区别,可重构为同一个类即可
(5). 提炼父类
提炼父类,主要指的是两个类之间存在比较多共同的行为或者重复的代码,可以考虑提炼成一个共同的父类
@interface DXCallVoiceViewModel : NSObject @property (nonatomic, assign) uint64_t peerUid; @property (nonatomic, strong) NSString *nickName; @property (nonatomic, assign) DXCallRoleType role; @property (nonatomic, assign) DXCallVoiceViewProcess viewProcess;
(instancetype)initWithPeerUid:(uint64_t)peerUid role:(DXCallRoleType)role; (void)answerIncomingCall:(DXCallAction)callAction; (void)hangup; (void)stopRing; (void)setRecordAudioEnabled:(BOOL)enabled result:(void (^)(BOOL))block; (BOOL)isAudioRecording; (void)setSpeakerEnabled:(BOOL)enabled; (BOOL)getPhoneSpeakerState; (NSString *)getCurrentSessionID; @end @interface DXCallMeetingVoiceViewModel : NSObject @property (nonatomic, strong) NSMutableArray<DXCallUserItem *> *meetingMembers; @property (nonatomic, strong) NSMutableArray<DXCallUserItem *> *invitees; @property (nonatomic, strong) DXCallUserItem *inviter; @property (nonatomic, assign) DXCallRoleType role; @property (nonatomic, assign) DXCallVoiceViewProcess viewProcess; @property (nonatomic, assign) uint64_t gid; (instancetype)initWithInvitees:(NSArray<DXCallUserItem *> *)invitees inviter:(DXCallUserItem *)inviter gid:(uint64_t)gid role:(DXCallRoleType)role viewProcess:(DXCallVoiceViewProcess)viewProcess; (void)answerIncomingCall:(DXCallAction)callAction; (void)hangup; (void)callHalfwayInvite:(NSArray<DXCallUserItem *> *)invitees; (void)stopRing; (void)setRecordAudioEnabled:(BOOL)enabled result:(void (^)(BOOL))block; (BOOL)isAudioRecording; (void)setSpeakerEnabled:(BOOL)enabled; (BOOL)getPhoneSpeakerState; (NSString *)getCurrentSessionID; @end
重构后
@interface DXCallBaseViewModel : NSObject @property (nonatomic, assign) DXCallRoleType role; @property (nonatomic, assign) DXCallVoiceViewProcess viewProcess; - (void)answerIncomingCall:(DXCallAction)callAction; - (void)hangup; - (void)stopRing; - (void)setRecordAudioEnabled:(BOOL)enabled result:(void (^)(BOOL))block; - (BOOL)isAudioRecording; - (void)setSpeakerEnabled:(BOOL)enabled; - (BOOL)getPhoneSpeakerState; - (NSString *)getCurrentSessionID; @end
@interface DXCallVoiceViewModel : DXCallBaseViewModel @property (nonatomic, assign) uint64_t peerUid; @property (nonatomic, strong) NSString *nickName; (instancetype)initWithPeerUid:(uint64_t)peerUid role:(DXCallRoleType)role; @end @interface DXCallMeetingVoiceViewModel : DXCallBaseViewModel @property (nonatomic, strong) NSMutableArray<DXCallUserItem *> *meetingMembers; @property (nonatomic, strong) NSMutableArray<DXCallUserItem *> *invitees; @property (nonatomic, strong) DXCallUserItem *inviter; @property (nonatomic, assign) uint64_t gid; (instancetype)initWithInvitees:(NSArray<DXCallUserItem *> *)invitees inviter:(DXCallUserItem *)inviter gid:(uint64_t)gid role:(DXCallRoleType)role viewProcess:(DXCallVoiceViewProcess)viewProcess; (void)callHalfwayInvite:(NSArray<DXCallUserItem *> *)invitees; @end
(6). 提炼函数
与函数一节中类似,将类中过于复杂的函数进行提炼以及封装,单独提炼出若干个粒度更小的函数
6.3 条件表达式的处理
(1). 分解条件表达式
前面有介绍过大型函数通常会使代码的可读性大幅下降,而复杂的条件表达式则会变的更难以阅读,通常也是代码复杂度上升的元凶之一。跟分解函数一样,分解条件表达式也是最常用和有力的手段
if (![SDVersion versionLessThanOrEqualTo:@"10.0.2"] && !TARGET_IPHONE_SIMULATOR && audioAuthorizationStatus == AVAuthorizationStatusAuthorized) {
[[UISDKManager sharedManager].callSDK setCallKitEnabled:YES];
[[UISDKManager sharedManager].callMeetingSDK setCallkitEnabled:YES];
} else {
[[UISDKManager sharedManager].callSDK setCallKitEnabled:NO];
[[UISDKManager sharedManager].callMeetingSDK setCallkitEnabled:NO];
}
重构后
if ([self shouldOpenCallKit]) { [[UISDKManager sharedManager].callSDK setCallKitEnabled:YES]; [[UISDKManager sharedManager].callMeetingSDK setCallkitEnabled:YES]; } else { [[UISDKManager sharedManager].callSDK setCallKitEnabled:NO]; [[UISDKManager sharedManager].callMeetingSDK setCallkitEnabled:NO]; }
(BOOL)shouldOpenCallKit { return ![SDVersion versionLessThanOrEqualTo:@"10.0.2"] && !TARGET_IPHONE_SIMULATOR && audioAuthorizationStatus == AVAuthorizationStatusAuthorized; }
(2.) 合并重复的代码片段
在条件表达式的每个分支上存在相同的一段代码,这时可以考虑将这段重复的代码移到条件表达式之外,这样不仅节省了代码空间,而且能够清楚的表达哪些东西是变化的,哪些是不变的
if ([DXAPPConfig isReleaseVersion]) {
NSString *appName = [DXAPPConfig appName];
[[SCRCrashReporter sharedReporter] startWithAppName:appName];
NSString *uid = [[NSUserDefaults standardUserDefaults] valueForKey:@"uid"];
[[SCRCrashReporter sharedReporter] configureCurrentReport:^(SCRCrashReport *report) {
report.uid = uid;
report.uuid = [MTDXLoginMyInfo instance].deviceId;
}];
} else {
NSString *appName = @"xm_ios_test";
[[SCRCrashReporter sharedReporter] startWithAppName:appName];
NSString *uid = [[NSUserDefaults standardUserDefaults] valueForKey:@"uid"];
[[SCRCrashReporter sharedReporter] configureCurrentReport:^(SCRCrashReport *report) {
report.uid = uid;
report.uuid = [MTDXLoginMyInfo instance].deviceId;
}];
}
重构后
NSString *appName;
NSString *uid;
if ([DXAPPConfig isReleaseVersion]) {
appName = [DXAPPConfig appName];
uid = [[NSUserDefaults standardUserDefaults] valueForKey:@"uid"];
} else {
appName = @"xm_ios_test";
uid = [[NSUserDefaults standardUserDefaults] valueForKey:@"uid"];
}
[[SCRCrashReporter sharedReporter] startWithAppName:appName];
[[SCRCrashReporter sharedReporter] configureCurrentReport:^(SCRCrashReport *report) {
report.uid = uid;
report.uuid = [MTDXLoginMyInfo instance].deviceId;
}];
(3). 移除临时性的控制标记变量
在循序条件表达式语句中,常常会出现临时性的控制标记变量,这时在重构的时候可以考虑移除这些临时性的控制变量,巧用 break,continue,或者 return 达到相同的目的,使我们的代码逻辑更加清晰,可读性更强
- (void)checkSecurity:(NSArray *)peoples
{
BOOL found;
for (int i; i < peoples.count; i++) {
if (!found) {
if ([peoples[i] isEqualToString:@"Don"]) {
sendAlert();
found = YES;
}
if ([peoples[i] isEqualToString:@"John"]) {
sendAlert();
found = YES;
}
}
}
}
重构后
- (void)checkSecurity:(NSArray *)peoples
{
for (int i; i < peoples.count; i++) {
if ([peoples[i] isEqualToString:@"Don"]) {
sendAlert();
break;
}
if ([peoples[i] isEqualToString:@"John"]) {
sendAlert();
break;
}
}
}
(3). 以多态取代条件表达式
多态是面向对象编程的一大特点,对于有些条件表达式,其主要作用是根据不同的类型进行不同的行为。这时还可以运用多态直接取代这些条件表达式,以面向对象思想重新封装其特征和行为,提高代码的可读性以及扩展性。
- (double)getSalary:(int)type
{
switch (type) {
case MANAGER:
// .....
// 一系列很复杂的运算,基本工资+奖金+抽成
return basic * bonus * commission;
case SALE:
// .....
return basic * commission;
break;
case ENGINEER:
// 通常比较简单
return basic;
break;
default:
break;
}
}
重构后
- (double)getSalary:(Employee *)employee
{
return [employee getSalary];
}
7.重构工具
7.1 代码检测工具-寻找重构的目标
7.1.1 代码质量管理工具
Sonar,全名“SonarQube”是一个开源的代码质量管理分析工具,可检测出项目代码的漏洞和潜在的逻辑问题。它的功能强大,对外提供了丰富的插件,兼容性强,可支持多种语言的检测, 如 Objective-C、Java、C、C++、Python 等几十种编程语言。它主要的核心价值体现在如下几个方面:
- 编码约束检测:通过一定的检测逻辑,报告代码当中异常和不安全的行为,也可以检查代码是否遵循编程标准,如命名规范,编写的规范等
- 检测潜在设计或者代码缺陷:通过插件 Findbugs、Checkstyle 等工具检测代码存在的缺陷
- 检测重复代码量:可以检测项目中的代码重复量,比如存在的大量复制粘贴的代码
- 检测代码中包、类之间的关系:分析类之间的关系是否合理,复杂度情况
- 检测代码中注释的程度:源码注释过多或者太少都不好,影响程序的可读性
7.1.2 重复代码检测工具
Simian,PMD
7.2 重构工具-提高重构的效率
7.2.1 Xcode
Xcode 原生提供了一些重构工具可供我们使用,主要有两个入口,一个是菜单栏--Editor--Refactor--下级菜单,或者鼠标右键选择 Refactor--下级菜单。
其提供的重构工具有那么几类:
- Rename:重命名,对类名或者函数名等进行重命名,只需要选择一个符号名,点击 Rename 便会自动关联处理相关的符号信息
- Extract Function:提炼函数,选取某一段代码,自动提炼生成一个新的 C 风格的函数
- Extract Method:提炼函数,选取某一段代码,自动提炼生成一个新的 Objective-C 风格的函数
- Extract Variable:提炼参数
- Extract All Occurrences:
7.2.2 Android Studio
作为安卓的原生开发工具,Android Studio 也提供了很丰富的重构工具,跟 Xcode 类似也主要有两个入口,一个是菜单栏--Refactor--下级菜单,或者鼠标右键选择 Refactor--下级菜单
7.3 自动化测试工具-保证重构之后的质量
运用自动化的测试工具,比如代码逻辑,UI 等自动化的测试工具,验收重构的成果,发现重构引起的 bug,从而保证重构之后的代码质量
总结
重构是在不影响原有行为的基础之上,对原有功能或者模块进行代码调整,增加其可读性和扩展性,最终改善当前的代码设计以及质量。而重构从来都不是一蹴而就,必须经过日积月累,千锤百炼,循序渐进完成的。而比较大型的系统或者模块的重构,通常不是一个人能够完成的,必须依赖团队的力量。在保持前期良好设计和规划之上,运用以上介绍的基于函数,类,表达式等的重构方法,逐步改善当前系统的代码质量,维持原有系统的良好设计。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论