Swift:通过Protocol封装统和入参

本文正在参加「金石计划 . 瓜分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.AControllerClickEvent.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.封装并不是一次就能写得非常完美的,是反复打磨和实践出来的,趁一时之功,不如多思考。

参考文档

Swift:网络请求库——Alamofire

Swift:where关键词使用

自己写的项目,欢迎大家star⭐️

RxStudy:RxSwift/RxCocoa框架,MVVM模式编写wanandroid客户端。

GetXStudy:使用GetX,重构了Flutter wanandroid客户端。

本文正在参加「金石计划 . 瓜分6万现金大奖」

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYKRZ2Fr' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片