您的位置:  首页 > 技术杂谈 > 正文

Swift在58安居客房产实践

2022-01-05 11:00 https://my.oschina.net/u/5359019/blog/5391279 58技术 次阅读 条评论


01

 背景

2014年Apple在WWDC发布了新的语言Swift。随后一直在不断的更新迭代和优化,国内外各大公司一直在踊跃欲试,但一直都没有商用或大规模使用。直到2019年Apple发布了5.0版本,并宣布ABI稳定,2020年更是陆续SwiftUI、CareKit等Swift专属SDK,并且Apple一直在大力推广鼓励大家使用Swift。在这样的背景下,越来越多的开发者、开源项目都加快了Swift生态搭建的脚步。
另外Swift作为一门新语言,相比于Objective-C有巨大的后发优势:安全、高效、高性能等。这些特性有利于开发者提升开发效率和APP质量。在《Swift 2021 生态调研报告》中App Store免费前100中国外APP使用Swift占比91%。国内占比近50%


02

 现 状

在这样的趋势之下,58集团与2020年底启动了Swift共建项目,内部称为混天项目。目标是搭建Swift的基础组件、辅助工具及基础设施。制定集团Swift开发规范和代码检测工具以及Swift在各个业务线中的落地。
房产业务作为集团核心产业,深度参与了混天项目的研发及Swift的落地。下面的内容主要是Swift在房产业务线从0到1落地的过程中遇到的一些问题和探索。
目前公司项目都是OC语言开发的,在这样的一个快速迭代历史悠久的项目中,短期内是不可能将所有项目用Swift重写,所以我们前期采用的都是Swift和OC的混合开发。

03

工程架构

在接入Swift之前,我们先来了解下集团APP的工程架构以及房产的业务结构。58集团iOS团队维护了58同城、安居客、赶集网、58同镇、招财猫、车商通等十几款APP,为了降低维护的成本和开发效率,团队将基础功能进行组件化,提供多个自研基础组件以及SDK,打造APP工厂。但不同APP和业务线对底层的依赖情况各不相同。于是在此基础之上增加中间层,来解决业务垂直业务跨APP时的底层差异和APP内业务之间的共享。

房产业务结构

集团在58同城、安居客、赶集网、58同镇都有房产业务,在早期各个团队之间相对独立运行维护。随着业务的垂直化和产业化。房产发起木星计划,目标是打造成一套代码,多APP运行。来降低维护的成本和开发效率。让不同团队更加关注自己的业务,发挥自己的优势。虽然开发维护效率提高了,但同时也增加了工程的复杂度,下面就是房产目前核心业务的业务结构:


04

 混编方案

目前Swift和Objective-C业内混编的方式主要有两种:

定向桥接

如果是在一个 App Target 内部混编的话,通过在宿主工程中添加桥接文件的方式进行混编,每个工程第一次创建Swift文件的时候,系统都会创建一个桥接(ProductModuleName-Bridging-Header.h)文件 Swift类需要访问OC的类时,只需要在这个桥接文件中导入需要暴露的类,即可在Swift中访问相应 OC的类和方法
OC访问Swift时在OC类中导入ProductName-Swift.h(隐藏文件),即可访问Swift中暴露给 Objective-C的类和方法
这种方式使用起来非常的简单便捷,但有两个缺陷:
  • 随着Swift使用场景越来越多,导入的头文件也会变得臃肿。
  • 如果工程是通过Cocoapods管理,Pod和Pod之间是不能相互调用的


Module

我们的项目工程一个壳工程和多个业务子工程组成在一起,每一个业务线子工程都有一个或多个模块连接在一起,通过Cocoapods进行管理,模块之间是有依赖关系的。我们不但需要Swift和OC之间的调用,还需要跨Pod调用,所以桥接的方式肯定是不能满足。这时候还有一种方式就是Module。将 Build Settings 中的 Defines Module 选项设置为 YES,然后新建一个 umbrella header,再将需要暴露给Swift 调用的 OC 的头文件在这个 umbrella header 中导入
如果要想在 ObjC 调用 Swift,同样也要将 Build Settings 中的 Defines Module 选项设置为 YES,然后在要引用 Swift 代码的 ObjC 文件中导入编译器生成的头文件 #import <ProductName/ProductModuleName-Swift.h>


05

 Module化实践

