Swift高阶-从Swift3.3到Swift4.1
最近把公司的项目从Swift3.3升级到了4.1,相对于Swift2.x到Swift3而言,这次升级所做工作量并不是特别大,但仍旧是碰到了些问题。当我们在XCode里把Swift语言版本选择到4.1然后编译时,错误数总共是999+,那一刻,我和同事的心情是崩溃的,接下来我们就开始了填坑工作。所以,在此记录下我们项目中为了适配Swift4.1改动过的地方。
一.去OC化:减少隐式@objc自动推断
问题1.1: dynamic var xxx must also be @objc,insert @objc
- 问题描述:项目中凡是有dynamic的地方,编译器都给出了可fixed的错误:dynamic var xxx must also be @objc,insert @objc
- 解决办法
- 1.点击编译器的红点,让其自动更正。编译器会自动在dynamic前加@objc
- 删掉dynamic
- 原因分析
之前为了在Swift项目中可以使用热修复JSPatch,所以在每个属性前面都加了dynamic,后来JSPatch被苹果封杀,项目中的dynamic一直保留着,直到Swift4.0开始出现了问题。那么为什么dynamic在Swift4.0之前没事,在4.0之后编译器就报错了呢?
知识点
去OC化是Swift4.0以来最大的变化,这也标志着Swift开始摆脱OC,朝着自己的方向去发展。 在Swift4.0之前,凡是继承了NSObject的类,编译器在编译时会自动在该类所有的属性,方法前加上@objc,供OC代码进行调用。但是在Swift4.0及之后,即使继承于NSObject的类编译器也不会自动的给该类所有的属性,方法前添加@objc。所以,对于我们想让OC代码访问的属性、方法,我们需要手动的在其前面加@objc。显然,dynamic是桥接OC的东西,编译器不自动添加@objc,只有我们手动的去添加了。如果项目中不再使用dynamic,可以直接将其删除。那么,问题又来了:苹果为什么这样做?为什么不让编译器自动的加@objc呢?
在Swfit4.0之前Swift中凡是继承了NSObject的类,会自动为该类的属性、方法前加上@objc,private修饰的除外,而这样也就可能导致大量不需要暴露给OC代码的属性、方法前加上@objc,大量的@objc也会导致最终二进制文件包增大。在这个OC向Swift过渡已差不多的时刻,哪里需要在哪里加,也显得更为合理。
问题1.2: Argument of '#selector' refers to instance method 'xxx()' that is not exposed to Objective-C Add '@objc' to expose this instance method to Objective-C
- 问题描述:使用#selector调用方法的地方,基本都报上述可fixed问题
- 解决办法: 点击红点,让其自动修复即可。会发现,编译器自动在selector方法前加了@objc
- 原因分析:
彻底明白问题1的原因的同学,应该已经知道了这个问题的原因。selector仍旧是属于OC的东西,我们要调用OC的方法,必须要在方法前加@objc。
问题1.3:Cannot override a non-dynamic class declaration from an extension
- 问题描述:在Swif4中,如果子类要在extension中重写父类的方法,那么在子类这个方法那里就会报错。在Swift4之前只是警告。
- 解决办法: 将要被重写的父类的方法前加@objc dynamic
- 原因分析: to be honest, 对于这个问题的原因,我并没有彻底理解透。先给出我google出的解释(该解释针对Swift4之前),再给出我自己的理解,大家也可以针对此问题留言,一起讨论。
- 谷歌此问题网址:https://stackoverflow.com/questions/50151102/objc-keyword-extension-subclass-behaviour/50153780#50153780
- 我自己的理解:Swift extension 中方法的重写依赖于消息的派发(why?上述解释给出的原因是为了互操作性),而仅仅将方法标记为@objc,在swift中也并不一定按照动态运行来处理该方法,在swift中真正将方法按照动态运行来处理的前提是在该方法前加dynamic。
问题1.4: 方法的调用使用NSSelecrotFromString("")导致项目崩溃
- 问题描述:在我们项目可以成功在swift4.1下跑起来之后,我和同事以为适配工作告一段落,哪知,我们仅仅完成了第一步。在登录界面 ,点击UITextField右边的x号,程序崩了。后来尝试点击登录按钮,也崩了。顿时,刀郎的一句歌词在我心里飘过“它来的那么快来的那么直接。。。”,查原因吧!
- 解决办法: 又在一些方法前面加上了@objc
- 原因分析: 项目中某些方法的调用是通过NSSeletorFromString(""),虽然问题的本质跟问题1是一样的,但是这种方式编译器是不会给提示错误的,只在程序运行起来之后,调用不到相关方法,所以导致崩溃。
问题1.5: 在Swift4下,MJExtension这个字典转模型框架不好使了,转出的模型的属性值都是空的。
- 问题描述:程序跑起来,也解决了问题4后,顿时觉得有点扬眉吐气了,当我们点击登录按钮时,发现“哎呀,出错了,等会再试吧”。心里又开始凉凉了,接口怎么搞的,账号问题?换账号,不好使。好吧。心里好多个"草泥马"飘过。连上Xcode,查原因吧。什么?控制台打印“用户名,密码不能为空”,明明有值啊。“威总威总(我们测试),来个线上包”,用线上包一登录,没事。问题很明朗了,Swift4的事,查原因吧。
- 解决办法:
- 如果仍旧使用MJExtension的话,需要在对应的模型前加@objcMemebers(推荐)
- 如果不打算使用MJExtension的话,可以使用苹果的Codable协议进行转模型(不建议,坑比较多)或者使用一些纯Swfit的框架,比如HandyJson(阿里的),SwfityJson。(补充:Codable协议和HandyJson,SwfityJson我们都进行了调研,都不太推荐。Codable协议存在一些问题,比如模型和接口返回的字段类型不匹配会导致转换无值,模型中使用与接口返回字段不一致的命名比较麻烦等;HandyJSON确实牛逼,直接通过操作内存地址来获取属性(自我理解),但是看网上说容易导致内存泄露。而且我也联系了MJ本人,他说在等Swfit5,所以我们也就采用了妥协的办法,使用了方案1)。。。。。哦,打字好累
- 原因分析: 此问题发生的根本原因还是在Swift4.0之后,即使继承于NSObject的类,编译器也不会自动的在该类的属性、方法前加@objc。而MJExtension的底层是通过运行时方法来获得模型的属性和属性类型,如果该模型不暴露给OC,MJExtension是无法成功获取模型的属性的,导致转出的模型里的属性值都没有值。在我们项目中,传递给接口的参数用户名和密码是在一个loginModel里取的,而loginModel是通过一个字典利用MJExtension转来的,由于loginModel并没有添加@objcMembers或者@objc,所以导致转出的模型里的值为空,也就意味着传递给接口的参数用户名和密码确实空,问题彻底排查。MJExtension在swift4.0下字典转模型失败的问题,还会引发很多问题,因为我们iOS开发基本采用MVC原则,把AFN解析出的字典或者数组进一步转成模型,一旦转出的模型里没有值,会导致界面上无数据等一些列问题。
二.新增Substring类型
-
问题描述:'substring(to:)' is deprecated: Please use String slicing subscript with a 'partial range upto' operator.(这是在Swfit4.0下新增的一个警告,并非报错。什么?这么常用的substring方法都被废弃了,苹果爸爸,你要搞什么呀?)
-
解决办法: 我在项目中使用现在苹果提倡的方式对substring 进行了进一步封装,消除了警告。并且提供了几个更加方便的方法。代码如下:
// compatibility for Swift4.0 // tk_substring 这三个方法是为了消除3.2->4的警告。开发中不建议使用这三个,建议使用下面的sbustring方法 func tk_substring(from index:String.Index) -> String{ return String(self[index...]) } func tk_substring(to index:String.Index) -> String { return String(self[..<index]) } func tk_substring(with range: Range<String.Index>) -> String { return String(self[range.lowerBound..<range.upperBound]) } // 开发建议使用已下方法,无需获取String.Index.简单快捷且容错性强 func substring(from: Int?, to: Int?) -> String { if let start = from { guard start < self.count else { return "" } } if let end = to { guard end >= 0 else { return "" } } if let start = from, let end = to { guard end - start >= 0 else { return "" } } let startIndex: String.Index if let start = from, start >= 0 { startIndex = self.index(self.startIndex, offsetBy: start) } else { startIndex = self.startIndex } let endIndex: String.Index if let end = to, end >= 0, end < self.count { endIndex = self.index(self.startIndex, offsetBy: end + 1) } else { endIndex = self.endIndex } return String(self[startIndex ..< endIndex]) } func substring(from: Int) -> String { return self.substring(from: from, to: nil) } func substring(to: Int) -> String { return self.substring(from: nil, to: to) } func substring(from: Int?, length: Int) -> String { guard length > 0 else { return "" } let end: Int if let start = from, start > 0 { end = start + length - 1 } else { end = length - 1 } return self.substring(from: from, to: end) } func substring(length: Int, to: Int?) -> String { guard let end = to, end > 0, length > 0 else { return "" } let start: Int if let end = to, end - length > 0 { start = end - length + 1 } else { start = 0 } return self.substring(from: start, to: to) }
-
原因分析:
正如警告信息那样,苹果建议使用字符串切片下标的方式。
具体使用形式:https://majing.io/posts/10000001371152
关于Substring苹果官方解释:https://developer.apple.com/documentation/swift/substring
三.实现协议中的属性类型必须完全匹配
-
问题描述: 在点击界面时,发现某些接口调用失败,但是在线上的包却无此问题,说明不是接口的问题。进一步排查发下,是某些传递给接口的参数少了。Swift4还能导致这种问题?肯定是哪里出了问题,查原因吧。
-
解决办法: 实现协议中的属性类型改成与协议中的属性类型完全一致(这句话确实有些别扭,看原因分析中的Demo演示)
-
原因分析:先看代码
@objc protocol myProtocol { @objc optional var params:[String : Any]? {get set} } class Model: NSObject,myProtocol { var params: [String : String]? var name: String? }
是否有看出这段代码的问题。Model遵守了myProtocol协议,并且实现了协议中的属性params。但是Model里params的类型是[String : String]?,而协议里myProtocol的类型却是[String : Any]? ,就这么一个类型之差,直接导致我们项目中请求接口不能成功。那么为什么呢?在swift4.0之前,在model里的params处会给出警告,警告信息是说你与协议中属性类型不一致,你到底是要新定义个属性还是实现协议里的属性啊,在swift4之前,编译器会默认认为是实现协议里的属性,虽然类型不一样,仅是给你个警告。但是在swift4之后,一向擅长变脸的苹果爸爸变了,在swift4中如果你这样写,编译器会默认为你定义了个新的属性,与协议中的属性无关。注意:协议中的属性是可选的,遵守了协议可以选择不实现这个属性。那么,这种不一致会导致什么问题呢?看Demo
override func viewDidLoad() {
super.viewDidLoad()
let m = Model()
m.params = ["t1":"123"]
test(m)
}
func test(_ model:myProtocol) {
if let _ = model.params {
print("++")
return
}
print("--")
}
下面这段代码为对模型Model的使用。运行结果,在swift4之前,打印++,在swift之后,打印--。即在swift4之前,model.params不为nil,在swift4之后,model.params为nil。至于原因就是,在swift4之前,model.params取的是model里的值,而在swift4之后,model.params取的是myProtocol里的值,因为编译器不认为model实现了协议里的属性。(可能有些绕,我是彻底理解了,大家可以多读几遍)
四.在Swift4下,带一个Void参数的闭包类型,调用时可能报错Missing argument for parameter #1 in call
-
问题描述: 这个问题是比较明显的,在swfit4下,编译直接给出错误
-
解决办法: 调用时传入元组() 。 (又比较抽象,看原因分析中的Demo)
-
原因分析: 看Demo
var success1:((Void)->Void)? var success2:(()->Void)? override func viewDidLoad() { super.viewDidLoad() success1!() }
上述代码在swift4下编译,在success1!()处会报错,提示让传入一个参数。解决办法就是这样调用 success1!(()),或者按照success2的方式来定义success1。而success1!()这样调用在swift4之前是没事的。原因就是在swift4之后,苹果比之前更加严格的对待元组。(Void)->Void 代表一种闭包类型,这个闭包有一个Void类型的参数,并且返回值是Void,代表没有返回值。这也就是报Missing argument for parameter #1 in call错的原因,我需要一个参数,但是你没给传参数啊,所以编译器就报错了。好吧,我给你传个Void类型的参数,所以传(),因为空的元组()是Void的唯一实例,所以解决办法就是success1!(())。注意:(Void)->Void和()->Void是两种不同的类型,如果想定义一个不带参数的闭包类型,更推荐后一种方式。参考地址:https://stackoverflow.com/questions/45183961/compile-error-in-swift-4-on-parameter-passing
五.Swift4其他部分特性
5.1 NSAttributedStringKey
- NSForegroundColorAttributeName使用NSAttributedStringKey.foregroundColor替代
- NSParagraphStyleAttributeName使用NSAttributedStringKey.paragraphStyle替代
- NSFontAttributeName使用NSAttributedStringKey.font替代
- ...
5.2 extension中可以访问类里的private属性
5.3 initialize方法在Swift4.0中报错
override class func initialize() {
// some code
}
以上代码在Swift3中报警告,在swift4中直接报错:Method 'initialize()' defines Objective-C class method 'initialize', which is not permitted by Swift
总结
上述所有问题除了5.2,5.3在我们项目中没有碰到外,其余的问题均在我们项目中遇到。我们在解决问题的同时,也在思考苹果为什么要这么变。有时解决问题就是点击下鼠标的事,但是思考原因就没那么easy了。而且,自我觉得只有针对问题去解决问题,才是学习的最好的方式。如果仅仅是干巴巴的学习Swift4新特性,效果不如碰到问题解决问题更好。希望这篇文章能够帮助到做Swift4适配的小伙伴。