本文正在参加「金石计划 . 瓜分6万现金大奖」
前言
本篇技术含量不高,更多的侧重从业务层面思考Protocol的封装。
接上回Swift:巧用module.modulemap,告别Bridging-Header.h,当我把友盟SDK集成好了之后,就准备开始开始着手点击事件的统计了。
直接调用友盟SDK的API就好了:
open class MobClick : NSObject {
open class func event(_ eventId: String!, attributes: [AnyHashable : Any]! = [:])
}
这个简单,作为一个API调用工程师,走起:
MobClick.event("redButtonClick", attributes: ["name": "seasonZhu", "webSite": "www.juejin.com"])
嗯,挺简单的,挺好的,可以散了。
撇开业务,只是调用这个接口,我想初学者都会,但是结合业务思考,让埋点工作更加简单才是我们需要继续思考的。
结合业务思考
我们先看看这个MobClick
这个event方法,需要传入的是String类型与字典,显然就这么持续硬编码,并不合符我们的风格,一旦写错,排查起来非常麻烦,而且后面App的业务扩展,持续统计点击事件的成本也会非常高。
我们一个一个参数的说,我先从这个eventId
开始说。
eventId入参的封装
避免硬编码最简单的方法就是定义一个常量,后续要使用的时候,调用这个常量即可。
let oneButtonClick = "oneButtonClick"
let twoButtonClick = "twoButtonClick"
.
.
.
就像上面这样的代码,比如我50个点击事件,就这么写50个就可以,这样做当然没有问题。
但是其实有的时候,我们需要区分业务的点击事件的,这么写一大堆并不好,我们需要一个前缀区分不同的页面。
你可能回想这有何难:
let AControllerOneButtonClick = "AControllerOneButtonClick"
let BControllerTwoButtonClick = "BControllerTwoButtonClick"
.
.
.
嗯这样写当然没有问题,但是看起来非常费力,也不易于维护。
很多Swift的代码已经给出了示范,我们做简单的处理就可以了:
enum AController {
static let oneButtonClick = "oneButtonClick"
.
.
.
}
enum BController {
static let twoButtonClick = "twoButtonClick"
.
.
.
}
这样的话,我调用的时候就用AController.oneButtonClick
就可以了。
这里有一个问题:
为什么我区分业务的时候,定义用的是enum AController
,而不是class AController
或者struct AController
?
我们继续往下看。
既然我都是用枚举了,我还用static let
这样吗?我的enum去“继承”String不就好了吗?
enum AController: String {
case oneButtonClick
case twoButtonClick
.
.
.
}
或者
enum AController: String {
case oneButtonClick, twoButtonClick...
}
调用的时候我就用AController.oneButtonClick.rawValue
就行了。
如果遇到枚举值和字符串不同的时候,我们特别处理一下就可以了:
enum AController: String {
case oneButtonClick
case twoButtonClick
case threeButtonClick = "3_button_click"
.
.
.
}
考虑到对整体的点击事件整合,我们可以这样进行分类并调用ClickEvent.AController.oneButtonClick.rawValue
到这里,我想各位应该懂了为啥要使用enum去定义点击事件了,通过申明enum的rawValue为String类型,可以让我少写一些不必要的代码。
enum ClickEvent {
enum AController: String {
case oneButtonClick
case twoButtonClick
.
.
.
}
enum BController: String {
case oneButtonClick
case twoButtonClick
.
.
.
}
.
.
.
}
这样的写法,让定义点击事件变得容易,但是调用的时候并不是特别友好,我分了多个业务层,导致需要.
很多次才能获取一个想要的字符串。所以说在这块大家可以自行斟酌。
并且这里不是封装的最后阶段,我们继续进行。
attributes入参封装
大家都知道,传递字典是一个比较辛苦的事情,通过Alamofire传参的经验,遵守Codable协议的Model转Dictionary就行了。
所幸是,我所编写的App友盟点击事件的属性上传,Dictionary是单层的,而且Key和Value都是String。事情变得简单起来。
我们先定义一个Model类型,小试牛刀:
struct AModel: Codabel {
let name: String
let webSite: String
}
extension AModel {
var toMap: [String: String] {
let encoder = JSONEncoder()
guard let data = try? encoder.encode(self) else {
return [:]
}
guard let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
return [:]
}
return dict
}
}
调用的时候AModel(name: "season", webSite: "www.juejin.com").toMap
就能直接转成Dictionary啦。
这里AModel用struct
定义,是因为struct
会根据定义的属性自动生成构造器,可以减少写init的代码。
但是如果我有另外一个BModel对应另外一个事件,就要把toMap
这个Copy一份,这样也太傻了吧。
当发现需要不断去机械Copy代码的时候,就需要考虑可不可以归纳总结,封装一个方法了。
Swift是一门面向协议的编程语言,所以在考虑这种重复性代码的问题的时候,优先考虑用协议能不能解决呢?
说干就干,于是就编写了这样一个ToMapProtocol
:
protocol ToMapProtocol {
var toMap: [String: String] { get }
}
extension ToMapProtocol where Self: Codable {
var toMap: [String: String] {
let encoder = JSONEncoder()
guard let data = try? encoder.encode(self) else {
return [:]
}
guard let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return [:]
}
return dict
}
}
使用的只要遵守这个协议,通过协议的默认方法就可以转为Dictionary了:
struct AModel: Codable {
.
.
.
}
extension AModel: ToMapProtocol {}
struct BModel: Codable {
.
.
.
}
extension BModel: ToMapProtocol {}
.
.
.
这个时候调用友盟的统计点击的API大概就变成了这个样子:
MobClick.event(ClickEvent.AController.oneButtonClick.rawValue, attributes: AModel(name: "season", webSite: "www.juejin.com").toMap)
这样看起来,甚至还没有最一开始的简洁,但是在调用这个方面,这种方式无疑更加高效与安全。
我们封装到了最后了吗?没有!我们接着继续。
做最后一次薄薄的封装,让调用变得更加简单
我们发现,在入参eventId的时候,我们使用枚举必须要调用.rawValue
,而入参attributes的时候,我们必须要调用.toMap
。
每次事件都这么写,就有点累。俗话说,不想偷懒的程序员不是好程序员,我们来进行一层封装,少写几行代码吧:
func uploadEvent(event: String, model: ToMapProtocol? = nil) {
MobClick.event(event, attributes: model?.toMap ?? [:])
}
我在MobClick.event
上层封了一个uploadEvent
方法,属性入参不再是Dictionary,而是一个遵守ToMapProtocol
的对象,那么我只要在这个方法的内部调用一次model?.toMap
就可以了。
就像这样:
uploadEvent(ClickEvent.AController.oneButtonClick.rawValue, model: AModel(name: "season", webSite: "www.juejin.com"))
这个时候,有同事和我反馈了这样一个使用问题:
model需要传入遵守ToMapProtocol的对象固然是好事,但是有的时候,某个点击事件需要上传的属性就只有一对键值对,比如[“age”: “12”],为了一对键值对,我还要必须创建一个模型,太麻烦了,能想想办法吗?
???
既然只要传入一个遵守ToMapProtocol
协议的对象就可以,那么我只需要让[String: String]
也遵守这个协议,并返回一个Dictionary就不就完事了吗?
这个实现反而不复杂:
extension Dictionary: ToMapProtocol where Key == String, Value == String {
var toMap: [String : String] { self }
}
对于一个Key和Value都是String的字典,返回它自己本身就好了。
这个分类重写ToMapProtocol
的方法的关键是在where
之后的约束。
到此,[String : String]
与模型都被统和在ToMapProtocol
协议下面了。
现在,让我们借着通过Protocol统和的思路,看看怎么来进一步封装eventId。
eventId入参的再封装,方案一(向上统和)
enum ClickEvent {
enum AController: String {
case oneButtonClick
case twoButtonClick
}
enum BController: String {
case oneButtonClick
case twoButtonClick
}
}
ClickEvent.AController
与ClickEvent.BController
被拆分的太细了,如果都统和到ClickEvent
下面的话就简单了:
enum ClickEvent: String {
/// 减少分层,都到ClickEvent这一层
/// A页面的业务
case AoneButtonClick
case AtwoButtonClick
/// B页面的业务
case BoneButtonClick
case BtwoButtonClick
}
于是乎最终的上层封装就是变成了这样:
func uploadEvent(clickEvent: ClickEvent, model: ToMapProtocol? = nil) {
MobClick.event(clickEvent.rawValue, attributes: model?.toMap ?? [:])
}
调用:
uploadEvent(ClickEvent.AoneButtonClick, model: AModel(name: "season", webSite: "www.juejin.com"))
eventId入参的再封装,方案二(通过Protocol统和)
根据ToMapProtocol
统和[String: String]
和Model
的经验,我们大致可以这么构思代码:
protocol ToStringProtocol {
/// 这里暂时不写具体实现
func abstractFunction() -> String
}
enum ClickEvent {
/// 让AController遵守ToStringProtocol
enum AController: String, ToStringProtocol {
case oneButtonClick
case twoButtonClick
}
/// 让BController遵守ToStringProtocol
enum BController: String, ToStringProtocol {
case oneButtonClick
case twoButtonClick
}
}
于是乎最终的上层封装的伪代码变成了这样:
func uploadEvent(aEvent: ToStringProtocol, model: ToMapProtocol? = nil) {
MobClick.event(aEvent.abstractFunction(), attributes: model?.toMap ?? [:])
}
???
ToStringProtocol协议里需要某个一个方法,可以将一个enum的状态值转为String就好啦。
那么ToStringProtocol协议里面的具体实现我就这么写:
protocol ToStringProtocol {
/// func abstractFunction() -> String变成了toString这个只读计算属性
var toString: String { get }
}
enum ClickEvent {
/// 让AController遵守ToStringProtocol
enum AController: String, ToStringProtocol {
case oneButtonClick
case twoButtonClick
var toString: String { /// 具体实现 }
}
/// 让BController遵守ToStringProtocol
enum BController: String, ToStringProtocol {
case oneButtonClick
case twoButtonClick
var toString: String { /// 具体实现 }
}
}
已经来到了最最最关键的一步,toString
的每个enum的实现怎么写?
还记得它吗——ClickEvent.AController.oneButtonClick.rawValue
,这不就简单了:
enum AController: String, ToStringProtocol {
case oneButtonClick
case twoButtonClick
var toString: String { rawValue }
}
最后的封装代码就成了这个样子:
func uploadEvent(aEvent: ToStringProtocol, model: ToMapProtocol? = nil) {
MobClick.event(aEvent.toString, attributes: model?.toMap ?? [:])
}
调用:
uploadEvent(ClickEvent.AController.oneButtonClick, model: AModel(name: "season", webSite: "www.juejin.com"))
其实不管是方案一还是方案二,目的都是一致的:
uploadEvent的这一层封装,让外部调用的时候,传入方便的参数就好,内部进行入参的转换与实现,避免外部写过多的重复代码。
这也是代码封装艺术的核心!!!
当然方案二的有一个问题就是,我没法通过extension ToStringProtocol where Self == enum {}
这样的where
方式去约束enum
类型,不得不在每个enum
去实现协议方法,比较辛苦。
总结
就这样,一个API方法,在我的封装下面,写下了洋洋洒洒2000+字的文章。
其实很多人会有疑问,怎么样才能有这样的封装思维?
我根据自己的经验总结了几点:
1.当自己编写代码的时候,如果遇到经常需要CV的代码,是否停下来进行思考与总结?
2.阅读优秀的开源源码,可以提升自己写代码的质量,同时理解大佬的思维模式。
3.封装并不是一次就能写得非常完美的,是反复打磨和实践出来的,趁一时之功,不如多思考。
参考文档
自己写的项目,欢迎大家star⭐️
RxStudy:RxSwift/RxCocoa框架,MVVM模式编写wanandroid客户端。
GetXStudy:使用GetX,重构了Flutter wanandroid客户端。
本文正在参加「金石计划 . 瓜分6万现金大奖」