通过上面了解到目前我们的工程架构已经是通过Cocoapods进行组件/模块化管理.每个模块就是一个Module,而定向桥接的方式是不能跨Module通信,所以我们适合Module的方式进行混编,那如果进行混编呢?

环境搭建

  • 开启Module选项
    为了Pod库之间能够引用暴露的Swift接口, 第一步需要让被访问的库开启module, 需要在Swift所在的Pod文件夹下的podspec中的xcconfig下,添加’DEFINES_MODULE’ => ‘YES’

  • 添加依赖
    调用方需要在自己的podspec里添加moudule依赖,s.dependency ‘被调用方的pod库’

  • 使用方式
    配置好上述的依赖配置后,就可以调用开启Module的pod库了。不管是Swift文件中还是OC文件中都可以通过@import方式引用即可,同时Components组件也可以跨Pod调用WBLOCO中的OC的方法以及对外暴露的Swift接口,当然,暴露Swift接口想要暴露在OC环境下,需要用@objc声明,同时接口要声明成public

#import "WBListVC.h"
@import WBLOCO;@interface WBListVC ()<LCListViewDelegate>@property (nonatomic, strong) LCListView *listV;@end


工程变化

业务库开启moudule后工程目录的变化及注意事项:
WBLOCO开启module后,额外生成WBLOCO.modulemap和WBLOCO-umbrella.h两个文件
默认情况下WBLOCO中的umbrella.h里会导出所有OC的头文件,为解决此问题,可通过在podspec的private_header_files中添加屏蔽头文件的导出 Components Pod添加WBLOCO依赖后工程文件的变化

Swift类型对外暴露注意事项

Swift接口想要暴露在OC环境中,无论是在当前Pod还是跨Pod暴露, 首先Swift的class想要定义成public, 同时对外暴露的接口需要用@objc声明,接口也要定义成public

import Foundation@objc public enum LCListItemSelectionStyle: Int {   case single   case multiple}public class LCListItemModel:NSObject {   @objc public var list_selected:Bool = false   @objc public var list_selection_style:LCListItemSelectionStyle = .single   @objc public var text:String = ""   @objc public var data:[LCListItemModel] = []   @objc public convenience init(modelWithDict: [String:Any]) {        self.init()        LCListItemModel.init(dict: modelWithDict)    }

踩坑案例

环境配置好,在接入Swift混编开发是时候遇到各种各样的问题坑,以下是房产在接入Swift时遇到的一些相对通用问题。

重复定义问题

创建Swift文件之后,工程直接编译失败,错误如下: 根据提示排查发现是因为工程代码中协议名称或者Block名称有重复,在纯OC中只要两个文件没有相互引用,编译器检测相对没有那么严格,所以编译不会报错,但接入Swift之后,编译器检测更加严格,编译就失败了,那解决的方式独立抽离一份,删除重复定义的或者修改相应的协议名和Block名称之后,再次编译成功。

LLDB调试问题

我们在混编开发过程中,但我们在在控制台po调试的时候,发现变量名看不到,提示类似错误信息如下:
(lldb) po selfwarning: Swift error in fallback scratch context: <module-includes>:1:9: note: in file included from <module-includes>:1:#import "WBLOCO-umbrella.h"       ^
/Users/xxxx/.../WBLOCO-umbrella.h:70:9: note: in file included from /Users/xxxx/.../Components-umbrella.h:70:#import "LGBaseNode.h" ^
/Users/xxxx/.../LGBaseNode.h:9:9: note: in file included from //Users/xxxx/.../LGBaseNode.h:9:#import "LGDefines.h"       ^error: could not build Objective-C module 'WBLOCO'<module-includes>:1:9: note: in file included from <module-includes>:1:#import "WBLOCO-umbrella.h"       
这种错误的原因都是跨Pod引用OC头文件引入不规范导致,我们需要把所有.h文件中跨Pod导入的文件都需要改成全路径的方式引入例如:
Components的Pod中引入WBLOCO的Pod类
修改前:
#import "WBLOCO.h"
修改后:
#import <WBLOCO/WBLOCO.h>

这样就能调试,但代码中有很多这种不规范的写法,我们可以通过脚本替换,把项目中所以不规范的全部统一修改。


Swift与OC混编时反射问题及原理探究

Swift与OC混编时反射问题背景

在日常开发中,我们经常会用到反射这个机制。iOS开发中系统也给我们提供了相应的API,我们可以通过这些API执行将字符串转为Class、SEL等操作。由于OC语言的动态性,这些操作都是发生在运行时的。
// SEL和字符串转换FOUNDATION_EXPORT NSString *NSStringFromSelector(SEL aSelector);FOUNDATION_EXPORT SEL NSSelectorFromString(NSString *aSelectorName);// Class和字符串转换FOUNDATION_EXPORT NSString *NSStringFromClass(Class aClass);FOUNDATION_EXPORT Class __nullable NSClassFromString(NSString *aClassName);// Protocol和字符串转换FOUNDATION_EXPORT NSString *NSStringFromProtocol(Protocol *proto) NS_AVAILABLE(10_5, 2_0);FOUNDATION_EXPORT Protocol * __nullable NSProtocolFromString(NSString *namestr) NS_AVAILABLE(10_5, 2_0);

通过这些方法,我们可以在运行时通过字符串创建相应的实例,并动态选择调用相应的方法。

Class cls = NSClassFromString(@"ViewController");ViewController *vc = [[cls alloc] init];SEL selector = NSSelectorFromString(@"initWithData");[vc performSelector:selector];

房产主要的核心页面都是流式布局,业务的特点是子业务相似度高,更新频率快,要有一定的动态性和灵活性。

基于以上的原因我们设计的方案客户端内置Cell,每个Cell绑定一个Key,通过Server下发数据Key,反射出对应的Cell,从而展示不同的内容和控制显示的内容的顺序。
但最近我们接入Swift与OC进行混编之后,就遇到了NSClassFromString反射的问题。

Swift与OC混编时反射初探

我们先来创建Swift类TestClass,当前的Module名称为HouseTest,下面看NSClassFromString这两种获取方式打印输出的结果。
import Cocoa@objc public class TestClass: NSObject {}
OC代码中通过反射获取这个TestClass这个类对象第一种方式输出的cls是为空的,第二种方式是在类名前期需求拼接上Module名称我们看到拿到cls实例 但这种方案我们实际在项目中使用时有2个明显的缺陷
1.Swift类我们必须知道Module名,而OC是没有Module名,我们需要判断是Swift还是OC的类来做特殊处理。每次新增一个Swift类,就得判断,代码很不优雅。
2.在多pod下,如果类被移动到另外一个pod,那么这个Class就找不到了,编译也不会报错。
我们工程中核心页面都是流式布局,根据Server下发的数据通过反射拿到对应的Cell Class实现动态化布局,混编情况下上面的方案差异处理较大,不能满足我们的需求。
我们最终使用了另外一种方案:在Swift的类中@ojbc后面加上自定义的类名,代码如下:
@objc(TestClass)public class TestClass: NSObject {}
加上@objc(TestClass)之后,OC中反射时获取Swift的Class我们就不需要关心Module名,底层处理方式和OC一样,这样就抹平了底层的差异性。
Class cls =  NSClassFromString(@"TestClass");
我们验证一下: 这们看到这里我们TestClass,通过这种方式就解决了上面那种方式的两个缺陷。
那么通过上面的问题思考:为什么Swift类在反射的时候需要加上Module名,@objc(TestClass)底层干了什么?

Swift与OC混编时反射打破砂锅问到底

因OC 源码不是开源的,没办法直接看源码,先下个符号断点,看下汇编代码(x86)
Foundation`NSClassFromString:->  0x181a3c43c <+0>:   pacibsp     0x181a3c440 <+4>:   stp    x28, x27, [sp, #-0x40]!    0x181a3c444 <+8>:   stp    x22, x21, [sp, #0x10]    0x181a3c448 <+12>:  stp    x20, x19, [sp, #0x20]    ......    ......    0x181a3c4f8 <+188>: bl     0x181d41f00               ; symbol stub for: objc_msgSend    0x181a3c4fc <+192>: mov    x21, x0    0x181a3c500 <+196>: mov    x0, x21    0x181a3c504 <+200>: bl     0x181d41ef0               ; symbol stub for: objc_lookUpClass

通过上面的汇编代码,查看关键信息,最后我们看到调用了objc_lookUpClass 通过上面的汇编模拟一下伪代码。

Class _Nullable MY_NSClassFromString(NSString *clsName) {    if (!clsName) { return Nil; }    NSUInteger classNameLength = [clsName length];    char buffer[1000];    if ([clsName getCString:buffer maxLength:1000 encoding:NSUTF8StringEncoding]        && classNameLength == strlen(buffer)) {        return objc_lookUpClass(buffer);    } else if (classNameLength == 0) {        return objc_lookUpClass([clsName UTF8String]);    }
for (int i = 0; i < classNameLength; i++) { if ([clsName characterAtIndex:i] == 0) { return Nil; } } return objc_lookUpClass([clsName UTF8String]);}

验证结果:

2021-06-23 21:13:58.750828+0800 HouseTest[25683:4936266] my_cls = TestClass

通过伪代码我们发现这里并没有看出异常,加不加@objc(TestClass)都一样,那肯定是后面流程有问题。那只能调试源码(当前为objc-781)
我们跟踪源码的调用流程:objc_lookUpClass -> look_up_class -> getClassExceptSomeSwift

最后我们看到是从 NXMapGet(gdb_objc_realized_classes, name, cls)获取的。gdb_objc_realized_classes保存的是从Mach-O中加载的全部的类,难道是Swift 写的类没有被加载进来?那我们去看看加入的时候是怎么加进去的,我们找到程序启动类插入到gdb_objc_realized_classes 方法

我们看到这里加个Log打印一下

到TestClass这个类并不是我们看到的样子,变成了 _TtC6KCObjc9TestClass所以我们在调用NSStringFromClass(“TestClass”)时,传入的key是TestClass,maptable中存入的key是_TtC6KCObjc9TestClass
所以返回空了。那为什NSClassFromString(“ModuleName.ClassName”)就可以了呢?我们跟踪一下流程

走到这里result 还是为空,往下执行 copySwiftV1MangledName


到这里处理完之后结果
那为什么加上@objc(ClassName)就可以了,验证一

这里就变成了真实的类名所以直接就能拿到对应Class的地址

我们在看一下加上 @objc(TestClass)和@objc编译之后的Swift桥接件和没加区别 @objc(TestClass)

没加@objc(TestClass)

这里我们看到一个 className是TestClass,一个是_TtC6KCObjc9TestClass

@objc后续

从这里我们也能够看到Swift中不同Pod中可以有相同的Class。通过ModuleName进行区分 第一个Pod

import Foundation
class TestClass: NSObject { var name = "我是One Pod"
}
第二个Pod
import Foundation
class TestClass: NSObject { var name = "我是Tow Pod"
}

两个Module名不同,类名相同,最后的拼接完之后是不相同的,所以能够正常编译。如果我们给这两个类都加上 @objc(TestClass)呢? 我可以看到编译直接失败。

这里也说明Swift中不能的Module可以同名原因

Swift与OC注入绑定问题与优化

前面的文章也说到房产主要的核心页面都是流式布局,我们设计的方案客户端内置Cell,每个Cell绑定一个Key,通过Server下发数据Key,反射出对应的Cell。那么Cell和Key是如何进行绑定的?
在纯OC时代,绑定Key-Class方式有很多种,最开始采用的也是比较简单和直接的方式,在进入房产业务时,通过NSDictionary进行绑定Key-CellName和Key-Model,绑定方式如下:
NSMutableDictionary *classNames = [NSMutableDictionary dictionary];[classNames setObject:@"HSListHeaderCell" forKey:@"list_header_data"];[classNames setObject:@"HSListFootCell" forKey:@"list_foot_data"];......
NSMutableDictionary *modelNames = [NSMutableDictionary dictionary];[classNames setObject:@"HSListHeaderModel" forKey:@"list_header_data"];[classNames setObject:@"HSListFootModel" forKey:@"list_foot_data"];

但经过一段时间的迭代,我们发现这种方式有一些弊端,每次新增Cell都需要来这里修改代码,而且多业务线同时开发的时候不容易管理和维护,且容易代码冲突,违反了设计原则中的开闭原则。所以我们后期在重构的时候想到解决这个问题。

OC注入绑定方案一

首先我们想到的方案是注入的方式,在每个类的+Load方法中绑定Key-CellName,代码如下:

+(void)load{    [HSBusinessWidgetBindManager.sharedInstance setWidgetKey:@"list_header_data" widgetClassName:@"HSListHeaderWinget"];}+ (NSString *)cellName {    return NSStringFromClass(HSListHeaderCell.class);}
+ (NSString *)cellModelName { return NSStringFromClass(HSListHeaderModel.class);}

但这种方式的缺陷就是+Load方法会对应用程序的启动时长有一定的影响,我们加起来有上百个Cell,所以+Load方法也不是一个很好的方式。(在这里我们直接绑定的是Widget,简化外部处理的流程,Cell、Model的绑定和数据相关的处理由Widget来完成)

OC注入绑定方案二

最后我们现在实现的方式是在程序预编译阶段,直接把绑定的数据写到Macho中,程序在进入业务线时,写入到内存中,然后通过Server下发的Key找到对应的WidgetName,具体代码如下:

typedef struct {    const char * cls;    const char * protocol;} _houselist_presenter_pair;

#define _HOUSELIST_SEGMENT "__DATA"#define _HOUSELIST_SECTION "__houselist"
#define HOUSELIST_PRESENTER_REGIST(PROTOCOL_NAME,CLASS_NAME)\__attribute__((used, section(_HOUSELIST_SEGMENT "," _HOUSELIST_SECTION))) static _houselist_presenter_pair _HOUSELIST_UNIQUE_PAIR = \{\#CLASS_NAME,\#PROTOCOL_NAME,\};\
但Widget需要绑定时,引入上面定义好的宏,传入对应的Key和WidgetName:
HOUSELIST_PRESENTER_REGIST(list_header_data, HSHeaderWidget)

进入房产业务时,读取Macho中DATA段之前存储的数据,并保存到内存中,代码如下:.h文件

@interface HouseListPresenterKVManager : NSObject+ (instancetype)sharedManager;- (Class)classWithProtocol:(NSString *)key;@end
.m文件
#import "HouseListDefines.h"#import "HouseListPresenterKVManager.h"#import <mach-o/getsect.h>#import <mach-o/loader.h>#import <mach-o/dyld.h>#import <dlfcn.h>
@interface HouseListPresenterKVManager ()
@property (nonatomic, strong) NSMutableDictionary<NSString*, NSString*> *presenterKV;
@end
@implementation HouseListPresenterKVManager
static HouseListPresenterKVManager *_instance;
+ (instancetype)sharedManager{ static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ _instance = [[HouseListPresenterKVManager alloc] init]; [self loadKVRelation]; }); return _instance;}
+ (void)loadKVRelation{#if DEBUG CFTimeInterval loadStart = CFAbsoluteTimeGetCurrent();#endif Dl_info info; int ret = dladdr((__bridge const void *)(self), &info); if (ret == 0) return;
#ifndef __LP64__ const struct mach_header *mhp = (struct mach_header *)info.dli_fbase; unsigned long size = 0; uint32_t *memory = (uint32_t *)getsectiondata(mhp, _HOUSELIST_SEGMENT, _HOUSELIST_SECTION, &size);#else /* defined(__LP64__) */ const struct mach_header_64 *mhp = (struct mach_header_64 *)info.dli_fbase; unsigned long size = 0; _houselist_presenter_pair *memory = (_houselist_presenter_pair *)getsectiondata(mhp, _HOUSELIST_SEGMENT, _HOUSELIST_SECTION, &size); /* defined(__LP64__) */#endif
#if DEBUG CFTimeInterval loadComplete = CFAbsoluteTimeGetCurrent(); NSLog(@"====>houselist_loadcost:%@ms", @(1000.0 * (loadComplete - loadStart))); if (size == 0) { NSLog(@"====>houselist_load:empty"); return; }#endif for (int idx = 0; idx < size / sizeof(_houselist_presenter_pair); ++idx) { _houselist_presenter_pair pair = (_houselist_presenter_pair)memory[idx]; [_instance.presenterKV setValue:[NSString stringWithCString:pair.cls encoding:NSUTF8StringEncoding] forKey:[NSString stringWithCString:pair.protocol encoding:NSUTF8StringEncoding]]; }#if DEBUG NSLog(@"====>houselist_callcost:%@ms", @(1000.0 * (CFAbsoluteTimeGetCurrent() - loadComplete)));#endif}
- (Class)classWithProtocol:(NSString *)key;{ NSString* protocolName = key; if (!ValidStr(protocolName)) { return [NSObject class]; } Class res = ValidStr(self.presenterKV[protocolName]) ? NSClassFromString(self.presenterKV[protocolName]) : [NSObject class]; return res ?: [NSObject class];}
- (NSMutableDictionary *)presenterKV{ if (!_presenterKV) { _presenterKV = [NSMutableDictionary dictionaryWithCapacity:10]; } return _presenterKV;}@end
这种方式即规避了之前集中绑定的弊端,也没有+Load中的性能问题,但我们引入Swift代码混编之后,发现Swift中既没有+Load方法,也没有预编译这样的机制.那我们如何解决Key-Wdiget绑定的问题并且还能和我们现在的机制无缝衔接?

Swift与OC混编注入绑定问题及解决方案

在尝试各种方案之后,最终我们选择的是因为Swift与OC混编时,具有OC运行时的特性,首先创建一个BindKVCenter这样的Class,但每次创建新的Widget时,我们就给BindKVCenter添加一个Extension,Extension中实现一个enter的方法来绑定Key-Widget。最后进入房产业务时,拿到BindKVCenter中所有Extension中的enter方法,直接进行函数调用,达到绑定的效果,具体代码如下: BindKVCenter.swift
@objc(BindKVCenter)public class BindKVCenter: NSObject {    // 分类重写绑定    private class func enter() {    }}
Widget1
private extension BindKVCenter {    @objc class func enter() {        HouseListPresenterKVManager.shared().bindKV(withKey: "list_header_data", value: "HSHeaderWidget")    }}
@objc(HSHeaderWidget)class HSHeaderWidget: NSObject {}
Widget2
private extension BindKVCenter {    @objc class func enter() {        HouseListPresenterKVManager.shared().bindKV(withKey: "list_foot_data", value: "HSFootWidget")    }}
@objc(HSFootWidget)class HSFootWidget: NSObject {}
分发绑定的核心代码
Class currentClass = [BindKVCenter class];    if (currentClass) {        typedef void (*fn)(id,SEL);        unsigned int methodCount;        Method *methodList = class_copyMethodList(object_getClass(currentClass), &methodCount);        IMP imp = NULL;        SEL sel = NULL;        for (NSInteger i = 0; i < methodCount; i++) {            Method method = methodList[i];            NSString *methodName = [NSString stringWithCString:sel_getName(method_getName(method))                                                      encoding:NSUTF8StringEncoding];            if ([@"enter" isEqualToString:methodName]) {                imp = method_getImplementation(method);                sel = method_getName(method);                if (imp != NULL) {                    fn f = (fn)imp;                    f(currentClass,sel);                }            }        }        free(methodList);    }

拿到BindKVCenter中的所有方法,分类中因为添加的重名方法不会覆盖,找到methodList所有enter方法,再通过函数指针直接调用。进行绑定,从而实现一种注入的方式。这种方式即能够和OC中macho绑定的方式无缝衔接,还能避免和之前设计初衷冲突。并且对性能的损耗达到最小

性能对比及收益

我们测试了下Swift和OC混编情况下的关键性能指标,通过Swift实现的轮播图功能的页面和之前OC之前的轮播图功能页面进行对比。测试方案是加载100次每10次取平均值所得到数据性能指标,得到的结果是:FPS不差上下,CPU性能消耗随着业务量的增加Swift有明显的优势,内存方面Swift比OC占用更高,主要是目前项目工程还是混编环境,Swift需要兼容OC的特性。代码量Swift相比于OC减少38%

06

 总 结

Swift是一门非常优秀的语言,融合了各种语言的优点和特性,相比于OC在性能、安全、效率等方面都有很大的提升。虽然在接入的过程中虽然有很多的坑,但最终逐个突破。在房产经过半年多时间的沉淀,目前租房/商业地产大类页、详情页以及直播业务均有使用Swift开发。团队从0到50%开发人员具备Swift开发能力。未来我们会在Swift方向继续加大投入,全面拥抱Swift。


参考文献:

https://stackoverflow.com/questions/24030814/swift-language-nsclassfromstring
https://stackoverflow.com/questions/27776497/include-of-non-modular-header-inside-framework-module
https://tech.meituan.com/2015/03/03/diveintocategory.html
https://swifter.tips/objc-dynamic/


作者简介:

吴品:房产事业部-大前端技术部-移动技术部-资深工程师。

本文分享自微信公众号 - 58技术(architects_58)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

展开阅读全文
  • 0
    感动
  • 0
    路过
  • 0
    高兴
  • 0
    难过
  • 0
    搞笑
  • 0
    无聊
  • 0
    愤怒
  • 0
    同情
热度排行
友情链接