注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

Swift接入例子-适合多人协作

iOS
在「 Swift接入例子 」中介绍了Swift项目如何接入SOT。但是要求SDK解压到特定目录中,编译配置的路径也是绝对路径,不适合多人协作合开。文本介绍适合多人开发协作的接入方法。 还是以开源的「 SwiftMessages 」Demo为例,该工程全部用Sw...
继续阅读 »

「 Swift接入例子 」中介绍了Swift项目如何接入SOT。但是要求SDK解压到特定目录中,编译配置的路径也是绝对路径,不适合多人协作合开。文本介绍适合多人开发协作的接入方法。


还是以开源的「 SwiftMessages 」Demo为例,该工程全部用Swift语言开发。这里把修改好的版本上传到了git上,分支为 「 sotcollaboration 」SotDebug接入免费版,SotRelease接入网站版,读者只需要进行下面的 Step1.配置编译环境 就可以直接用该分支测试。


现在开始从头讲解,git clone原本的工程后(我的路径为/Applications/SwiftMessages),命令行cd /Applications/SwiftMessages进入根目录,使用版本切换命令:git checkout 1e49de7b3780b699(因本文档制作于21年10月21号,以当日版本为准)。首先进入Demo目录,打开Demo.xcodeproj工程,scheme默认就已经选中了Demo:...


我使用的是Xcode12.4,可以直接编译成功,启动APP能看到画面(模拟器):


......


点击最上面的MESSAGE VIEW控件,会弹出一个错误提示窗口,今天我们就来用SOT热更的方式修改错误提示的文案:...


Step1: 配置编译环境


「 下载SOT的SDK 」,解压到项目目录下 /Applications/SwiftMessages/Demo/sotsdk...


在terminal运行命令:sh /Applications/SwiftMessages/Demo/sotsdk/compile-script/install.sh安装SOT编译工具链,需要输入密码。


用文本编辑器打开 /Applications/SwiftMessages/Demo/sotsdk/project-script/sotconfig.sh,修改EnableSot=1:...新版SDK已经不会再使用sotconfig.sh里的sdkdir,sotbuilder和objbuilder路径了,所以不用修改这些配置了,删掉也可以。


Step2: 增加Configuration


增加两个Configuration,只有切换到这两个Configuration才使用SOT编译模式,平时还是用原来的Configuration做开发,步骤如下:



  1. 选中Demo Project,然后选择Info面板,点击Configurations的下面加号,复制Debug的编译配置,并且命名为SotDebug,用来接入免费版的SOT。再选择复制Release编译配置,命名为SotRelease,用来配置网站版的SOT,注意名字都不要留有空格:...加完就是:...

  2. SwiftMessages也加上这两个Configuration:...


注意:读者应用到自己项目中时,需要把所有的工程都加上这两个Configuration,否则编译会报找不到文件等等的错误。所以加完这两个Configuration的之后,就马上切换到它们去Build和Run一下,看是否有编译错误,如果没有再进行下面的操作,如果有,请检查是否漏了一些工程没有添加上。


Step3: 修改编译选项


添加热更需要的编译选项,添加SOT虚拟机静态库等,步骤如下:




  1. 选中Demo工程,然后选择Demo这个Target,再选择Build Settings:...




  2. Other Linker FlagsSotDebug中添加-sotmodule $(PRODUCT_NAME) sotsdk/libs/libsot_free.a -sotsaved $(SRCROOT)/sotsaved/$(CONFIGURATION)/$(CURRENT_ARCH) -sotconfig $(SRCROOT)/sotsdk/project-script/sotconfig.sh


    SotRelease中添加-sotmodule $(PRODUCT_NAME) sotsdk/libs/libsot_web.a -sotsaved $(SRCROOT)/sotsaved/$(CONFIGURATION)/$(CURRENT_ARCH) -sotconfig $(SRCROOT)/sotsdk/project-script/sotconfig.sh


    每个选项的意义如下:



    • -sotmodule是module的名字,可以直接用$(PRODUCT_NAME),也可以自定义名字,名字不要有空格

    • -sotsaved是编译中间产物保存的目录,补丁自动化生成需要对比前后编译的产物来生成补丁

    • -sotconfig指定了项目sotconfig.sh的路径,该脚本控制sot编译器的工作

    • sotsdk/libs/libsot_free.a是SOT虚拟机静态库的路径,链接的是免费版的虚拟机

    • sotsdk/libs/libsot_web.a是SOT虚拟机静态库的路径,链接的是网站版的虚拟机




  3. Other C Flags以及Other Swift Flags的SotDebug和SotRelease下添加-sotmodule $(PRODUCT_NAME) -sotconfig $(SRCROOT)/sotsdk/project-script/sotconfig.sh,意义跟上一步是一样的,需要保持一致。经过上面两步,相关的编译配置结果如下图:...




  4. Preprocessor Macros添加USE_SOT=1,后面用来控制是否编译调用SDK的代码...




  5. 因为SOT SDK库文件编译时不带Bitcode,所以也需要把Enable Bitcode设为No...




  6. 为了模拟器架构时不编译arm64,给SotRelease增加如下配置...或者把Build Active Architecture Only设为Yes




Step4: 增加拷贝补丁脚本


SDK里提供了一个便利脚本,路径在sdk目录的project-script/sot_package.sh,它会把生成的补丁拷贝到Bundle文件夹下,在每次项目编译成功时调用该脚本,添加步骤如下:


...


脚本内容为:



if [[ "$CONFIGURATION" == "SotDebug" || "$CONFIGURATION" == "SotRelease" ]];then
sh "$SOURCE_ROOT/sotsdk/project-script/sot_package.sh" "$SOURCE_ROOT/sotsdk/project-script/sotconfig.sh" "$SOURCE_ROOT/sotsaved/$CONFIGURATION" Demo
fi

复制代码

...


Based on dependency analysis的勾去掉。




Step5: 链接C++库


SOT需要压缩库和c++标准库的支持,还是在这个页面下,打开Link Binary With Libraries页...


点击加号,分别加入这两,libz.tbdlibc++.tbd...




Step6: 调用SDK API


需要用Swift代码调用OC代码,已经提供了一个样例代码在SDK的swift-call-objc目录中,先把callsot.h和callsot.m拷贝到Demo目录下...


再添加到Demo工程中。点击Xcode软件的File按钮,找到Demo目录下的callsot.h和callsot.m,接着点击Add Files to "Demo",如下图所示:...


点击Add按钮,添加后会弹出询问:是否创建桥接文件。点击按钮Create Bridging Header...


然后可以看到项目中多了3个文件,分别是callsot.h,callsot.m和Demo-Bridging-Header.h:...


打开Demo-Bridging-Header.h,加入一行代码#import "callsot.h"...


打开callsot.m,修改代码为


#import <Foundation/Foundation.h>
#import "callsot.h"
#import "../sotsdk/libs/SotWebService.h"
@implementation CallSot:NSObject
-(void) InitSot
{
#ifdef USE_SOT
#ifdef DEBUG
[SotWebService ApplyBundleShip];
#else

[SotWebService Sync:@"1234567" is_dev:false cb:^(SotDownloadScriptStatus status)
{
if(status == SotScriptStatusSuccess)
{
NSLog(@"SotScriptStatusSuccess");
}
else
{
NSLog(@"SotScriptStatusFailure");
}
}];

#endif
#endif
}
@end
复制代码

注意SotWebService.h的头文件路径不再依赖于绝对路径,并且代码里用了#ifdef USE_SOT宏来隔开API调用代码,不影响正常编译:...


打开AppDelegate.swift,加入两行代码let sot = CallSot()sot.initSot()...


注意:读者在应用到自己项目中时,以上这些配置的路径不要生搬硬套。例如找不到SotWebService.h文件,找不到sotconfig.sh文件等等,读者自己要清楚SDK的目录与自己工程目录的相对关系,灵活调整这些配置的路径。


测试热更-免费版


按上面配置完之后,先测试免费版热更功能


Step1: 热更注入



  1. Build Configuration切换到SotDebug...

  2. 确保sotconfig.sh的配置是,EnableSot=1以及GenerateSotShip=0,先Clean Build Folder一下,然后再Build:...


然后看编译日志的输出,Link日志可以看到run sot link等输出,会告诉你每个文件里哪些函数可以被热更等信息:......


项目编译成功了,该APP可以正常启动。同时它具备了热更能力,可以加载补丁改变程序的代码逻辑,下面介绍如何生成补丁来测试它。




Step2: 生成补丁


上一步进行了热更注入的编译,当时的代码保存到了Demo/sotsaved这个文件夹下,用来和新代码比较生成补丁。生成补丁步骤如下:



  1. 首先启动SOT生成补丁模式,修改sotconfig.shEnableSot=1GenerateSotShip=1

  2. ...

  3. 接下来直接在Xcode里修改源代码,把ViewController.swift文件的”Something is horribly wrong!“改成了”SOT is great“,修改前:...修改后:...

  4. Swift项目生成补丁,每次都需要先Clean项目,再Build项目。然后查看编译日志输出,可以看到生成了补丁并且被脚本拷贝到了Bundle目录下,可以展开Link Demo(x86_64)的编译日志:...点击展开后,可看到生成补丁的Link日志,日志里显示了函数demoBasics被修改了:...

  5. 生成出来的补丁原始文件保存到了Demo/sotsaved/SotDebug/x86_64/ship/ship.sot,还记得之前加了一个script到Build Phase中吗?它会每次编译结束时,会把这个补丁拷贝到了Bundle目录中,并且添加CPU架构到文件名中。可以在Bundle中看到这个补丁,至此完毕。




Step3: 加载补丁


启动APP,API会判断Bundle内是否有补丁,有则加载,加载成功的日志大概如下,提示有一个模块加载了热更补丁:...之后点击最上面的MESSAGE VIEW控件,发现弹出的文案变成了SOT is great:...


如果去Xcode断点调试demoBasics,会发现无法断住了,因为实际执行补丁代码的是SOT虚拟机。


顺便提一嘴,GenerateSotShip=1时,编译APP用的是保存在sotsaved目录下的代码,所以无论怎么修改Xcode里的代码,如果没有把补丁拷贝到Bundle目录里,那么APP都是最后一次GenerateSotShip=0热更注入时的样子。


如果怀疑,可以把拷贝补丁的Script脚本从Build Phases删除,可以发现GenerateSotShip=1怎么改代码都不会生效了。


注意:如果读者接入自己的一个很简单项目进行测试,例如设置某个控件的颜色,热更前是红色,修改后是绿色,发现无法生效。那是因为这样的项目太过于简单,寥寥几行代码。热更前没有访问过绿色的这个全局变量,在热更时也无法访问到了,SOT只能利用原有的能力,无法无中生有。所以不要这样测试,更具体的原因在「 热更能力-语言特性 」说明。通常完整的项目代码比较多,所以就不会有这样的缺陷。


接入网站版


按上面的教程,已经对APP实现了免费版和网站版的接入。它俩区别只是链接的库不一样,具体就是Other Linker Flags根据Configuration区别配置,SotRelease下接入了网站版。但除了APP接入了网站版SDK,还需要用配合网站来管理补丁的发布。


Step1: 注册网站



  1. 第一步当然是注册网站,成为会员。点击跳转注册页面,免费注册,注册需要验证邮箱,然后登录。

  2. 从导航栏进入我的APP:...

  3. 点击创建APP,弹出弹窗填写APP的名字:...

  4. 进入APP页面,点击右上角的创建新版本按钮,会弹出弹窗,需要选择网站版,SDK版本选择1.0,目前只有1.0版本,然后输入版本号,版本号可以是随意字符串,方便区分就行。...

  5. 创建版本成功后,点击版本,进入版本页面,左上角是唯一标识该版本的VersionKey,后面API接口需要这个Key。...


Step2: 修改VersionKey


打开callsot.m,修改网站版的Sync接口,第一个参数填入你在网站创建的版本的VersionKey。...至此,网站版热更就算接入完成了。


Step3: 测试网站热更




  • 网站版生成补丁的步骤免费版是一样的,需要经历热更注入->出包->修改代码->生成补丁,这里不再赘述。


    唯一不同的是,生成出来的补丁要上传到网站上,然后才能通过网络同步到手机上实现热更。通过之前的免费版教程,知道生成的补丁会被拷贝到Bundle目录下,所以去Bundle目录里就能找它,在Xcode导航栏里右键选择Products下的Demo.app,选择Show in Finder:...




  • 右键Demo文件,选择Show Package Contents:...




  • 找到目录下的sotship_arm64.sot,这里用手机测试,cpu是arm64类型,补丁名字带有cpu后缀,这就是补丁了:...




  • 回到网站的版本页面,点击右侧上传补丁按钮:...




  • 弹出页面里,真机的架构一般选择arm64,除非是老的armv7的机器,并把补丁文件拖到框里,点击上传:...




  • 上传成功并且补丁文件无异常(补丁最大支持5MB),则会添加成功,补丁默认是停用状态,需要点击编辑来启用它:...




  • 这里选择全量启用,点击下面的提交按钮,然后补丁就会成功启用了:...




  • 上一步更新了补丁状态,通常很快生效,但CDN有时也需要1到2分钟才能生效。之后手机打开APP,如果成功下载补丁和加载的话,就能看到下面的日志:...这里输出的md5也跟网站上的补丁md5是一致的。




  • 打开APP后,点击最上面的MESSAGE VIEW控件,发现弹出的文案变成了SOT is great:...




注意:使用网站版,需要考虑到网络传输延迟的问题,只有看到了下载补丁和成功加载补丁的日志之后,调用的函数才会使用热修后的函数。例如有的开发问我,首屏代码怎么无法热更生效?那是因为首屏代码调用的时机太早了,SOT去网站上拿补丁,是异步的,不会一直卡住等着,而且在异步线程中等待结果。在补丁没传输回来之前,首屏的代码都已经调用结束了,这种情况下当然调用的还是老的代码了。而免费版没有这个问题,因为免费版是同步加载补丁的,直接去Bundle里加载,不是异步的。


构建热更注入版本和构建补丁必须是同一台机器,同一个Xcode版本。例如上架前APP用Xcode12进行了热更注入,而之后用Xcode13来构建补丁,那么将得到无效甚至错误的补丁。请使用同一个版本Xcode。


Step4: 几点提示



  • 网站版跟免费版主要接入流程差不多,可以用免费版测试,功能通过测试之后再接入网站版。

  • 网站版需要有网络的情况下才能生效,如果手机没有网,即使之前已经下载过了补丁,也无法加载。

  • 网站版费用很低,日活10万的APP,一个月几百块就够了。

  • 网站版补丁和配置都放在CDN上,支持高并发。




非主Target接入热更


上面的教程都是针对主Target,也就是Demo。这个工程还有一个名为SwiftMessages的Framework,也可以热更,下面介绍如何配置。


可以看到SwiftMessages的Mach-O Type是Dynamic Library,通过下图方式查看得到:...


这种类型的话,配置相对麻烦些。还有一种是Static Library,配置起来会简单得多。但本例改成Static Library启动会崩溃,所以按Dynamic Library的方式来介绍。


Step1: 修改编译选项



  1. 选中SwiftMessages.xcodeproject工程,然后选择SwiftMessages这个Target,再选择Build Settings:...

  2. Other Linker FlagsSotDebug中添加-sotmodule $(PRODUCT_NAME) $(SRCROOT)/Demo/sotsdk/libs/libsot_free.a -sotsaved $(SRCROOT)/Demo/sotsaved/$(CONFIGURATION)/$(CURRENT_ARCH) -sotconfig $(SRCROOT)/Demo/sotsdk/project-script/sotconfig.sh

  3. Other Linker FlagsSotRelease中添加-sotmodule $(PRODUCT_NAME) $(SRCROOT)/Demo/sotsdk/libs/libsot_web.a -sotsaved $(SRCROOT)/Demo/sotsaved/$(CONFIGURATION)/$(CURRENT_ARCH) -sotconfig $(SRCROOT)/Demo/sotsdk/project-script/sotconfig.sh

  4. Other C Flags以及Other Swift FlagsSotDebugSotRelease中,添加-sotmodule $(PRODUCT_NAME) -sotconfig $(SRCROOT)/Demo/sotsdk/project-script/sotconfig.sh,意义跟上一步是一样的,需要保持一致。经过上面两步,相关的编译配置结果如下图:...这一步跟Demo的配置差不多,区别在于有些路径写法不一样,以达到复用Demo配置的目的,读者可以仔细比较一下。

  5. Preprocessor Macros添加USE_SOT=1,后面用来控制是否编译调用SDK的代码...

  6. 需要把Target的Enable Bitcode设为No...

  7. 为了模拟器架构时不编译arm64,给SotRelease增加如下配置...


Step2: 链接C++库


点击Build Phases页面,打开Link Binary With Libraries页,点击加号,分别加入这两,libz.tbdlibc++.tbd...


Step3: 调用SDK API


因为SwiftMessages是动态库,所以需要在它的编译文件中调用SDK的热更初始化接口。跟Demo一样,添加OC文件。先从Demo文件夹中复制callsot.h和callsot.m文件到SwiftMessages文件夹中...


选中SwiftMessages工程,点击Xcode软件的File按钮,接着点击Add Files to "SwiftMessages.xcodeproject",如下图所示:...


选择到SwiftMessages目录,同时选中callsot.h和callsot.m两个文件,勾选下面的Copy items if needed,勾选Add to targets:中的SwiftMessages target,如下图所示:...


点击Add按钮,然后可以看到项目中多了2个文件,分别是callsot.h,callsot.m,修改CallSot类名为CallSotMessage:...


去到右边面板,把文件属性改成public:...


打开callsot.m,做相应路径和类名的修改:...


打开Demo-Bridging-Header.h,加入一行代码#import "SwiftMessages/callsot.h"...


打开AppDelegate.swift,加入两行代码let sot1 = CallSotMessage()sot1.initSot()...


因为这时候有两个Target都可以生成补丁,Demo和SwiftMessages,需要修改拷贝补丁的脚本,加入SwiftMessages:...


Step4: 测试热更




  1. 测试热更的流程跟之前是一模一样的,只是输出的日志可能会有所区别,我们过一遍。EnableSot=1和GenerateSotShip=0热更注入,先Clean后Build,如果去看编译日志的Link SwiftMessages,也可以看到热更注入的信息。




  2. 然后修改MessageView.swift的代码,错误提示的文案会加上“SOT is great”:...




  3. GenerateSotShip=1开启生成补丁模式,Clean后Build,查看Link SwiftMessages日志,有提示该函数被热更:...




  4. 接下来可以看到补丁拷贝脚本日志输出的信息,这里它检测到有两个Target都生成了补丁文件,会把它们两个合成一个,拷贝到Bundle目录下:...




  5. 启动APP,会看到两条加载补丁的日志,因为我们Demo Target和SwiftMessages Target都调用了API接口:...




  6. 点击MESSAGE VIEW控件,可以看到错误提示文案后面多了“SOT is great”,热更成功:


    ...网站版的测试跟以前也是一样的,这里不再重复了。




Step5: 几点提示



  1. Dynamic Library的热更编译改法其实跟主Target,也就是Mach-O Type为Executable的改法是一样,只是这里复用了主Target的一些配置,例如sotsaved目录和sotconfig.sh的路径。增加再多的Target也可以按同样的改法修改它们。

  2. 补丁拷贝脚本只需要主Target有就行了,把要热更的sotmodule对应的名字加上即可,条件就是sotsaved目录必须是同一个。

  3. 如果需要接入网站版,那么每个需要热更的Target都需要调用API跟网站同步,它们的消耗是独立计费的。


Static Library的改法


上面说到Dynamic Library的改法步骤比较多,而且有诸多缺点,如果能把Framework的Mach-O Type改成Static Library是最好的,会少很多步骤和配置。由于本例无法修改,这里简单说一下步骤:



  1. Other Libraian Flags添加-sotmodule $(PRODUCT_NAME) -sotsaved $(SRCROOT)/Demo/sotsaved/$(CONFIGURATION)/$(CURRENT_ARCH) -sotconfig $(SRCROOT)/Demo/sotsdk/project-script/sotconfig.sh ,注意是Other Libraian Flags而不是Other Linker Flags了。还有这里比Dynamic Library加的配置少一个,即没有链接SDK的.a库文件了。

  2. Other C Flags以及Other Swift Flags添加-sotmodule $(PRODUCT_NAME) -sotconfig $(SRCROOT)/Demo/sotsdk/project-script/sotconfig.sh,这步跟之前是一模一样的。

  3. 需要把Target的Enable Bitcode设为No

  4. 修改拷贝补丁的脚本,加入该Target的名字,例如本例加入SwiftMessages,跟之前也是一模一样的:...


然后就配置完了,如果是使用网站版,同步一次消耗,就能实现所有Target的热更,修改简单,对包体影响最小。




总结


本文完整介绍Swift项目如何接入免费版和网站版。


本文的方式是把SDK拷贝到了工程文件夹里,让它可以跟随项目一起进行版本管理,路径也配置成了相对路径,更加灵活。


通过新增Configuration的方式,也做到了不影响原来的开发,Debug和Release相当于没有接入SOT,适合大多数开发平时使用。只需上线前改成SotRelease出包,就能让APP就得到热更能力。


作者:忒修斯科技
链接:https://juejin.cn/post/7033403091550470180
收起阅读 »

一个录音项目的开发总结(一)

iOS
最近,工作之余,自己做了一个项目,项目的一期主要功能是音频录制和播放,音频格式包含m4a、mp3、wav三种格式,录制过程中要支持变音,还要能获取到metering以绘制录音过程的声音强弱变化图,播放功能包括音频波形图的绘制以及音频播放。 在做之前,我对iOS...
继续阅读 »

最近,工作之余,自己做了一个项目,项目的一期主要功能是音频录制和播放,音频格式包含m4a、mp3、wav三种格式,录制过程中要支持变音,还要能获取到metering以绘制录音过程的声音强弱变化图,播放功能包括音频波形图的绘制以及音频播放。


在做之前,我对iOS中的录音方面的知识了解甚少, 以至于走了很多弯路,虽然浪费了很多时间,但是也从中学到了很多知识,最终完成了项目的编码。


以下,主要介绍录音方面开发总结,播放方面后续记录。


在iOS中如果想实现录音功能,那么有四种方式可以实现:



  1. AVAudioRecorder :这是最简单的录音方式,只需要配置好录音格式就能得到相应的文件,但是相应的,这种方式无法得到录音过程中的音频源数据,无法实现变音功能和录制mp3文件。

  2. AVAudioEngine:AVAudioEngine功能强大,能实现录音、播放、混响、变音等功能,当我发现我这个类时,我高兴坏了,看了很多文档,结果做demo时,发现这个类外强中干,譬如你不能改变AudioEngine默认的inputNode、outputNode、mainMixNode的数据format,即使你千辛万苦找方法成功改变了数据格式,输出的数据也会狠狠地扇你一巴掌,告诉你高兴太早了,而且inputNode、outputNode、mainMixNode不会自动转换数据格式......如果想录制m4a、mp3还是需要其他方式才能实现。

  3. AudioQueue & AudioFile:AudioQueue在录音过程中有个回调方法,抛出编码后的音频数据,如果想处理音频数据,譬如变音、转码,可以在这个回调中实现,而且能获取到metering数据; AudioFile用于存储回调方法中抛出的音频数据,这个音频数据要与AudioFile创建时配置的inFormat一致。如果想了解audioqueue,可以看看这个文档

  4. AudioUnit &  ExtAudioFile:AudioUnit在录音过程中也会有回调,AudioUnit只支持pcm录制,所以要辅以ExtAudioFile来实现其他音频格式的录制,ExtAudioFile是个宝藏类,这个类可以实现音频格式的自动转换。


如果只是单纯的录用,那么使用AVAudioRecorder就可以非常简单的实现了,如果想对音频数据做处理,那么对于录音过程中抛出的数据请务必是pcm,只要能拿到pcm源数据,任何能做到的音频处理只要你想,都能实现。


iOS中,系统支持的录音编码格式为:


官方参考文档点击查看



wav文件是无损编码格式pcm,m4a文件编码格式AAC,mp3文件iOS系统不支持录制,系统支持mp3文件播放,有解码器但是无相应编码器,如果想录制mp3 文件只能依托于第三方的编码库,我采用的是lame库。


变音


iOS系统中本身是支持变音功能的,AudioUnit中有一个属性kNewTimePitchParam_Pitch,可以改变声音的音色,但是这个属性是MixerUnit的属性,录音的OutputUnit不支持这个属性,要想将 混音MixerUnit和录音OutputUnit连接到一块,需要AUGraph类去连接,但是AUGraph已弃用。。。系统推荐去使用AVAudioEngine类,这个类也是个坑,我在做demo时无法实现m4a文件的录制,就放弃了。


所以变音我采用的也是三方类SoundTouch,SoundTouch支持pcm编码、音频数据是lettle-endian,所以录音过程中抛出的音频buffer的format必须是pcm的,不然无法实现边录音边变音。


这样看起来AudioUnit貌似更适合这个项目一些,但是AudioUnit无法获取到录音过程中的metering数据,真是个令人悲伤的事情,AudioUnit很强大,如果AUGraph不被弃用,可能我会用它来录音。


最终我的实现方案是:


1、wav文件录制:AudioQueue录音、 AudioFile存储文件,AudioQueue和AudioFile的dataFormat为:


AudioStreamBasicDescription mDataFormat;

mDataFormat.mSampleRate = 44100;
mDataFormat.mChannelsPerFrame = 1;
mDataFormat.mReserved = 0;
mDataFormat.mBitsPerChannel = 16;
mDataFormat.mFramesPerPacket = 1;
mDataFormat.mSampleRate = self.model.sampleRate;
mDataFormat.mBytesPerFrame = mDataFormat.mBytesPerPacket = 2;
mDataFormat.mFormatID = kAudioFormatLinearPCM;
mDataFormat.mFormatFlags = kLinearPCMFormatFlagIsSignedInteger | kLinearPCMFormatFlagIsPacked;


AudioFile的fileType为kAudioFileCAFType


2、mp3文件:AudioQueue录音, lame转码, FILE文件存储, AudioQueue的编码格式为:


AudioStreamBasicDescription mDataFormat;

mDataFormat.mSampleRate = 44100;
mDataFormat.mChannelsPerFrame = 1;
mDataFormat.mReserved = 0;
mDataFormat.mBitsPerChannel = 16;
mDataFormat.mFramesPerPacket = 1;
mDataFormat.mSampleRate = self.model.sampleRate;
mDataFormat.mBytesPerFrame = mDataFormat.mBytesPerPacket = 2;
mDataFormat.mFormatID = kAudioFormatLinearPCM;
mDataFormat.mFormatFlags = kLinearPCMFormatFlagIsSignedInteger
| kLinearPCMFormatFlagIsPacked
|kAudioFormatFlagIsNonInterleaved


3、m4a文件:AudioQueue录音,  ExtAudioFile转码加存储,AudioQueue和ExtAudioFile的kExtAudioFileProperty_ClientDataFormat属性的编码格式为:


AudioStreamBasicDescription mDataFormat;

mDataFormat.mSampleRate = 44100;
mDataFormat.mChannelsPerFrame = 1;
mDataFormat.mReserved = 0;
mDataFormat.mBitsPerChannel = 16;
mDataFormat.mFramesPerPacket = 1;
mDataFormat.mSampleRate = self.model.sampleRate;
mDataFormat.mBytesPerFrame = mDataFormat.mBytesPerPacket = 2;
mDataFormat.mFormatID = kAudioFormatLinearPCM;
mDataFormat.mFormatFlags = kLinearPCMFormatFlagIsSignedInteger | kLinearPCMFormatFlagIsPacked;


ExtAudioFile文件的音频编码格式为:


AudioStreamBasicDescription outputFormat;outputFormat.mSampleRate = 44100;

outputFormat.mFormatID = kAudioFormatMPEG4AAC;outputFormat.mFormatFlags = 0;outputFormat.mBytesPerPacket = 0;outputFormat.mFramesPerPacket = 1024;

outputFormat.mBytesPerFrame = 0;outputFormat.mChannelsPerFrame = 1;outputFormat.mBitsPerChannel = 0;outputFormat.mReserved = 0;


关于四种录音方式的代码实现后续会更新,音频编辑功能,二期可能会上,到时也会研究、记录,希望到时自己不会太懒......


作者:阿喵同学
链接:https://juejin.cn/post/6936869349546426382

收起阅读 »

“杀死” App 上的疑难崩溃!

iOS
问题与背景在移动应用性能方面,崩溃带来的影响是最为严重的,程序崩溃可以打断用户正在进行的操作体验,造成关键业务中断、用户留存率下降、品牌口碑变差、生命周期价值下降等影响。很多公司将崩溃率作为优先级最高的技术指标,因此程序崩溃的监控与收集就成为了一项必不可少的工...
继续阅读 »

问题与背景

在移动应用性能方面,崩溃带来的影响是最为严重的,程序崩溃可以打断用户正在进行的操作体验,造成关键业务中断、用户留存率下降、品牌口碑变差、生命周期价值下降等影响。很多公司将崩溃率作为优先级最高的技术指标,因此程序崩溃的监控与收集就成为了一项必不可少的工作, 目前58同城App使用腾讯Bugly作为发布环境下App异常数据的收集工具。

我们的崩溃率一直在优化,每个版本都有专门负责监控线上崩溃以及解决问题的同学,经过我们不断的优化,目前 58同城iOS App的崩溃率维持在一个比较优秀的水准, Bugly上收集的崩溃大部分都是野指针崩溃和疑难崩溃。但是遗留的疑难崩溃优化手段比较有限,一个主要的原因是Bugly上的崩溃不能正常解析,定位不到真正原因。我们拿一个简单的例子来说明一下。

RN的HOOK函数问题

0 CoreFoundation  0x00000001804f504c 	0x000000018045c000 + 626764
1 Foundation 0x0000000181dae6cc 0x0000000181c7e000 + 1246924
2 UIKit 0x0000000198e5cf30 0x0000000198e57000 + 24368
3 AppName 0xe622388106d79fcc RCTFBQuickPerformanceLoggerConfigureHooks + 16244
4 CoreTelephony 0x0000000198e5e628 0x0000000198e57000 + 30248
5 CoreTelephony 0x0000000108f68fe4 0x0000000198e57000 + 78455260
6 CoreTelephony 0x00000001061ed870 0x0000000198e57000 + 30763624
7 CoreTelephony 0x0000000108f657ec 0x0000000198e57000 + 78440932
8 AppName 0x0000000108f67024 _ZN6tflite19AcquireFlexDelegateEv + 78447132
9 Foundation 0x0000000108f67024 _NSGetUsingKeyValueGetter + 88

在近几个版本中,我们发现Bugly上有大量的崩溃日志都会携带一个来自RN的函数调用栈: RCTFBQuickPerformanceLoggerConfigureHooks,这是一个RNHOOK函数。多条崩溃日志的堆栈都指向这个函数,且这个函数是一个空函数,没有任何实现,这让我们比较困扰。用过Bugly的同学都知道,Bugly每条崩溃日志都有个跟踪数据,记录着这个崩溃发生之前页面的跟踪日志,通过页面的跟踪日志我们发现这些崩溃中用户浏览的页面大多数都不涉及RN业务,与RN没有任何关系。而且每条崩溃的页面跟踪日志也不相同。既然程序崩溃之前浏览的业务不涉及RN但Bugly上的堆栈确指向RN,因此我们怀疑这种崩溃不是崩溃在RNHOOK函数上并且它们是不同错误导致的崩溃。带着这种疑问,我们开始验证这个猜想,来看一看我们的怀疑是否准确。

如何验证Bugly解析错误

因为Bugly无法拿到应用崩溃后所产生的ips文件,无法利用symbolicatecrash等工具符号化日志。因此我们采用atos命令来验证我们的怀疑是否正确。

1. atos验证

atos工具会输出崩溃的代码语句和它所在的文件以及行数,前置条件是需要拿到dSYM文件,确定手机架构是arm64还是armv7,还需要拿到atos需要的load-addressaddress,根据这些信息就能够找到问题所在。aots命令格式如下:

atos -o yourAppName.app.dSYM/Contents/Resources/DWARF/yourAppName -arch arm64/armv7 -l <load-address> <address>

怎么获取dSYM文件与架构这里就不做详细介绍了,我们来看一下怎么在Bugly的崩溃日志中拿到load-addressaddress

一般以app命名的地方就是崩溃的位置,例如:正常的一个崩溃日志格式为:

0x0000000103ef6970 0x0000000102728000 + 30252

其中0x0000000103ef6970为运行地址,就是atos需要的address0x0000000102728000为运行起始地址,就是atos需要的load address302522为偏移量,一般来说,偏移量 + 运行起始地址 = 运行地址。

介绍完atos需要的load address(运行起始地址)与address(运行地址)之后,再来看一下RCTFBQuickPerformanceLoggerConfigureHooks这个函数的崩溃,根据图中示例我们看到这个崩溃的运行地址为0xe622388106d79fcc,但是这个崩溃地址是错误的,一般地址小于0xFFFFFFFFFF,示例中明显大很多。因此我需要将高位地址清洗,清洗后此地址为0x106d79fcc。因此address0x106d79fcc

接下来我们打开Bugly其他信息一栏,看到App base addr(基地址):0x0000000102604000,这个就是atos需要需要的load address。 image20211116110000581.png 通过上述信息,我们以RCTFBQuickPerformanceLoggerConfigureHooks这个函数为例验证一下Bugly的解析结果是否正确

➜ atos -o AppName.app.dSYM/Contents/Resources/DWARF/AppName -arch arm64 -l 0x0000000102604000 0x0000000106d79fcc
-[NSMutableDictionary(YJKit) yjKit_setObject:forKey:] (in AppName) (YJKit.m:432)

结果发现atos符号化后的结果与Bugly给我们的结果确实不一致。再根据Bugly的页面跟踪数据我们确认atos符号化后的结果是正确的,这与我们的怀疑是一致的。

既然Bugly的堆栈错误的指向了这个RN的空函数,那么我们就来看一看源码中RCTFBQuickPerformanceLoggerConfigureHooks是怎样的存在。

**2. 源码中的RCTFBQuickPerformanceLoggerConfigureHooks**函数

RCTFBQuickPerformanceLoggerConfigureHooks函数在源码中的声明如下:

image-20211116110100992.png 源码中,这个函数没有任何实现,完全是一个空函数。将RCT__EXTERN 展开后为__attribute__((visibility("default"))),其作用为将RCTFBQuickPerformanceLoggerConfigureHooks向外界暴露,如果外界存在同名函数,那么RCTFBQuickPerformanceLoggerConfigureHooks会报符号冲突的错误。这里利用__attribute__((weak))RCTFBQuickPerformanceLoggerConfigureHooks声明为弱符号,当外界有同名函数时,SDK内部调用外届的函数,否则调用内部空函数,这个弱符号在RN里起到了HOOK的作用 ,接下来我们就详细的了解一下弱符号。

3. 弱符号__attribute__ ((weak))

在一个程序中,无论是变量名,还是函数名,在编译器的眼里,就是一个符号而已,符号可以分为强符号和弱符号。对于C/C++来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量是弱符号,强符号和弱符号在程序编译连接过程中一般遵循下面三个规则:

  1. 不允许强符号被多次定义。如果有多个强符号,会报符号重定义错误

  2. 如果有一个强符号,其他定义都是弱符号,则选择强符号

  3. 如果一个符号在所有文件中都是弱符号,则选择其中一个占用空间最大的

强弱符号规则定义摘选自:强符号和弱符号,强引用和弱引用

duplicate symbol '_OBJC_CLASS_$_XXX'这个错误大家应该都比较熟悉,通过错误的描述我们很容易就可以知道这是因为在链接的时候有重复的符号。在编译时,编译器向汇编器输出每个全局符号,若两个或两个以上全局符号(函数或变量名)名字一样,且都是强符号就会出现符号重定义错误,如果有一个是弱符号(weak symbol),则不会出现问题。

一个程序内同时存在强符号与弱符号时,链接器会忽略弱符号,去使用普通的全局符号来解析所有对这些符号的引用,但当普通的全局符号不可用时,链接器会使用弱符号。当有函数或变量名可能被用户覆盖时,该函数或变量名可以声明为一个弱符号。可以通过__attribute__((weak))来定义弱符号。

4. 弱符号的使用

在开发中,假如我们不确定外部模块是否提供一个函数func,但是我们不得不用这个函数,即自己模块的代码必须用到func函数:

extern int func(void);
...int a = func;
...

我们不知道func函数是否被定义了,这会导致2个结果:

  1. 外部存在这个函数func,那么在我自己的模块使用这个函数func,正确。
  2. 外部如果不存在这个函数,那么我们使用func,程序直接崩溃。

所以这个时候,__attribute__((weak)) 派上了用场,在自己的模块中定义:

int __attribute__((weak)) func(......)
{
return 0;
}

将本模块的func转成弱符号类型,如果遇到强符号类型(即外部模块定义了func),那么我们在本模块执行的func将会是外部模块定义的func。如果外部模块没有定义,那么,将会调用这个弱符号。

我们发现Bugly对某些没有解析正确的崩溃,堆栈都会定位到项目中的弱符号上,同时我们还发现在58同城App中,Bugly不单单定位到RCTFBQuickPerformanceLoggerConfigureHooks这一个弱符号上,还有大量的崩溃定位到了其他的弱符号上。

上面我们通过atos还原了正确的日志,并定位到了是弱符号的问题,下面我们结合符号表来看一下日志符号化的原理。

如何处理bugly解析异常的数据

Crash 日志在被符号化之前是不可读的,所谓符号化就是把堆栈信息解释成源码里可读的函数名或方法名,也就是所谓的符号。只有符号化成功后,Crash 日志才能更好的帮助开发者定位问题。日志的解析需要用到dSYM文件,dSYM指的是 Debug Symbols, 也就是调试符号。

DWARF是一种被众多编译器和调试器使用的用于支持源码级别调试的调试文件格式,该格式是一个固定的数据格式,dSYM就是按照DWARF格式保存调试信息的文件,我们常常称为符号表文件。

日志的符号化有很多种方式,例如xcode分析、symbolicatecrashatosdwarfdump等,本质其实就是查找崩溃指令在符号表哪个函数的指令区间。今天我们主要讲一下Bugly解析不准的日志怎么在符号表里查找出正确的堆栈。

1. Bugly还原正确堆栈的原理

以弱符号RCTFBQuickPerformanceLoggerConfigureHooks函数为例,还原一下日志的解析原理。

0 CoreFoundation  0x00000001804f504c 	0x000000018045c000 + 626764
1 Foundation 0x0000000181dae6cc 0x0000000181c7e000 + 1246924
2 UIKit 0x0000000198e5cf30 0x0000000198e57000 + 24368
3 AppName 0xe622388106d79fcc RCTFBQuickPerformanceLoggerConfigureHooks + 16244
4 CoreTelephony 0x0000000198e5e628 0x0000000198e57000 + 30248
5 CoreTelephony 0x0000000108f68fe4 0x0000000198e57000 + 78455260
6 CoreTelephony 0x00000001061ed870 0x0000000198e57000 + 30763624
7 CoreTelephony 0x0000000108f657ec 0x0000000198e57000 + 78440932
8 AppName 0x0000000108f67024 _ZN6tflite19AcquireFlexDelegateEv + 78447132
9 Foundation 0x0000000108f67024 _NSGetUsingKeyValueGetter + 88

  • 上图中我们看到 RCTFBQuickPerformanceLoggerConfigureHooks 这行调用栈的虚拟内存地址存在异常,一般地址地址小于0xFFFFFFFFFF ,示例中明显大很多。我们将高位地址清洗后来保证堆栈正常。调整后,地址为 0x106d79fcc,但当然不是每个Bugly解析错误的日志虚拟内存地址都异常,如果是正常的,则不用改变
  • 查看其他信息,找到基地址App base addr,此处为 0x102604000。如果崩溃发生在其他动态库,那么查找下方对应动态库的地址。
  • 经过第一步和第二步,我们获取到了 0x106d79fcc 和 0x102604000
  • 指令偏移地址为:0x4775FCC = (步骤1)0x106d79fcc - (步骤2)0x102604000
  • 找到此次打包对应的Bugly符号表,并以文本的方式打开
  • 查找0x4775FCC在哪一行符号区间内
  • 最终查找到其在 0x4775fb4 ≤ 0x4775FCC < 0x4775fd0,即3997407行的符号,符号区间遵循前闭后开原则

image20211112210415260.png 通过以上步骤我们找到了RCTFBQuickPerformanceLoggerConfigureHooks函数的实际崩溃位置,并且与我们用atos工具验证后的结果一致,说明这个结果是正确的。

上面我们在符号表里查找到Bugly解析错误的日志的正确堆栈,那如果没有符号表怎么呢,这就涉及到了提取符号表。

2. 如何提取符号表

如果符号表丢失了,但是代码没有改动,那么可以尝试在相同的环境下重新编译和提取符号表,这个步骤有两个前提 1. 代码要与之前保持一致 2. 编译和链接环境都相同,防止由于Debug/Relase对最终包有影响。如果是Debug包,可以用过dsymutil xxx.app/xxx -o xxx.dSYM 来提取符号表

有了以上两个前提就可以通过dSYM文件来提取符号表了,目前我们实现了Bugly轻量符号表的提取,并且文件体积相对于Bugly符号表体积减少到60%。推动ICI(58项目管理平台)按照一定规则输出符号表,目前可以做到根据崩溃日志的UUID直接下载对应的符号表,日志解析和问题排查效率极大提高。

3. 无符号表符号化日志

如果既找不到符号表(dSYM文件或symbol文件),也无法恢复到原先的代码重新生成符号表,那么可以考虑借助无符号表符号化工具 WBBlades 来还原日志:github.com/wuba/WBBlad…

WBBlades是基于Mach-O文件解析的工具集,包括未使用代码检测(支持ObjCSwift)、应用程序大小分析、不需要dSYM文件的日志恢复。

由于方案自身的限制,目前还不能解析除了OC方法以外的崩溃日志,如:block的崩溃、自定义C函数的崩溃。后续需要考虑如何将block的崩溃日志进行符号化。

优化成果与收益

现在我们知道了当Bugly解析不准的时候,我们可以利用Bugly给我们提供的其他信息在符号表里找到正确的答案。通过以上研究,我们通过自研解析工具重新对Bugly的日志进行符号化,通过工具我们在集团内部解决了除RNHOOK函数问题以外还解决了多个遗留已久的历史版本崩溃问题,这里简单的介绍几个比较有代表性的。

1. 拿不到基地址的问题

通过RCTFBQuickPerformanceLoggerConfigureHooks函数的崩溃的介绍,我们可以在Bugly的其他信息里获取到日志的基地址,通过这个地址我们不论是用atos验证还是手动在符号表里查找都可以还原正确的堆栈,但是如果Bugly的其他信息里没有基地址怎么办,我们来看一下下面的这种崩溃日志。

0 CoreFoundation 0x00000001835891b8 0x0000000183459000 + 1245624
5 UIKit 0x000000018963a660 0x000000018942f000 + 2143840
6 AppName 0x00000001075c9904 str_to_integral_8ExpectedIT_NS_14Conversion + 1950052
7 AppName 0x000000010627f94c RCTFBQuickPerformanceLoggerConfigureHooks + 3098344
8 AppName 0x00000001062015a0 RCTFBQuickPerformanceLoggerConfigureHooks + 2581308
9 AppName 0x00000001061fe498 RCTFBQuickPerformanceLoggerConfigureHooks + 2568756
10 AppName 0x00000001061fed38 RCTFBQuickPerformanceLoggerConfigureHooks + 2570964
11 AppName 0x00000001061ed900 RCTFBQuickPerformanceLoggerConfigureHooks + 2500252
12 AppName 0x0000000105231bd8 _ZZGetAppIdTableEvE12arAppIdTable + 57325128
13 libdispatch.dylib 0x00000001824121fc 0x0000000182411000 + 4604
21 UIKit 0x00000001894a4534 UIApplicationMain + 208
22 AppName 0x00000001085b73e8 _ZN15CTXAppidConvert13GetAppIdTableEv + 8521236
23 libdyld.dylib 0x00000001824455b8 0x0000000182441000 + 17848

通过上面的堆栈信息我们看到崩溃的调用栈也停留在了弱符号RCTFBQuickPerformanceLoggerConfigureHooks上,但与我们上面举的例子的不同点是这个崩溃在Bugly上的其他信息一栏里是空的,也就是拿不到基地址,因此我们使用atos命令是不可行的,所以只能在符号表里查找,但是我们要首先要拿到基地址。下面我们来看一下遇到这种情况该怎样拿到基地址。

  1. 首先我们看到 22 AppName 0x00000001085b73e8 str_to_integral_8ExpectedIT_NS_14Conversion + 8521236 这一行信息,熟悉Bugly与crash 日志的同学一定知道, 这一行大概率是main函数,那么我们就在这里找到突破口。
  2. 我们看到main函数的调用栈符号是_ZN15CTXAppidConvert13GetAppIdTableEv,这个函数运行地址是0x00000001085b73e8
  3. 那这个函数的运行起始地址为 0x00000001085b73e8 - 0x8521236 = 0x107D96DD4
  4. 打开符号表,找到_ZN15CTXAppidConvert13GetAppIdTableEv这个符号的偏移地址为 0x7d86dd4
  5. App base addr(基地址): 0x107D96DD4 - 0x7d86dd4 = 0x100010000

这样我们就拿到了这个日志的基地址,然后利用上面的方式在符号表里找到正确的堆栈,因此也就能将这个日志正确的解析了。

2. 百度地图SDK的崩溃问题

除了RNHOOK函数问题,我们还发现有大量的崩溃日志调用栈都指向了百度地图SDK。 image20211116110252571.png 首先我们通过Bugly显示的堆栈信息以为是百度地图SDK的崩溃,这个崩溃在某几个版本中占58同城App总崩溃率的40%左右,是58App内崩溃率最高的一个模块,在更换了新的SDK后崩溃率也并没有下降,而这么高的崩溃率,我们在开发与测试中却从未遇到过。通过Bugly上的跟踪数据我们看到最后的页面记录停留在了金融业务内,而金融业务与百度地图没有任何关系。因此这个崩溃应该与上面描述的一样,解析错误。拿到基地址与运行地址,通过我们自研的工具拿到了正确的堆栈。结果为金融业务使用的一个人脸识别SDK的崩溃,文件名称与Bugly上的跟踪日志也相同。

3. 安居客IM登录问题

我们编写了脚本文件,利用脚本文件定位了一个安居客存在很久的问题。在Bugly上排名比较靠前,崩溃占比很高,Bugly上的堆栈显示异常,因此这个崩溃之前并没有定位到具体原因。脚本协助安居客定位是IMSDK的原因。 image20211116110315205.png

以上是几个比较具有代表性的Bugly解析错误的日志,我们通过研究分析将这些错误的堆栈还原正确并解决了问题。

目前我们支持按版本自动排查出Bugly上前200名崩溃中解析异常的日志,并且可以将异常日志自动符号化成正确的日志。整个过程在符号表已经提前下载并解析好的前提下,只有10秒左右,大大提升了我们日常研发以及解决问题的效率。通过对Bugly上的疑难崩溃的治理,目前为止我们修复了Bugly上70%左右的疑难崩溃,大大降低了58 App的崩溃率。 image20211125150625544.png 除了上述我们研究的Bugly解析异常的日志可以正确解析外,58同城还支持其他异常日志的解析。 例如App内存在段迁移发生崩溃后的日志,段迁移崩溃日志中的库名变成了异常字符、丢失了进程的起始地址,获取到错误的偏移地址,这种情况下我们可以进行自动修正并解析出正确的堆栈信息。

总结与展望

文本首先介绍了我们使用Bugly遇到的RNHOOK函数问题,通过这个问题我们提出Bugly可能解析存在错误的疑问,后续用atos命令以及符号表排查找到了正确的答案,过程中又发现了弱符号的问题。按照这个研究方向我们在集团内做了一系列工具并解决了多个版本的历史遗留问题,大大的降低了58同城iOS App的崩溃率,也提高了日常工作研发效率。

App的性能优化对用户的体验十分重要,而崩溃作为其中最重要的一个环节需要我们持续的钻研与探索。后续我们将持续优化App的性能给用户带来最好的体验。

首发自CSDN:“杀死” App 上的疑难崩溃!


作者:ZYJ
链接:https://juejin.cn/post/7037308047382806565

收起阅读 »

iOS 实现类似探探、陌陌的卡片左滑右滑效果

iOS
本文章分析怎么实现这种卡片效果以及都有哪些功能,基于这些功能是否可以完善,让框架更加灵活,可拓展等。现已封装成通用框架。效果图如下: 代码地址 1、功能分析 不管是探探还是陌陌的点点匹配模块,都是对卡片的左滑右滑进行的操作,那么以陌陌的点点匹配模块分析,所涉...
继续阅读 »

本文章分析怎么实现这种卡片效果以及都有哪些功能,基于这些功能是否可以完善,让框架更加灵活,可拓展等。现已封装成通用框架。效果图如下:


效果图


代码地址


1、功能分析


不管是探探还是陌陌的点点匹配模块,都是对卡片的左滑右滑进行的操作,那么以陌陌的点点匹配模块分析,所涉及的功能有:



  • 卡片复用机制

  • 拖拽卡片时的动画

    • 卡片拖拽左上方、左下方、右上方、右下方都会有相应的旋转动画



  • 喜欢功能

    • 喜欢功能又分为拖拽卡片向右侧滑出、点击下方的喜欢按钮控制卡片滑出。



  • 不喜欢功能

    • 不喜欢功能也是和喜欢功能逻辑一样,只是方向不同。



  • 超级喜欢功能

    • 超级喜欢功能跟喜欢功能一样,只不过是针对的vip用户,普通用户需要充值vip才能使用,vip用户触发超级喜欢,会有一个炫丽的动画特效,并进行其它业务处理



  • 回退功能

    • 回退功能也是针对vip用户设计的,vip用户单次只能回退一张卡片。我们实现回退多张功能,让外界控制是否可以回退功能,更加灵活。



  • 预加载功能

    • 卡片可以无限的滑走,那么数据源获取就得支持加载更多数据。



  • 无数据的处理功能

    • 卡片数据操作完了,就得处理无数据的情况,显示占位图等等。



  • 触发卡片喜欢功能时,需要检测是否允许此次操作

    • 这个功能也是根据业务去做,探探里面,好像对喜欢的操作是有限制的,如果超出了这个限制,再次触发喜欢功能就会提示充值vip同时拖拽的卡片也会恢复原位。






2、功能实现


完成了上面的功能分析之后,接下来就可以一个一个的去实现了。




  • 卡片复用机制


    这里用了4张卡片,最上面的卡片划走之后,会被放在最下面一层,达到复用。




  • 拖拽卡片时的动画


    卡片拖拽左上方、左下方、右上方、右下方都会有相应的旋转动画。我们可以确定用户手势触发的点的位置,根据方位进行设置相关的旋转角度,左右是相反的。




  • 喜欢、不喜欢功能


    1、通过拖拽手势划走卡片
    给每个卡片添加一个拖拽手势,当拖拽卡片的时候,根据拖拽的距离和卡片原始的中心点X值进行判断卡片是向左还是向右,拖拽结束的时候,通过改变卡片的位置并加上动画,达到卡片划走的效果。


    2、通过按钮触发划走卡片
    按钮触发的时候,指定卡片的x位置。然后内部统一走手势结束的方法。




  • 超级喜欢功能
    超级喜欢功能,其实也是喜欢的一种,通过按钮触发喜欢操作,之后加上自己的炫丽动画以及业务逻辑.




  • 回退功能


    卡片回退的实现,将最底下的卡片放到最上面的卡片上面并加上入场动画,同时更新对应的索引数据。
    陌陌vip用户只支持回退一张,我们可以设计支持多张,有多少张不喜欢卡片,默认回退多少张,如果想要实现一张也可以,外界可以控制。相关的方法如下:

    这里回退操作的场景很多,比如:左滑10次,然后右滑4次,回退5次,在一次次的滑动,那么怎么保证卡片是按照正常的顺序显示的呢。这里用了2个数组处理的,第一个数组保存左滑的数据index,第二个数组保存回退的index。具体的思路看下截图:




  • 预加载功能
    每次划走一个卡片,都会代理回调对应的数据源Index供上层更新底部的卡片显示内容, 卡片划走的时候,也会做校验,看看当前的index相对于数据源总数是否小于一个值,这个值我们称为阀值。小于这个阀值会触发加载更多的代理回调。




  • 无数据的处理功能
    每次划走一个卡片,都会更新底部卡片显示内容,如果内部卡片的数据index超出了外界的数据源总数,则将卡片内容隐藏,也会做无数据的检测。




  • 触发卡片喜欢功能时,需要检测是否允许此次操作
    可以在拖拽手势结束的时候,通过代理去询问是否允许滑走,如果不允许则内部更改拖动的距离x值,走复位逻辑。






3、总结


卡片交互的细节很多,很多控制的地方也很多,封装的框架现已支持上面的所有功能, 使用的时候,可以自定义卡片cell实现自己的样式。提供的有示例demo. 欢迎预览。


作者:大大的太阳
链接:https://juejin.cn/post/7036769362652430372
收起阅读 »

cocoapods-binary工作原理及改进

iOS
「这是我参与11月更文挑战的第4天,活动详情查看:2021最后一次更文挑战」 在iOS开发中,如果能够对一些稳定的组件能够二进制化,那么将大大的缩减我们在开发过程中的编译时间。在基于Cocaopods工程,快速实现Swift组件二进制一文中,我们讲述了,借助P...
继续阅读 »

「这是我参与11月更文挑战的第4天,活动详情查看:2021最后一次更文挑战


iOS开发中,如果能够对一些稳定的组件能够二进制化,那么将大大的缩减我们在开发过程中的编译时间。在基于Cocaopods工程,快速实现Swift组件二进制一文中,我们讲述了,借助Pods工程和Shell脚本,一步实现二进制打包,但需要我们手动更改podspec文件,采用这种方式,如果作为依赖项加入到其他工程中,还会出现二进制源码共存的情况,今天介绍一个cocoapods插件 cocoapods-binary,可以实现组件预编译,该工程已经两年多没维护了,随着pods更新,有了一些小bug。基于源码,我对该插件做了几点更改,又可以开心的玩耍了.在了解该插件之前,我们先大概了解下,输入pod install之后发生了什么?


Pod install


如果你想调试cocoapods工程,可以查看之前的文章Ruby和Cocoapods文章合集


一图胜千言,当输入Pod install


Pod install.png



  • 1,首先校验是否有Podfile文件,并解析为Podfile对象

  • 2,准备阶段,安装插件,调用pre_install阶段的插件

  • 3,解析依赖,通过 当前的Podfile文件 和 上一次的Pofile.Lock,Manifest.Lock文件进行比对,确认哪些文件需要更新。

  • 4,下载依赖,更新需要更改的文件,并执行Podfile里面定义的pre_installhook函数。

  • 5,集成阶段,生成新的Pods.xcodeproj工程文件,并执行Podfile里面的post_installhook函数。

  • 6,写入新的Lockfile信息。

  • 7,执行 post_install阶段的插件,并输出安装信息。


cocoapods-binary是以插件的形式,在Pod工程的pre_install阶段进行预编译的,


cocoapods-binary工作流


pre_install 插件


cocoapods-binary中的,通过HookManager来注册插件的执行时机


Pod::HooksManager.register('cocoapods-binary', :pre_install) do |installer_context|

end


主流程


主要流程如下图所示


截屏2021-11-28 下午7.20.16.png



  • 1,必须使用framework的形式,也就是use_frameworks

  • 2,在Pods文件夹下,创建一个名为_Prebuild的文件夹,作为预编译沙箱

  • 3,在当前环境下,读取Podfile文件,并创建Podfile对象。

  • 4,读取Podflie.lock文件,创建 Lockfile对象。

  • 5,创建预编译安装器,沙箱地址为 预编译沙箱

  • 6,对比PodfilePodfile.lock,得到已更改的pod_name

  • 7,使用预编译安装器pod_name的源代码下载到预编译沙箱中,并生成新的Pods.xcodeproj文件。开始编译需要更新的framework

  • 8,回到主工程,继续执行 pod install的后续流程,在这一步修该需要二进制文件的podspec文件。


解析自定义参数


在插件中,有两个自定义的参数 :binaryall_binary!,是通过自定义DSL来实现的,有对这一块不熟悉的,可以参考我的这篇文章Cocoapods之 Podfile文件


Podfile.png
创建Podfile对象时,通过 method swizzling来hook:parse_inhibit_warnings方法,拿到我们在Podfile文件中写入的配置选项。将需要预编译pod,保存到数组中。


old_method = instance_method(:parse_inhibit_warnings)
define_method(:parse_inhibit_warnings) do |name, requirements|
variables = requirements
parse_prebuild_framework(name, requirements)
old_method.bind(self).(name, variables)
end
复制代码

Ruby中,Method Swizzling主要分为三步:



  • 1,获取parse_inhibit_warnings实例方法。

  • 2,定义一个相同名字的方法。

  • 3,调用原来的方法。


对比Lockfile


lockfile.png
在这里 Podfle.lock预编译沙箱Manifest.lock是一样的,通过对比可以一个Hash对象


<Pod::Installer::Analyzer::SpecsState:0x00007f83370c61a8 @added=#<Set: {}>, @deleted=#<Set: {}>, @changed=#<Set: {}>, @unchanged=#<Set: {"Alamofire", "SnapKit"}>>


可以很清楚的知道哪些pod库发生了更改。如果有改动,则就在预编译沙箱进行install


binary_installer.install!


pre_install.png
在这一阶段,主要是在预编译沙箱中拉取framework源码和修改Pods.xcodeproj文件,在 Manifest.lock成功写入预编译沙箱,通过hook run_plugins_post_install_hooks函数,在预编译沙箱中,使用 xcodebuild命令,编译每一个需要更新的pod_target,并将编译好的framework放至GeneratedFrameworks目录下。


回到主工程执行pod install


截屏2021-11-28 下午8.22.44.png


编译完成后,就回到了主工程里面的 Pod install流程中。对:resolve_dependencies方法进行Method Swizzling,对需要更改的pod_target进行修改。通过修改内存中的Pod::Specification对象的vendored_frameworkssource_filesresource_bundlesresources属性,来引用已经编译好的framework


工作流总结


通过对每一个阶段的了解,我们了解了作者的思路是这样的:
1,先将源码和Pods.project安装到预编译沙箱中 。
2,借助于Pods.project工程,使用xcodebuild编译需要预编译的scheme
3,巧妙的利用Method Swizzling,在分析依赖阶段,修改Pod::Specification对象,完成二进制的引用等工作。


现有问题


1,:binary => true 无效


ruby 2.6.8p205 (2021-07-07 revision 67951) [universal.x86_64-darwin21]版本中,使用:binary => true无效,无法编译为framework。在D ebug模式下生效,在发布后就失效了。


def set_prebuild_for_pod(pod_name, should_prebuild)
Pod::UI.puts("pod_name: #{pod_name} prebuild:#{should_prebuild}")
if should_prebuild == true
@prebuild_framework_pod_names ||= []
@prebuild_framework_pod_names.push pod_name
else
@should_not_prebuild_framework_pod_names ||= []
@should_not_prebuild_framework_pod_names.push pod_name
end
end


ruby 2.6.8中,release模式下,执行了两次,参数还不一致,导致 @prebuild_framework_pod_names@should_not_prebuild_framework_pod_names相等,最终需要预编译的数组为[]


pod_name: SnapKit, should_prebuild:true
pod_name: SnapKit, should_prebuild:


2,pod update没有及时更新最新的framework


frameworkA依赖frameworkB,在 Podfile中,只引入了 frameworkA


target xxx do 
pod "frameworkA", :binary => true
end


frameworkB有新版本时,没有更新最新的frameworkB,对于我们自己的组件,我们期望有新版本发布时,能及时更新。


解决办法:
在检测更新的方法中,读取最新的Manifest.lock文件,读取已编译的frameworkplist文件,比较两个版本号是否一致,不一致则重新编译。


cocoapods-binary-bel


为了能充分利用该插件,我根据实际产生的问题,对源码进行了一些修改,增加了 plist版本校验。使用方式和cocoapods-binary一致。
源码github链接cocoapods-binary-bel


安装


sudo gem install cocoapods-binary-bel


流程图


cocoapods-binary的基础上增加了版本校验工程,总的流程图如下所示:


cocoapods-binary-bel.png


一键转源码


1,:binary => false,指定某一个framework为源码


2, 新增 --hsource选项,输入 pod install --hsource,可以将所有的framework转为源码。


去除了依赖分析


在实际运用的工程中,如果 A 依赖 BC, B又依赖D,如果 A需要预编译,那么 BCD都需要重新编译,实际上此时BCD已经有了已经编译好的版本,无需重新编译。在Podfile指明 BCD即可。


pod "A"
pod "B"
pod "C"
pod "D"


增加静态库的resource处理


在 cocoapods中,如果使用 resource_bundle 处理资源文件,会生成一个相对应的target来处理资源文件,如果是动态库,会在动态库Target,添加资源工程依赖,使用 xcodebuild命令制作二进制会将bundle文件编译至xxx.framework目录下,使用 resources同样也会将资源文件编译至xxx.framework目录下。


对于静态库而言,如果是使用resource_bundle,也同样生成一个会生成一个相对应的target来处理资源文件,但对资源文件的拷贝,是由主工程做的,无需静态库工程处理, 如果使用 resources,则需要将资源文件从源码中,拷贝到 xxx.framework下,在主工程编译时,由主工程处理即可。


作者:Bel李玉
链接:https://juejin.cn/post/7035628418972516360

收起阅读 »

iOS 简单封装一个新用户功能模块引导工具类小玩儿意

iOS
废话开篇:新手引导功能就是简单的告诉用户某一模块下能够进行什么样的操作,起到指引用户的作用,那么就简单的实现一下这样的功能模块。一、实现效果展示可以从效果图中看到,对新用户的必要模块都会进行简单的功能解释。二、调用代码添加待引导功能视图到管理类管理类进行展示三...
继续阅读 »

废话开篇:新手引导功能就是简单的告诉用户某一模块下能够进行什么样的操作,起到指引用户的作用,那么就简单的实现一下这样的功能模块。

一、实现效果展示

屏幕录制2021-12-08 上午11.04.57.gif

可以从效果图中看到,对新用户的必要模块都会进行简单的功能解释。

二、调用代码

添加待引导功能视图到管理类

image.png

管理类进行展示

image.png

三、工具类解析

image.png

1、KDSGuideMannager 类

(1)统一管理全局下需要进行 “引导” 的功能区域(UIView)的保存。

(2)控制引导界面的显示与消失。

(3)控制下一个功能区域(UIView)圈定及描述展示。

2、KDSGuideView 类

(1)整体的蒙板视图层。

(2)对当前所选引导功能区域(UIView)进行镂空标注

(3)调整气泡(KDSGuideBubbleView)位置。

3、KDSGuideBubbleView 类

(1)气泡标注视图。

4、KDSGuideModel 类

(1)保存功能区域(UIView)视图及功能描述文字

四、实现代码

1、KDSGuideMannager 类

KDSGuideMannager.h

image.png

KDSGuideMannager.m

image.png

image.png

image.png

2、KDSGuideView 类

KDSGuideView.h

image.png

KDSGuideView.m

image.png

image.png

image.png

image.png

image.png

image.png

image.png

image.png

3、KDSGuideBubbleView 类

KDSGuideBubbleView.h

image.png

KDSGuideBubbleView.m

image.png

image.png

4、KDSGuideModel 类

KDSGuideModel.h

image.png

KDSGuideModel.m

image.png

五、其他效果展示

屏幕录制2021-12-08 下午1.54.31.gif

个人总结,代码拙劣,大神勿笑。

收起阅读 »

SDWebImage从小白到大师蜕变

iOS
简介SDWebImage提供的简洁的获取远程URL图片的API;平时开发中使用最多场景就是列表中的cell中要显示远程图片的需求,在具体的实现中要避免加载图片造成的界面卡顿,列表卡顿等现象的出现;所以需要编码实现如下功能:使用占位图片显示UI界面,异步线程加载...
继续阅读 »

简介

SDWebImage提供的简洁的获取远程URL图片的API;平时开发中使用最多场景就是列表中的cell中要显示远程图片的需求,在具体的实现中要避免加载图片造成的界面卡顿,列表卡顿等现象的出现;所以需要编码实现如下功能:

  • 使用占位图片显示UI界面,异步线程加载图片成功后刷新控件
  • 缓存机制,下载过的图片做内存缓存和磁盘缓存
  • app内存吃紧的状态下移除缓存的内容

SDWebImage的框架结构

SDWebImage的框架结构

SDWebImage的图片下载分类,只要一行代码就可以实现图片异步下载和缓存功能。

功能简介

  1. 一个添加了web图片加载和缓存管理的UIImageView分类
  2. 一个异步图片下载器
  3. 一个异步的内存加磁盘综合存储图片并且自动处理过期图片
  4. 支持动态gif图
    • 4.0 之前的动图效果并不是太好
    • 4.0 以后基于 FLAnimatedImage加载动图
  5. 支持webP格式的图片
  6. 后台图片解压处理
  7. 确保同样的图片url不会下载多次
  8. 确保伪造的图片url不会重复尝试下载
  9. 确保主线程不会阻塞

实现原理

  1. 架构图(UML 类图)

架构图(UML 类图)

  1. 流程图(方法调用顺序图)

1559217862563-364c0d60-3f2a-4db9-b5c5-e81f01cd125e.png

目录结构

  • Downloader
    ○ SDWebImageDownloader
    ○ SDWebImageDownloaderOperation

  • Cache
    ○ SDImageCache

  • Utils
    ○ SDWebImageManager
    ○ SDWebImageDecoder
    ○ SDWebImagePrefetcher

  • Categories
    ○ UIView+WebCacheOperation
    ○ UIImageView+WebCache
    ○ UIImageView+HighlightedWebCache
    ○ UIButton+WebCache
    ○ MKAnnotationView+WebCache
    ○ NSData+ImageContentType
    ○ UIImage+GIF
    ○ UIImage+MultiFormat
    ○ UIImage+WebP

  • Other
    ○ SDWebImageOperation(协议)
    ○ SDWebImageCompat(宏定义、常量、通用函数)

相关类名与功能描述

SDWebImageDownloader:是专门用来下载图片和优化图片加载的,跟缓存没有关系

SDWebImageDownloaderOperation:继承于 NSOperation,用来处理下载任务的

SDImageCache:用来处理内存缓存和磁盘缓存(可选)的,其中磁盘缓存是异步进行的,因此不会阻塞主线程

SDWebImageManager:作为 UIImageView+WebCache 背后的默默付出者,主要功能是将图片下载(SDWebImageDownloader)和图片缓存(SDImageCache)两个独立的功能组合起来

SDWebImageDecoder:图片解码器,用于图片下载完成后进行解码

SDWebImagePrefetcher:预下载图片,方便后续使用,图片下载的优先级低,其内部由

SDWebImageManager :来处理图片下载和缓存

UIView+WebCacheOperation:用来记录图片加载的 operation,方便需要时取消和移除图片加载的 operation

UIImageView+WebCache:集成 SDWebImageManager 的图片下载和缓存功能到 UIImageView 的方法中,方便调用方的简单使用

UIImageView+HighlightedWebCache:跟 UIImageView+WebCache 类似,也是包装了 SDWebImageManager,只不过是用于加载 highlighted 状态的图片

UIButton+WebCache:跟 UIImageView+WebCache 类似,集成 SDWebImageManager 的图片下载和缓存功能到 UIButton 的方法中,方便调用方的简单使用

MKAnnotationView+WebCache:跟 UIImageView+WebCache 类似

NSData+ImageContentType:用于获取图片数据的格式(JPEG、PNG等)

UIImage+GIF:用于加载 GIF 动图

UIImage+MultiFormat:根据不同格式的二进制数据转成 UIImage 对象

UIImage+WebP用于解码并加载 WebP 图片

工作流程

工作流程

  • 入口 setImageWithURL:placeholderImage:options: 会先把 placeholderImage 显示,然后 SDWebImageManager 根据 URL 开始处理图片。

  • 进入 SDWebImageManager-downloadWithURL:delegate:options:userInfo:交给 SDImageCache 从缓存查找图片是否已经下载 queryDiskCacheForKey:delegate:userInfo:。

  • 先从内存图片缓存查找是否有图片,如果内存中已经有图片缓存,SDImageCacheDelegate 回调 imageCache:didFindImage:forKey:userInfo: 到 SDWebImageManager。

  • SDWebImageManagerDelegate 回调 webImageManager:didFinishWithImage: 到 UIImageView+WebCache 等前端展示图片。

  • 如果内存缓存中没有,生成 NSInvocationOperation 添加到队列开始从硬盘查找图片是否已经缓存。

  • 根据 URLKey 在硬盘缓存目录下尝试读取图片文件。这一步是在 NSOperation 进行的操作,所以回主线程进行结果回调 notifyDelegate:。

  • 如果从硬盘读取到了图片,将图片添加到内存缓存中(如果空闲内存过小,会先清空内存缓存)。SDImageCacheDelegate 回调 imageCache:didFindImage:forKey:userInfo:进而回调展示图片。

  • 如果从硬盘缓存目录读取不到图片,说明所有缓存都不存在该图片,需要下载图片,回调 imageCache:didNotFindImageForKey:userInfo:。

  • 共享或重新生成一个下载器 SDWebImageDownloader 开始下载图片。

  • 图片下载由 NSURLConnection(3.8.0之后使用了NSURLSession),实现相关 delegate 来判断图片下载中、下载完成和下载失败。

  • connection:didReceiveData: 中利用 ImageIO 做了按图片下载进度加载效果。connectionDidFinishLoading: 数据下载完成后交给 SDWebImageDecoder 做图片解码处理。

  • 图片解码处理在一个 NSOperationQueue 完成,不会拖慢主线程 UI。如果有需要对下载的图片进行二次处理,最好也在这里完成,效率会好很多。

  • 在主线程 notifyDelegateOnMainThreadWithInfo: 宣告解码完成,imageDecoder:didFinishDecodingImage:userInfo: 回调给 SDWebImageDownloader。

  • imageDownloader:didFinishWithImage: 回调给 SDWebImageManager 告知图片下载完成。

  • 通知所有的 downloadDelegates 下载完成,回调给需要的地方展示图片。

  • 将图片保存到 SDImageCache 中,内存缓存和硬盘缓存同时保存。写文件到硬盘也在以单独 NSInvocationOperation 完成,避免拖慢主线程。

  • SDImageCache 在初始化的时候会注册一些消息通知,在内存警告或退到后台的时候清理内存图片缓存,应用结束的时候清理过期图片。

  • SDWebImagePrefetcher 可以预先下载图片,方便后续使用。

常见面试题

  1. 图片文件缓存的时间有多长:1周

_maxCacheAge = kDefaultCacheMaxCacheAge

  1. SDWebImage 的内存缓存是用什么实现的?

NSCache

  1. SDWebImage 的最大并发数是多少?

maxConcurrentDownloads = 6

  • 是程序固定死了,可以通过属性进行调整!
  1. SDWebImage 支持动图吗?GIF
1. #import <ImageIO/ImageIO.h>
2. [UIImage animatedImageWithImages:images duration:duration];
复制代码
  1. SDWebImage是如何区分不同格式的图像的

    • 根据图像数据第一个字节来判断的!
    • PNG:压缩比没有JPG高,但是无损压缩,解压缩性能高,苹果推荐的图像格式!
    • JPG:压缩比最高的一种图片格式,有损压缩!最多使用的场景,照相机!解压缩的性能不好!
    • GIF:序列桢动图,特点:只支持256种颜色!最流行的时候在1998~1999,有专利的!

6.SDWebImage 缓存图片的名称是怎么确定的!

  • md5

  • 如果单纯使用 文件名保存,重名的几率很高!

  • 使用 MD5 的散列函数!对完整的 URL 进行 md5,结果是一个 32 个字符长度的字符串!

  1. SDWebImage 的内存警告是如何处理的!
    • 利用通知中心观察
    • - UIApplicationDidReceiveMemoryWarningNotification 接收到内存警告的通知
    • 执行 clearMemory 方法,清理内存缓存!
    • - UIApplicationWillTerminateNotification 接收到应用程序将要终止通知
    • 执行 cleanDisk 方法,清理磁盘缓存!
    • - UIApplicationDidEnterBackgroundNotification 接收到应用程序进入后台通知
    • 执行 backgroundCleanDisk 方法,后台清理磁盘!
    • 通过以上通知监听,能够保证缓存文件的大小始终在控制范围之内!
    • clearDisk 清空磁盘缓存,将所有缓存目录中的文件,全部删除!

实际工作,将缓存目录直接删除,再次创建一个同名空目录!

青山不改,绿水长流,后会有期,感谢每一位佳人的支持!

收起阅读 »

闲鱼正在悄悄放弃 Flutter 吗?

iOS
闲鱼技术阿里巴巴集团采访嘉宾 | 于佳(宗心)编辑 | Tina闲鱼在 2017 年引入 Flutter,当时的 Flutter 还远未成熟,行业内也没有把 Flutter 放入已有工程体系进行开发的先例。之后这支不到 15 人的闲鱼团队从工程架构、混合栈调用...
继续阅读 »

闲鱼技术lv-4阿里巴巴集团

采访嘉宾 | 于佳(宗心)

编辑 | Tina

闲鱼在 2017 年引入 Flutter,当时的 Flutter 还远未成熟,行业内也没有把 Flutter 放入已有工程体系进行开发的先例。

之后这支不到 15 人的闲鱼团队从工程架构、混合栈调用、打包构建、协同模式上都做了一些创新,保证了 Flutter 能融入到闲鱼已有的客户端工程体系内。在 2017 年到 2019 年期间,闲鱼也不断的修正 Bug 提高 Flutter 的稳定性并同步给 Google,并在实践中沉淀出一套自己的混合技术方案,开源了 Flutter Boost 引擎。

2019 年,闲鱼开始大规模落地,推进 Flutter 在闲鱼的应用。2020 年,闲鱼线上的主链路几乎已经完全拥抱 Flutter。这两年,Flutter 也逐渐在其他企业里落地,但同时也不断有质疑的声音发出。甚至有传言表示“闲鱼的新业务已经放弃 Flutter”、“相信闲鱼遇到了很大的难题”......

那么,作为 Flutter 先驱和探路者,闲鱼在过去几年的摸索过程中是否有走弯路?闲鱼现在到底面临着什么样的挑战?是否会放弃 Flutter?新业务选择了什么技术?对应的技术选型原则是什么?针对这些疑问,闲鱼技术团队客户端负责人于佳(宗心)逐一给了我们解答。

国内第一个引进 Flutter 的团队

InfoQ:闲鱼当时引进 Flutter 时主要是为了解决什么问题?

于佳(宗心):闲鱼在 17 年调研的时候,客户端团队只有不到 15 人,而闲鱼的业务场景可以称得上是一个 “小淘宝”,相对比较复杂。这种场景下我们首先需要解决的是多端人力共享的问题。多端人力带来的好处不只是可以一人开发双端,也代表着更好的研发资源调配灵活性(这意味着团队的 iOS:Android 的比例不再需要 1:1,而市面上 Android 的工程师基数远大于 iOS)。

另外我们希望这个技术是贴合移动端研发技术栈的,而非前端技术栈,本身对于 RN 和 Weex 来说,工具链和研发习惯还是有比较大的差异的。最后我们希望这个技术的体验可以做到接近原生,AOT 下的 Flutter 基本满足我们当时的要求,在实际测试过程中,同样未深度优化的详情页面,Flutter 在低端机的表现比 Native 更好。因此当时基于这三个条件选择了 Flutter。

2018 年的尝试投入过程中,整个基建和探索带来了一定的成本。2019 年,团队开始正式大量使用 Flutter 进行研发,目前整个团队 70% 的 commit 来自 Dart,可以说基本完成了我们当初的期望。在实际的研发过程中,基本可以完成一个需求一个客户端投入的目标。

InfoQ:很多人质疑 Dart 语言,认为这个语言独特小众,还存在比如说多层嵌套的问题,您们怎么看待新语言的应用?

于佳(宗心):语言是我们选择技术方案的其中一个因素,但是相对比较弱的因素。

我们会从几个角度去看:

  • 语言的背景,从我们的角度来看 Dart 是大厂研发的,也有比较久的历史。

  • 语言的学习成本,从语法糖和学习曲线上来看,Dart 成本都比较低,首先 Android 同学的上手率很快。另外熟悉 swift 的 iOS 同学,上手也很快。现代语言的特性有很多是相通的。这部分是它的优势。

  • 语言带来的其他优势,如编译产物支持 AOT 和 JIT,比较灵活。AOT 有明显的性能优势。

  • 语言的未来的趋势。Dart 在 2020 年第四季度 Github Pull Request 的排名已经到了全网第 13 位,超过了 Kotlin(15 位),Swift(16 位),Objective-C(18 位)。作为移动技术领域的新语言成长性还是非常不错的。

对于像多层嵌套的问题,可以通过进一步抽象一些控件类或方法解决,并不是特别大的问题。

InfoQ:闲鱼引入 Flutter 之后做了哪些关键创新?在使用 Flutter 上有哪些收益?

于佳(宗心):闲鱼在这部分创新非常多,并在内部申请了非常多专利。

  • 我们的开源项目 Flutter Boost 彻底改变了 Flutter 官方的一些 RoadMap。目前 Add2ExistApp 是行业最主流的研发方式。混合开发一方面帮助了业务更平滑的迁移到了新的技术栈,另一方面可以更好的利用已有的 Native 能力,大幅减少了重复开发的工作。

  • 针对音视频的外接纹理方案,也是目前行业大厂常见的解决方案,在外接纹理方案下,Native 和 Flutter 侧的缓存管理得到了统一,在性能上也有一定的提升。

  • Flutter APM,基于 Flutter 技术栈的性能稳定性数据采集和加工方案,目前在集团内部也是跟多个 BU 一起共建,为大的 AliFlutter 组织提供服务。

  • Flutter 相关的动态模版方案,Flutter DX,兼容集团的已有的 Native 模版,保证了业务的平滑迁移,并为 Flutter 提供了部分业务动态性。

  • 其他还有很多,包括内部的高性能长列表容器 PowerScrollView,动画框架 Fish-Lottie,游戏引擎 Candy,我们现在还有一些新的方向在沉淀,在基于 Flutter 的研发流程和研发工具上也有投入,未来大家如果感兴趣可以去 InfoQ 组织的行业大会与我们交流。

闲鱼有想过放弃 Flutter 吗?

InfoQ:最近一两年,您们在 Flutter 开发上,遇到的最大挑战是什么?跟最初使用 Flutter 时的挑战一样吗?

于佳(宗心):早先几年闲鱼作为整个行业的先驱,主要的挑战是整个技术生态太差,都需要自己做。另外就是前期引擎的稳定性有比较大的问题。

最近几年随着整个技术的深度使用,以及闲鱼这两年业务快速发展背后,越来越多的体验问题被大家提及,因此我们从去年开始进行了整个产品的大改版,同时客户端的目标就是全面优化,打造更好的用户端产品体验。

因此在生态逐渐完善后,我们的挑战是,怎么通过 Flutter 来实现更加精细化的用户体验。去年,这部分确实花了我们比较多的精力。基于这个命题,我们在内存和卡顿上内部也开发了较多的基于 Flutter 的检测工具,在内存优化和卡顿优化上也有一些比较具体的方法,但不得不说,所有的细节优化都是比较耗人力的,不管是 Native 还是 Flutter 都要投入相当的精力,所以我们目前也面向全行业进行客户端的招聘,希望有志在 Flutter 领域进行探索的同学联系我。

InfoQ:在混合研发体系下,闲鱼还进行了引擎定制,那么官方提供的方案主要问题是什么?对于一般小企业来说,混合开发复杂度会不会太高?

于佳(宗心):闲鱼在前期有不少修改引擎的动作,我针对当时有一些 自己的反思,一方面是确实因为 Flutter 不太完善,另一方面在 18 年左右,我们自己引擎的理解也不够深刻,很多时候可以通过更上层的方案解决,这也间接导致了我们的很多引擎定制修改难以合入主干。

所以这部分我想说的是,目前官方的方案可以解决 90% 的问题,如果一定要说定制,目前在性能侧还是有一些问题的。比如闲鱼目前首页还是 native 没有使用 Flutter,就是因为替换以后启动加载体验不佳,另外在长列表侧大家一直诟病的卡顿问题,我们有尝试通过上层框架解决了一部分,接下来可能还需要底层引擎帮忙优化。另外一些包括双端字体不一致的问题,还有输入框体验不一致的问题,都需要官方进行长期的优化。

目前我们主要还是希望跟随主干分支,尽量不修改 Flutter 的代码,闲鱼团队会储备一些引擎侧的专家,同时也会依靠集团 AliFlutter 的生态做事情。在整个 AliFlutter 的组织里不同的 BU 擅长的也不同,如 UC 同学更擅长引擎定制,闲鱼团队有大量的上层应用框架,淘宝团队提供基于构建相关的基础设施。这样在大型公司中通过内部开源社区的方式就可以解决大部分的问题,放心开发了。

对于中小企业来说,要明确下大家面临的场景,如果前期快速迭代跑起来,对细节问题可以有一部分妥协,选择 Flutter 是一个比较明确的路径。今天大家所处的环境比闲鱼当年所处的环境要完善的多。推荐使用 Flutter Boost 进行混合开发,在部分场景下遇到问题无法快速响应时,也可以通过混合工程使用 native 进行兜底。复杂度方面,单纯引入混合栈能力,整体复杂度一般。

InfoQ:有传言,闲鱼有新业务没采用 Flutter,这给很多人造成了闲鱼放弃 Flutter 的观念,那么您们在新业务的技术选型上,考虑了哪些因素?

于佳(宗心):作为技术决策者,是应该避免自己被某一个技术绑架而在落地过程中产生谬误的。Flutter 和其他技术一样,最终是为了帮助团队实现业务价值,同时它也只是移动端的一种技术,捧杀和谩骂都是不合适的。这也是我特别不想在公众面前回应这个事情的原因,因为 技术本身要看适用场景。

从目前闲鱼的人员规模和业务规模来看。对于架构设计,我的理念是尽量追求一致性和架构的简洁。

整个客户端组织未来从语言的方向来看是 Dart First,尽量减少双端的研发投入。而对其他容器的选择,主要以 H5 为主,在未来的路径上尽量减少其他容器的接入,让前端开发也回归到标准路线来。

这里有两个好处:

  1. 组织成本最低,组织成本包括了同学们的学习成本、协同成本等等,多技术栈和容器多会带来额外的成本,这是我不愿意看到的。

  2. 架构的一致性对研发效能和质量都有帮助。举个例子,随着业务复杂性加大,多容器带来的内存飙升和包大小的问题是非常致命的,而且几乎是无解的,这就需要架构师作出决策,干掉什么留下什么。回到研发效能上,配套的工具,流程一定是围绕一类容器和语言来扩展的,如果方案特别多,每个方向都需要做额外的配套设施,成本收益很低,研发的幸福感也很低。

从这个设计的角度出发,我们会有几个明确的选择

  • 在默认场景下使用 Flutter 作为首选的方案;

  • 在投放活动、前台导购、非常不确定的新业务、以及管理后台等使用 H5 作为首选实现方案;

  • 在极少场景下,比如已有完整的 SDK 附带 UI 的支持如直播,以及未来中台的拍摄功能 SDK 也是自带 UI 的部分,如要切换,Native 成本最低,选择 Native。另外目前 Flutter 在首页加载还有一定的性能问题,因此还在使用 Native。从长远发展来看,未来到一定程度可能随改版直接改为 Flutter。

关于未来发展

InfoQ:使用 Flutter 多年后,现在回过头去看,您认为哪些公司哪些场景适合 Flutter?

于佳(宗心):目前看起来有几个典型场景比较适合:

  • 中台战略下的小前台产品,从大公司的组织里看阿里、头条、美团都有相对完善的 Flutter 组织或内部技术社区可以提供一些基础服务,保证了基于 Flutter 基础设施在前期投入过程中的成本均摊,在未来落地过程中,业务团队可以更加专注于业务研发,而更少的担心过程中填坑的成本。

  • 中小型企业的初创 App,在人力成本资源都不够的情况下,希望先跑通流程上线验证的团队,可以尝试使用 Flutter 进行研发,在我自己实际的面试和行业交流过程中,这一类情况也比较典型。这种方式可以避免前期成本过度投入,在人员调配上也更加灵活。

  • 另外这个观点还没有验证,但是逻辑上应该可行。未来面向企业内部流程工具,政府部门的部分工具属性较强的 App,可以尝试使用 Flutter。因为目前我了解的情况来看,在企业这边的应用来看,整体 ToB(美团商家端)和 ToD(比如饿了么骑手端)的场景的 App 特别多。横向比较来看,场景比较类似,也就是说更多中长尾应用有可能是 Flutter 技术的主要场景。

InfoQ:您认为未来 Flutter 急需改善的地方是什么?

于佳(宗心):从 Flutter 2.0 发布后我跟一些一线开发者交流的感受来看,Flutter 还是需要推进跨端性能和细节体验的优化。去年一年在大的战略方向上(跨终端),Flutter 做的不错,在 PC 和 Web 侧都有建树,跟车企以及操作系统厂商合作都有一定进展。但回归到产品体验和开发者体验上,还有不少路要走,很多时候对于一个严苛的业务方来说,小到字体和控件的体验都会成为最后不选择这门技术的原因。这部分希望整个开源社区在新的一年能有一些进步。我们 AliFlutter 组织内部,以 UC 内核团队为首的同学们,在这方面就有非常多的沉淀以及 PR,在内部引擎制定上有很多体验的提升。未来在 AliFlutter 组织内,我们也会除了完善整个公司的基建外,进一步关注细节体验,沉淀一些最佳实践给到其他的开发同学。大家会在2个月内看到我们最新出版的书籍,欢迎交流。

InfoQ:Flutter2.0 来了,那么 Flutter 会成为主流选择吗?

于佳(宗心):可以讲一下我对 Flutter 未来的判断。一方面在未来操作系统有可能走向分裂,多终端的场景下,Flutter 会有比较不错的发展,跨平台本身的对企业来说在成本侧是有很大的诉求的,尤其是互联网公司。但是从历史的经验来看,Flutter 只是渲染引擎,即使今天的游戏开发,在游戏引擎和配套工具完善的情况下,有部分的功能模块(比如社区 / 直播的功能)依然还是混合的框架,所以混合开发最后一定是一直存在的。能不能成为未来整个移动研发的主流这件事情上看,我无法给出答案,但可以肯定的是,在生态更加完善后,会在一定的历史阶段成为客户端研发的另一种常见的技术选择。

嘉宾介绍:

于佳,花名 宗心,闲鱼技术团队客户端负责人。2012 年应届毕业加入阿里巴巴,经历集团无线化转型的重要时期,参与过集团多款重量级 App 以及移动中间件的设计与开发,多年客户端老兵。2014 年参与了手机淘宝的 iOS 客户端的架构升级,该架构首次完成了对百人团队并行开发的支持,同年主导了手机天猫客户端基础架构以及交易链路向手淘架构的归一,为手机淘宝作为未来集团无线中台奠定了坚实的基础。2015 年加入闲鱼客户端团队负责端架构和团队建设,工作期间完成了基于 Flutter 混合架构的闲鱼客户端的整体架构设计,在工程体系上完善了针对 Flutter 的持续集成以及高可用体系的支撑,同时推进了闲鱼主链路业务的 Flutter 化。未来将持续关注终端技术的演变及发展趋势。

收起阅读 »

一篇完整的Swift属性参考,轻松让你提高一个档次!

iOS
属性 提供了更多关于声明和类型的信息。在 Swift 中有两种类型的属性,一种用于声明,一种用于类型。例如,required 属性-当用于类的指定或者便利初始化声明时-指明每个子类都必须实现它的初始化函数。noreturn 属性-当用于函数或者方法的类型时-指...
继续阅读 »

属性 提供了更多关于声明和类型的信息。在 Swift 中有两种类型的属性,一种用于声明,一种用于类型。例如,

required 属性-当用于类的指定或者便利初始化声明时-指明每个子类都必须实现它的初始化函数。noreturn 属性

-当用于函数或者方法的类型时-指明函数或者方法无需返回值。

咋们一起好好看,好好学

你可以用字符 @ 加上属性名和属性参数来指定一个属性:

@属性名
@属性名(属性参数)

含有参数的声明属性可以为属性指定更多的信息,可以用于特殊的声明。这些属性参数 被包含在圆括号里,参数的格式由属性决定。

声明属性

声明属性只能用于声明,当然,你也可以使用 noreturn 属性作为函数或者方法的类型。

assignment

此属性可用于修饰重载复合赋值运算符的函数。这个重载复合赋值运算符的函数必须用 inout 来标记初始输入参数。assignment属性示例参见复合赋值运算符

class_protocol

此属性可用于定义类类型协议。

如果你使用 objc 属性的协议, 那么这个协议就隐式含有 class_protocol 属性,你无需显式标记 class_protocol 属性。

exported

此属性可用于内部声明,可以将当前模块的内部模块、子模块或者声明暴露给外部其他模块。如果另一个模块引用了当前模块,那么这个模块就可以访问当前模块中暴露出来的部分。

final

此属性可用于修饰类或者类的属性、方法或成员下标运算符。用于一个类的时候,表明这个类是不能被继承的。用于类的属性、方法或成员下标运算符的时候,表明这个类的这些成员函数不能在任何子类中重写。

lazy

此属性可用于修饰类或者结构体中的存储变量属性,表明这个属性在第一次被访问时,其初始值最多只能被计算和存储一次。lazy 属性示例参见惰性存储属性

noreturn

此属性用于函数或者方法的声明,表明其函数或者方法相应的类型T是@noreturn T。当一个函数或者方法无需返回其调用者时,你可以用这个属性来修饰其类型。

你可以重写没有标示noreturn属性的函数或者方法。也就是说,你不能够重写有noreturn属性的函数或者方法。当你实现此类型的协议方法时,也有相似的规则。

NSCopying

此属性可用于修饰类中的存储变量属性。被修饰的这个属性的赋值函数是由这个属性值的拷贝组成-由copyWithZone方法返回-而不是这个属性本身的值。此属性类型必须符合NSCopying协议。
NSCopying属性类似于Objective-C中的copy属性。

NSManaged

用于修饰类中的存储变量属性,此类继承于NSManagedObject,表明这个属性的存储和实现是由Core Data基于相关的实体描述实时动态提供的。

objc

此属性可用于能用Objective-C表示的任何声明中-例如,非嵌套的类、协议、类和协议的属性和方法(包括取值函数和赋值函数)、初始化函数、析构函数以及下标运算符。objc属性告诉编译器此声明在Objective-C代码中可用。

如果你使用objc属性修饰类或者协议,它会显式的应用于这个类或者协议中的所有成员。当一个类继承于标注objc属性的另一类时,编译器会显式的为这个类添加objc属性。标注objc属性的协议不能够继承于不含有objc属性的协议。

objc属性可以接受由标识符组成的单个属性参数。当你希望暴露给Objective-C的部分是一个不同的名字时,你可以使用objc属性。你可以使用这个参数去命名类、协议、方法、取值函数、赋值函数以及初始化函数。下面的示例就是ExampleClass的enabled属性的取值函数,它暴露给Objective-C代码的是isEnabled,而不是这个属性的原名。

1.  @objc
2. class ExampleClass {
3. var enabled: Bool {
4. @objc(isEnabled) get {
5. // Return the appropriate value
6. }
7. }
8. }

optional

此属性可用于协议的属性、方法或者成员下标运算符,用来表明实现那些成员函数时,此类型的不是必需实现的。

optional属性只能用于标注objc属性的协议。因此,包含可选成员的协议只有类类型适用。更多的关于怎样使用optional属性,以及怎样访问可选协议成员的指导-例如,当你不确定它们是否实现了此类型时-参见可选协议需求

required

此属性用于类的指定或者便利初始化函数时,表明这个类的每个子类都必须实现这个初始化函数。

需求的指定初始化函数必须被显式的包含。当子类直接实现所有超类的指定初始化函数时(或者子类使用便利初始化函数重写了指定初始化函数时),需求的便利初始化函数必须被显式的包含或者继承。

使用Interface Builder声明属性

Interface Builder属性就是使用Interface Builder声明属性以与Xcode同步。Swift提供了如下几种Interface Builder属性:IBAction,IBdesignable,IBInspectable以及IBOutlet。这些属性理论上与Objective-C中相应的属性一样。

IBOutlet和IBInspectable属性可用于类的属性声明,IBAction属性可用于类的方法声明,IBDesignable属性可用于类的声明。

类型属性

类型属性可以只用于类型。当然noreturn属性也可以用于函数或者方法的声明。

auto_closure

此属性用于延迟表达式的赋值计算,将表达式自动封装成一个无参数的闭包。此属性还可作为函数或者方法的类型,此类型无参数并且其返回的是表达式类型。auto_closure属性示例参见函数类型

noreturn

此属性用于函数或者方法时表明此函数或者方法无返回值。你也可以用此属性标记函数或者方法的声明,以表明其函数或者方法相应的类型T是@noreturn T。

属性语法
attribute → @­attribute-name attribute-argument-clause opt
attribute-name → identifier
attribute-argument-clause → (balanced-tokens­ opt)
attributes → attribute­ attributes­ opt­
balanced-tokens → balanced-token ­balanced-tokens­ opt­
balanced-token → (­balanced-tokens­ opt­)­
balanced-token → [balanced-tokens­ opt­]­
balanced-token → {balanced-tokens­ opt­­}­
balanced-token → 任意标识符,关键字,常量,或运算符
balanced-token → 任意的标点符号 (­, )­, [­, ]­, {­, 或 }­

由于文章篇幅有限,只能点到即止地介绍当前一些工作成果和思考,各个 Swift 还有一些新的方向在探索,如果你对 iOS 底层原理、架构设计、构建系统、如何面试有兴趣了解,你也可以关注我及时获取最新资料以及面试相关资料。如果你有什么意见和建议欢迎给我留言!

写的不好的地方欢迎大家指出,希望大家多留言讨论,让我们共同进步!

喜欢iOS的小伙伴可以关注我,一起学习交流!!!

链接:juejin.cn/post/698169…


作者:在做开发的信哥
链接:https://juejin.cn/post/6988459235797368862

收起阅读 »

啥?iOS长列表还可以这么写

iOS
一般说,iOS界面的一些长列表,比如首页,活动页,长的会比较长,那么写起来总感觉没有那么优雅,那么如何才能做到优雅呢? 我在实践工作利用swift枚举的关联值和自定义组模型方法来实现了 下面是gif图效果 可以看到,有些组是杂乱无章的排列着,而且运营那边...
继续阅读 »

一般说,iOS界面的一些长列表,比如首页,活动页,长的会比较长,那么写起来总感觉没有那么优雅,那么如何才能做到优雅呢?
我在实践工作利用swift枚举的关联值和自定义组模型方法来实现了



  • 下面是gif图效果



可以看到,有些组是杂乱无章的排列着,而且运营那边要求,他们可以在后台自定义这些组的顺序
这可怎么办!🥺
下面看我的实现方式


定义一个组模型枚举



  • 包含可能的定义,每个枚举关联当前组需要显示的数据模型,有可能是一个对象数组,也有可能是一个对象


/// 新版首页组cell的类型
enum OriginGroupCellType {
case marquee(list: [MarqueeModel]) // 跑马灯
case beltAndRoad(list: [GlobalAdModel]) // 一带一路广告位
case shoppingCarnival(list: [GlobalAdModel]) // 购物狂欢节
case walletCard(smallWelfare: WelfareSmallResutlModel) // 钱包卡片
case wallet(list: [HomeNavigationModel]) // 钱包cell
case otc(list: [GlobalAdModel]) // OTC
case hxPrefecture(list: [GlobalAdModel]) // HX商品专区
case middleNav(list: [HomeNavigationModel]) // 中部导航
case bottomNav(list: [HomeNavigationModel]) // 底部导航
case broadcast(topSale: HomeNavigationModel, hot: OriginBroadcastModel, choiceness: OriginBroadcastModel) // 直播cell
case middleAd(list: [GlobalAdModel]) // 中间广告cell
case localService(list: [LocalServiceModel]) // 本地服务cell
case bottomFloat(headerList: [OriginBottomFloatHeaderModel]) // 底部悬停cell
}


  • 考虑到要下拉刷新等问题,可以这些枚举都得遵守Equatable协议


  extension OriginGroupCellType: Equatable {
public static func == (lhs: OriginGroupCellType, rhs: OriginGroupCellType) -> Bool {
switch (lhs, rhs) {
case (.marquee, .marquee): return true
case (.beltAndRoad, .beltAndRoad): return true
case (.shoppingCarnival, .shoppingCarnival): return true
case (.walletCard, .walletCard): return true
case (.wallet, .wallet): return true
case (.otc, .otc): return true
case (.hxPrefecture, .hxPrefecture): return true
case (.middleNav, .middleNav): return true
case (.bottomNav, .bottomNav): return true
case (.broadcast, .broadcast): return true
case (.middleAd, .middleAd): return true
case (.localService, .localService): return true
case (.bottomFloat, .bottomFloat): return true
default:
return false
}
}
}

接下来就是组模型的定义



  • 同时我抽取一个协议GroupProvider,方便复用


protocol GroupProvider {
/// 占位
associatedtype GroupModel where GroupModel: Equatable

/// 是否需要往组模型列表中添加当前组模型
func isNeedAppend(with current: GroupModel, listMs: [GroupModel]) -> Bool
/// 获取当前组模型在组模型列表的下标
func index(with current: GroupModel, listMs: [GroupModel]) -> Int
}

extension GroupProvider {
func isNeedAppend(with current: GroupModel, listMs: [GroupModel]) -> Bool {
return !listMs.contains(current)
}

func index(with current: GroupModel, listMs: [GroupModel]) -> Int {
return listMs.firstIndex(of: current) ?? 0
}
}




  • OriginGroupModel,同样也遵守Equatable协议,防止重复添加


func addTo(listMs: inout [OriginGroupModel]) 



  • 这个方法是方便于下拉刷新时,替换最新数据所用


public struct OriginGroupModel: GroupProvider {
typealias GroupModel = OriginGroupModel

/// 组模型的类型
var cellType: OriginGroupCellType
/// 排序
var sortIndex: Int

/// 把groupModel添加或替换到listMs中
func addTo(listMs: inout [OriginGroupModel]) {
if isNeedAppend(with: self, listMs: listMs) {
listMs.append(self)
} else {
let index = self.index(with: self, listMs: listMs)
listMs[index] = self
}
}
}

extension OriginGroupModel: Equatable {
public static func == (lhs: OriginGroupModel, rhs: OriginGroupModel) -> Bool {
return lhs.cellType == rhs.cellType
}
}


  • 考虑要自定义顺序,所以需要定义一个排序的实体


// MARK: - 新版首页组模型的排序规则模型
struct OriginGroupSortModel {
/// 搜索历史的排序
var marqueeIndex: Int
var beltAndRoadIndex: Int
var shoppingCarnivalIndex: Int
var walletCardIndex: Int
var walletIndex: Int
var otcIndex: Int
var hxPrefectureIndex: Int
var middleNavIndex: Int
var bottomNavIndex: Int
var broadcastIndex: Int
var middleAdIndex: Int
var localServiceIndex: Int
var bottomFloatIndex: Int

static var defaultSort: OriginGroupSortModel {
return OriginGroupSortModel(
marqueeIndex: 0,
beltAndRoadIndex: 1,
shoppingCarnivalIndex: 2,
walletCardIndex: 3,
walletIndex: 4,
otcIndex: 5,
hxPrefectureIndex: 6,
middleNavIndex: 7,
bottomNavIndex: 8,
broadcastIndex: 9,
middleAdIndex: 10,
localServiceIndex: 11,
bottomFloatIndex: 99)
}
}


控制器里定义一个 组模型数组



  • 这里有关键代码是


listMs.sort(by: { return $0.sortIndex < $1.sortIndex }) 



  • 所有的数据加载完毕后,会根据我们的自定义排序规则去排序


    /// 组模型数据
public var listMs: [OriginGroupModel] = [] {
didSet {
listMs.sort(by: {
return $0.sortIndex < $1.sortIndex
})
collectionView.reloadData()
}
}

/// 组模型排序规则(可以由后台配置返回,在这里我们先给一个默认值)
/// 需要做一个请求依赖,先请求排序接口,再请求各组的数据
public lazy var sortModel: OriginGroupSortModel = OriginGroupSortModel.defaultSort


网络请求代码


func loadData(_ update: Bool = false, _ isUHead: Bool = false) {
// 定义队列组
let queue = DispatchQueue.init(label: "getOriginData")
let group = DispatchGroup()

// MARK: - 文字跑马灯
group.enter()
queue.async(group: group, execute: {
HomeNetworkService.shared.getMarqueeList { [weak self] (state, message, data) in
guard let `self` = self else { return }
self.collectionView.uHead.endRefreshing()

defer { group.leave() }
let groupModel = OriginGroupModel(cellType: .marquee(list: data), sortIndex: self.sortModel.marqueeIndex)
guard !data.isEmpty else { return }

/// 把groupModel添加到listMs中
groupModel.addTo(listMs: &self.listMs)
}
})

/// .... 此处省略其它多个请求

group.notify(queue: queue) {
// 队列中线程全部结束,刷新UI
DispatchQueue.main.sync { [weak self] in
self?.collectionView.reloadData()
}
}
}


collectionView的代理方法处理


    func numberOfSections(in collectionView: UICollectionView) -> Int {
return listMs.count
}

func collectionView(_: UICollectionView, numberOfItemsInSection section: Int) -> Int {
let groupModel = listMs[section]
switch groupModel.cellType {
case .marquee, .beltAndRoad, .walletCard, .wallet, .otc, .hxPrefecture, .shoppingCarnival, .middleAd:
return 1
case .middleNav(let list):
return list.count
case .bottomNav(let list):
return list.count
case .broadcast:
return 1
case .localService(let list):
return list.count
case .bottomFloat:
return 1
}
}



  • 同理,collectionView的代理方法中,都是先拿到 cellType 来判断,达到精准定位, 举个栗子


    /// Cell大小
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let groupModel = listMs[indexPath.section]
let width = screenWidth - 2 * margin
switch groupModel.cellType {
case .marquee:
return CGSize(width: screenWidth, height: 32)
case .beltAndRoad:
return CGSize(width: width, height: 46)
case .walletCard:
return CGSize(width: width, height: 85)
case .wallet:
return CGSize(width: width, height: OriginWalletCell.eachHeight * 2 + 10)
case .otc, .hxPrefecture:
return CGSize(width: width, height: 60)
case .middleNav:
let row: CGFloat = 5
let totalWidth: CGFloat = 13 * (row - 1) + 2 * margin
return CGSize(width: (screenWidth - totalWidth) / row, height: CGFloat(98.zh(80).vi(108)))
case .bottomNav:
let isFirstRow: Bool = indexPath.item < 2
let row: CGFloat = isFirstRow ? 2 : 3
let totalWidth: CGFloat = 4 * (row - 1) + 2 * margin
let width = (screenWidth - totalWidth) / row
return CGSize(width: floor(Double(width)), height: 70)
case .shoppingCarnival:
return CGSize(width: width, height: 150)
case .broadcast:
return CGSize(width: screenWidth - 20, height: 114)
case .middleAd:
return CGSize(width: width, height: 114)
case .localService:
let width = (82 * screenWidth) / 375
return CGSize(width: width, height: 110)
case .bottomFloat:
let h = bottomCellHeight > OriginBottomH ? bottomCellHeight : OriginBottomH
return CGSize(width: screenWidth, height: h)
}
}


总结一下这种写法的优势




  • 方便修改组和组之前的顺序问题,甚至可以由服务器下发顺序




  • 方便删减组,只要把数据的添加组注释掉




  • 用枚举的方式,定义每个组,更清晰,加上swift的关联值优势,可以不用在控制器里定义多个数组




  • 考虑到要下拉刷新,所以抽取了一个协议 GroupProvider,里面提供两个默认的实现方法



    • 方法一:获取当前cellType在listMs中的下标

    • 方法二:是否要添加到listMs中




  • 界面长什么样,全部由数据来驱动,这组没有数据,界面就对应的不显示(皮之不存,毛将焉附),有数据就按预先设计好的显示




源码地址(源码内容和gif图中有差异,但是思路是一致的)


github.com/XYXiaoYuan/…


作者:Bruceyuan
链接:https://juejin.cn/post/6939767696846225421

收起阅读 »

SwiftUI与Swift的区别

iOS
引言 SwiftUI 于 2019 年度 WWDC 全球开发者大会上发布,它是基于 Swift 建立的声明式框架。该框架可以用于 watchOS、tvOS、macOS、iOS 等平台的应用开发,等于说统一了苹果生态圈的开发工具。 本人最早开始 iOS 开发时选...
继续阅读 »

引言


SwiftUI 于 2019 年度 WWDC 全球开发者大会上发布,它是基于 Swift 建立的声明式框架。该框架可以用于 watchOS、tvOS、macOS、iOS 等平台的应用开发,等于说统一了苹果生态圈的开发工具。


本人最早开始 iOS 开发时选择了 OC(Objective-C,一种编程语言),当时 OC 不但拥有各种知名的第三方库和完善的社区支持,同时 Swift 语言本身都还在不断颠覆性改进中。但当我看了 2020 年 WWDC 关于 SwiftUI 一系列课程之后,便从 Swift 语言的学习开始,逐步了解并掌握 SwiftUI,并果断抛弃了OC,将新项目全部迁移到了 SwiftUI 框架。


SwiftUI 到底有没有苹果宣传的那么理想化?资深的 iOS 开发者是否有必要转型,以及如何转型?SwiftUI 在实际使用的过程中真实体验如何?这些问题就是这篇文章希望探讨的话题。


在最后,我会分享一些自己的学习心得和材料顺序,借开源的精神与大家共同进步。


什么是 SwiftUI


对于 Swift UI,官方的定义很好找到,也写得非常明确:



SwiftUI is a user interface toolkit that lets us design apps in a declarative way.

可以理解为 SwiftUI 就是⼀种描述式的构建 UI 的⽅式。



单单通过描述,大部分人其实很难对抽象的编程方法,和其中的改进有直观的认识。这篇文章也希望通过尽量口语化的叙述,减少专业词汇和代码的出现来降低阅读门槛,让更多人了解计算机科学,了解程序的世界。


下面是我手头正在做的一个项目,定位是一个原生全平台的电子阅读应用,正在使用 SwiftUI 构建用户界面。


为什么苹果要推出 SwiftUI


SwiftUI 的两个组成部分,Swift + UI,即是这个问题的答案。


Swift:编程语言和体验的一致性


Swift 代表苹果推出的一种现代编程语言


很多苹果用户之所以喜欢苹果的产品,其中一个原因,是不同产品之间由内而外的统一感和协调感。这一点在硬件层面的感知是最明显的,从早期开始苹果出的硬件就是「果味十足」的。即使是新品迭代或者是开发全新的品类,也一定会带有烙印很深的「果味」工业设计。


仅仅外观的统一还不够,苹果真正追求的是内外一致,也就是体验的统一。


然而工业设计可以交给自家精英团队,系统可以相互借鉴,但用户使用的软件是由广大的开发者自由创造的。让人意外的是,这一点苹果做的也很不错,与别家相比,质量精良是很多人对苹果系统上软件的印象。


为了实现这一目标苹果做了大量不为普通消费者所感知的工作。


在设计上,苹果提供了一整套不断在更新的 Human Interface Guidelines,详细规定了与视觉相关的各个方面。在完成开发,准备上架分发之前,苹果的审核团队会对每一款应用进行审核,根据 App Store Review Guidelines 的条款判断应用是否允许上架 App Store,即使是知名的应用违反规定也是说下架就下架,绝不含糊。对于不越狱的移动设备而言,App Store 是唯一可以安装应用的途径,控制了其中的准入也就等于替整个平台做了筛选。


除了控制终端以外,苹果也在想方设法增加开发者的数量,提升单个应用质量。方式也非常符合第一性思维原则——降低开发的难度。所以先有了Swift,紧接着又推出了 SwiftUI。苹果希望直接优化语言本身,并统一所有设备的开发体验,让开发者更容易上手,也更容易将心里的想法转化为运行的程序。


虽说在 2015 年推出 Swift2.0 的时候就进行了开源。但这些年 Swift 在后端或是跨平台的发展上并不是非常顺利。提起 Swift,圈内还是会被默认为特指苹果平台内使用的编程语言,地位有些类似 OC 的接班者。其实为了推广这门语言,苹果本身也做了非常多的工作,像是推出SwiftUI,基本就可以看做苹果在推广新语言的过程中一个里程碑式的节点。


SwiftUI 使用了大量 Swift 的语言特性,特别是 5.0 之后新增的特性。Swift 5.1 的很多特性几乎可以说都是为了 SwiftUI 量身定制的,比如 Opaque return types、Property Delegate 和 Function builder 等。


UI:开发的困局


在 SwiftUI 出现之前,苹果不同的设备之前的开发框架并不互通,移动端的⼯程师和桌⾯端的⼯程师需要掌握的知识,有很⼤⼀部分是差异化的。


从 iOS SDK2.0 开始,移动端的开发者⼀直使⽤ UIKit 进⾏⻚⾯部分的开发。UIKit 的思想继承了成熟的 AppKit 和MVC(Model-View-Controller)模式,作出了⼀些改进,但本质上改动不⼤。UI 包括了⽤⼾能看到的⼀切,包括静⽌的显⽰和动态的动画。


再到后来苹果推出了Apple Watch,在这块狭小屏幕上,又引入了一种新的布局方式。这种类似堆叠的逻辑,在某种程度上可以看做 SwiftUI 的未完全体。


截止此时,macOS 的开发需要使用 AppKit,iOS 的开发需要使用 UIKit,WatchOS 的开发需要使用堆叠,这种碎片化的开发体验无疑会大大增加开发者所需消耗的时间精力,也不利于构建跨平台的软件体验。


即使单看 iOS 平台,UIKit 也不是完美的开发⽅案。


UIKit 的基本思想要求 ViewController 承担绝⼤部分职责,它需要协调 model,view 以及⽤⼾交互。这带来了巨⼤的 sideeffect 以及⼤量的状态,如果没有妥善安置,它们将在 ViewController 中混杂在⼀起,同时作⽤于 view 或者逻辑,从⽽使状态管理愈发复杂,最后甚⾄不可维护⽽导致项⽬崩溃。换句话说,在不断增加新的功能和⻚⾯后,同⼀个ViewControlle r会越来越庞杂,很容易在意想不到的地⽅产⽣ bug。⽽且代码纠缠在⼀起后也会⼤⼤降低可读性,伤害到维护和协作的效率。


SwiftUI的特点


在很多地方都能看到 SwiftUI 针对现有问题的一些解决思路,而且现在的编程思想经过不断以来的演化,也一直就软件工程在开发过程中的各种问题在寻找答案。


近年来,随着编程技术和思想的进步,使用声明式或者函数式的方式来进行界面开发,已经越来越被接受并逐渐成为主流。SwiftUI 不是第一个,也不会是最后一个使用声明式界面开发的框架。


声明式的界面开发方式


在计算机科学的世界内,抽象是一个很重要的概念。从底层的二进制逻辑门,到人类可以阅读和理解的编程语言之间,是由很多层的抽象将它们关联起来的。所谓抽象,简单解释就是通过封装组件,将底层细节打包并隐藏起来,从而明确逻辑降低复杂度。就像把晶体管打包成逻辑门,以及软件工程中的函数对象。在软件开发的过程中,工程师只需负责某个具体功能的实现,而其他人则通过开放的 api 使用该功能。


与曾经的布局方式相比,声明式的页面开发无疑又加了一层抽象。


在 UIKit 框架中,界面上的每一个元素都需要开发者进行布置,期间有不少计算工作,例如长宽的改变或是屏幕可视面积的变化等。这种线性的方式被称为指令式 (imperative) 编程。以一行文字为例,放置在哪个坐标、宽度多少、在哪里换行、怎么断句、字形字号是多少、最终高度多少、是否需要缩小字号来完全显示等,这些都是开发者在制作界面时要考虑和计算妥当的问题。到了第二年,用户可能会换更大屏幕的手机,系统支持动态字体调节等新功能,此时原先的程序不进行适配就可能出现显示问题,开发者就需要回头进行程序的重新调试。


换做 SwiftUI 之后,上述的很多变量就被系统接管了。开发者要做的就是直观的告诉系统放置一个图像,上面加一行文字,右边加一个按钮。系统会根据屏幕大小、方向等自动渲染这个界面,开发者也不再需要像素级的进行计算。这被称为声明式 (declarative) 编程


对比同一个场景界面的实现


作为常用的列表视图,在UIKit中被称为 TableView,而在 SwiftUI 中被叫做 List 或 Form。同样是实现一个列表,在 SwiftUI 仅需要声明这里有一个 List 以及每个单元的内容即可。而在UIKit 中,需要使用委托代理的方式定制每个单元的内容,还需要事无巨细的设置行和组的数量、对触摸的反应、点击过程等各方面。


在我的另一个早期项目 Amos 时间志中就可以看到,为了绘制主页就需要几千行代码。


智能适配不同尺寸的屏幕


除了不同尺寸的屏幕,SwiftUI 还能根据运行平台的区别,将按钮、对话框、设置项等渲染成匹配的样式。由于声明的留白是很大的,当开发者不需要事无巨细的安排好每一个细节时,系统可操作的空间也会变大。


可以想象,假如苹果推出新品例如眼镜,或许同样的界面代码会被展示成与 iPhone 中完全不同的样式。


提高了解决问题时所需要着手的层级,这可以让开发者可以将更多的注意力集中到更重要的创意方面。


链式调用修改属性


链式调用是 Swift 语言的一种特性,就是用来使用函数方法的一种方式。可以像链条那样不断地调用函数,中间不需要断开。使用这种方式开发者可以给界面元素添加各种属性,只要愿意,同样能够事无巨细的安排页面元素的各种细节。


除了系统预制的属性可以调节外,开发者也可以进行自定义。例如将不同字体、字号、行间距、颜色等属性统合起来,可以组合成为一个叫「标题」的文字属性。之后凡是需要将某一行文字设置成标题,直接添加这个自定义的属性即可。


使用这种方式进行开发无疑能够极大的避免无意义的重复工作,更快的搭建应用界面框架。


界面元素组件化


理论上来讲,每一个复杂的视图,都是由大量简单的单元视图构成。但是函数方法可以包装起来,做到仅在有需要的时候进行调取使用。在 UIKit 框架下的页面元素解耦却不太容易,一般都是针对某种特定情境,很难进行移植。有时候可能手机横屏就会让页面元素混乱,就更别论页面元素的组件化了。


不过 SwiftUI 在布局上的特点,却可以便捷的拆分复杂的视图组件。单一的组件不仅可以自由组合,而且在苹果的任意平台上都可以使用该组件,达到跨平台的实现。


一般我个人会将视图组件区分为基础组件、布局组件和功能组件。因为 SwiftUI 的界面不再像 UIKit 那样,用 ViewController 承载各种 UIVew 控件,而是一切都是视图。这种视图的拼装方式提高了界面开发的灵活性和复用性。


响应式编程框架 Combine


在构建复杂界面的过程中,数据的流通一直是指令式编程中相当让人头疼的部分。


在 UIKit 框架下时,会配合 Target-Action 或者 protocol-delegate 模式来交换信息,使用 Key-Value Observing (KVO) 或者 Key-Value Coding (KVC) 来监测变化和读写属性。但即便开发者熟练地使用这些工具,面对日益增长的应用复杂性,掉坑里的可能性还是非常大。因为有太多需要开发者妥善处理的数据流动,例如数据改动后需要通知相关的页面进行刷新,或是让关联数据重新计算等。


像是 React Native 和 Flutter 这样的移动端跨平台方案,由于采用了声明式 UI 的编写方式和严格的数据流动方向,就能够大幅减轻开发者的思考负担。


SwiftUI 很明显也吸收了这些现代的编程思想,在另一个重量级系统框架 Combine 的协助下,实现了单一数据源的管理。


响应式编程的核心是将所有事件转化成为异步的数据流,这刚好就是 Combine 的主要功能。Combine 采用观察者模式,对应多个观察者,可以分别订阅感兴趣的内容。在 SwiftUI 的界面布局过程中,不同的 View 就是观察者,分别订阅了相关联的属性,并在数据发生变化之后就能够自动的重新渲染。


单一数据源


在 WWDC 的介绍视频中,「Source of truth」这个词反复出现,中文可以将这个词理解为单一数据源。


一直以来复杂的UI结构都会创造更为复杂的数据和逻辑管理需求,每次在用户交互,或是数据来源发生变化的时候,能否及时更新相关界面组件,不然就会引起显示问题。


但是在 SwiftUI 中,只要在属性声明时加上 @State 等关键词,就可以将该属性和界面元素联系起来,在每次数据改动后,都有机会决定是否更新视图。这样就可以将所有的属性都集中到一起进行管理和计算,也不再需要手写刷新的逻辑。因为在 SwiftUI 中,页面渲染前会将开发者描述的界面状态储存为结构体,更新界面就是将之前状态的结构体销毁,然后生成新的状态。而在绘制界面的过程中,会自动比较视图中各个属性是否有变化,如果发生变化,便会更新对应的视图,避免全局绘制和资源浪费。


使用这种方式,读和写都集中在一处,开发者就能够更好地设计数据结构,比较方便的增减类型和排查问题。而不用再考虑线程、原子状态、寻找最新数据等各种细节,再决定通知相关的界面进行刷新


与UIKit彼此相容


一般开发者学习新技术有一个最大的障碍就是原先的项目怎么办。但 SwiftUI 在这一点上做的相当不错。由于是一个新发布的框架,UI 组件并不齐全,当 SwiftUI 中并没有提供类似的功能时,就可以把 UIKit 中已有的部分进行封装,提供给 SwiftUI 使用。需要做的仅仅是遵循UIViewRepresentable协议即可。相反,在已有的项目中,也可以仅用 SwiftUI 制作一部分的 UI 界面。


当然两种代码的风格是截然不同的,但在使用上却基本没有性能的损失。到最终成品时,用户也无法分辨出两种界面框架的不同。


从开发者的⻆度看 SwiftUI


回到开头的问题:SwiftUI 到底有没有苹果宣传的那么理想化?


在 WWDC 发布 SwiftUI 时,有一句话让我印象深刻:「不论多复杂,原先布局的 99% 现在都可以使用 SwiftUI 进行构建」。当我查询 SwiftUI 是否可以承担大型项目开发时,又一次从资深开发者那里看到了这句话。


在我实际体验一段时间,并最终将一款全 SwiftUI 开发的应用上架后,认为这句话并没有什么问题,但前提是对编程这件事需要有比较基础且深入的理解。


这有点像我们学习一些优秀的第三方库时候的感受,同样是用 Xcode 写代码,有些人写出来就是白开水,而另一些人就是黑魔法。学习同样的语言特性,但由于理解的深刻程度不同,在使用时也会大不一样。仅仅依靠一些标准的自带组件无法做出一款出色的应用,即使如 UIKit 那样拥有如此丰富的组件也不行。很多时候还是要根据业务需要,或者是一些独特的脑洞做出最合适的界面。


对于个⼈开发者而言,意味着什么?


SwiftUI 的上限有多高,还要看未来一年一度的更新。但与之前的 UIKit 相比,下限被大大拉低已是不争的事实。这里的所谓下限,指的是学习的难度。由于描述性的布局方式与我们平时的阅读习惯非常接近,告诉系统在页面中间放一个图片就像告诉别人在桌子中间放一个苹果那么直观。我认识的好多 UI 设计师就通过短时间的自学掌握了 SwiftUI,并且搭建起可以直接在真机上使用的 Demo。


降低学习成本这件事是非常有意义的,不仅可以增加开发者数量,降低学习门槛,而且就学习本身而言,让初学者感受到成就感和明确学习方向,长久而言是比短时间的学习效率更重要的事情。只有开始的时候培养足够的兴趣,在后期才可能自主自发的研究更深入更困难的问题。


SwiftUI 和 Combine 大量借助了 Swift 的语法特性,尤其是 5.0 之后的几个更新,新特性就仿佛是为了这两个系统及框架量身定做的一般。虽然将 Swift 开源,但苹果无疑还是牢牢地把握着这门语言的发展。这两个框架和编程语言之间的配合默契,也仿佛让开发者体会到了软硬件一体带来的发展潜力。无论 Swift 出圈后的成果能有多少,在苹果的体系内,无疑是能够将各种消费层面的软件体验整合统一。


只要使用 SwiftUI,系统会默认支持白天和黑夜模式的自动切换;在各种尺寸的屏幕间自动适配;为任意控件添加 Haptic Touch 或是动画;在 Apple Watch 上带来独立而完整的体验;将iOS 的应用转换为 macOS 的原生应用,会以最快的速度支持第一方的各种新特性。这种对苹果硬件的深入支持是那些跨平台方案无论如何无法实现的。可以看一些采用第三方框架的知名应用,像是横屏、黑夜模式、小组件等基础的特性,到现在都迟迟没有适配。


所以 SwiftUI 对小工作室或是独立开发者来说是件好事,可以让新的想法快速落地并且接受市场验证,真正的做到敏捷开发。以这种方式在市场中的细分领域获得一席之地,也能让更多人体会到编写程序的感受,甚至是创造财富。


开源我的学习心得


在最后的部分我会分享一些自己学习 SwiftUI 的过程和介绍相关的资源,给一些也对开发感兴趣的小伙伴们做个参考。


首先要学习的是 Swift 编程语言,它与 OC 之间的差别还是挺大的,学习也没有什么捷径,直接阅读官方教程,对照着实例自己写一遍就行。国内有几个非常好的汉化网站,可以一起对照学习。基本上没有必要特意买书,反而不如直接电脑上看了就敲来的方便。



  1. 官网

  2. SwiftGG

  3. GitHub 汉化库


对语言有了大概的了解后,就可以开始对 SwiftUI 的学习,假如遇到问题可以反复回去查看之前的资料。很多被忽略的细节,或是当时初看没概念的部分,结合具体的案例就能够有比较透彻的理解。



  1. 斯坦福公开课 CS193P·2020 年春:该课程强推,我当年学习 OC 看的就是它,现在到SwiftUI了还是先看这个,系统且细致,结合案例和编程过程中的小技巧介绍,是很好的入门课程。

  2. 苹果官方 SwiftUI 课程:打开Xcode,照着官方的教学,从头到尾学着做一遍应用。

  3. Hacking with swift:这是国外一个程序员用业余时间搭建的分享网站,有大量的文章可以阅读,还有推荐初学者跟着做的「100 Days of SwiftUI」课程。

  4. 苹果官方文档:文档是必读的,虽然很多文档缺乏工程细节,但是文档涉及很多概念性的内容,你可以知道官方是怎么思考的,并且有很多具体的机制参数。我本人有一个习惯,要是工程涉及某个框架,会把相关的文档都翻译一遍。

  5. Stack Overflow:有问题查询专用,在谷歌中搜索错误代码或者关键词基本都会由该网站给答案。

  6. 阅读 SwiftUI 库的源代码。


基本到此假如能够顺利完成下来,就可以开启自己的项目。开发想要提高的关键就是亲自写代码和不断地阅读学习。初期学习的关键能力就是英语,而到后期需要的就是真正的兴趣和一些数学能力。


作者:洋仔
链接:https://juejin.cn/post/6997313521067229214
收起阅读 »

Swift:基石库——R.swift

iOS
这是我参与更文挑战的第4天,活动详情查看: 更文挑战何为基石库?做一个App无外乎两大要素:获取数据通过数据驱动页面也许你的App没有网络请求或者网络请求少,你可以不需要Alamofire。也许你的App的UI不是特别复杂,简单的xib和storyb...
继续阅读 »

这是我参与更文挑战的第4天,活动详情查看: 更文挑战

何为基石库?

做一个App无外乎两大要素:

  • 获取数据

  • 通过数据驱动页面

也许你的App没有网络请求或者网络请求少,你可以不需要Alamofire。

也许你的App的UI不是特别复杂,简单的xib和storyboard就可以胜任。

但是在当下一个App中,图片资源、字符串资源等,作为一个App开发者,你是不得不用的。

举个栗子,传统的获取一个image资源我们都是这么写:

let image = UImage(named: "saber")

这么写的最大弊端就是saber这是一个字符串硬编码,靠的的是纯手工敲打,一旦出错,界面就会出现异常。

在开发中,需要尽量避免这种硬编码,如何高效将这种硬编码的表达方式更换为高效安全的方式,就由本次的主角出场了--R.swift

统和所有的资源,以现代化的方式引用资源,项目中使用它,虽然不会让你的App上升一个层次,不过却给你的编码极度舒适。

let image = R.image.saber()

同样是Ex咖喱棒,味道却完全不同,哈哈。

基石库就是那些你无法避免不得不用的库,而R.swift恰恰就是。

R.swift

何为R,即Resource的缩写,我们先看看官方给出的一些例子:

使用R.swift函数前:

let icon = UIImage(named: "settings-icon")
let font = UIFont(name: "San Francisco", size: 42)
let color = UIColor(named: "indicator highlight")
let viewController = CustomViewController(nibName: "CustomView", bundle: nil)
let string = String(format: NSLocalizedString("welcome.withName", comment: ""), locale: NSLocale.current, "Arthur Dent")

使用R.swift函数后:

let icon = R.image.settingsIcon()
let font = R.font.sanFrancisco(size: 42)
let color = R.color.indicatorHighlight()
let viewController = CustomViewController(nib: R.nib.customView)
let string = R.string.localizable.welcomeWithName("Arthur Dent")

所有的资源都函数化后,编写过程想出错都难,特别需要注意的是最后一个涉及国际化的函数R.string.localizable.welcomeWithName("Arthur Dent"),Arthur Dent这个字符串需要自己具体制定,可以通过在做国际化时,通过info.strings进行处理。

R.swift目前支持下面这些资源文件管理:

  • Images
  • Fonts
  • Resource files
  • Colors
  • Localized strings
  • Storyboards
  • Segues
  • Nibs
  • Reusable cells

基本上覆盖了绝大多数的App中的资源管理。

安装和使用

安装

R.swift使用其他特别舒服,不过它的安装确实比其他的第三方库稍微麻烦一点,正所谓工欲善其事必先利其器,这一点麻烦是值得的。

1.添加'R.swift' 在工程的Podfile文件中,并运行pod install。 2.如下图所示。添加脚本:

image.png

3.如下图所示,移动脚本的位置,让它在Compile Sources phase和Check Pods Manifest.lock之间:

image.png

4.添加脚本:

image.png

在shell,下面这一栏添加"$PODS_ROOT/R.swift/rswift" generate "$SRCROOT/R.generated.swift"

在input Files通过+号添加$TEMP_DIR/rswift-lastrun

在Output Files通过+号添加$SRCROOT/R.generated.swift

5.运行添加R.generated.swift:

完成第4步后,进行command + B编译,然后在工程的根目录下面会找到R.generated.swift文件: image.png

将这个文件拖入到工程中,并且不要勾选Copy items if needed

image.png

这样,R.swift就安装完成啦。

使用

每一次添加了新的资源文件,就运行一次command + B一次,这样R.generated.swift文件就将新加入的资源文件更新,使用使用的时候只用通过R.来进行引用了。

更多用法,参考上面写的例子,以及官方文档

明天周末怎么破?

最怕周末更文,因为作为一个奶爸,休息都不是自己的,我争取做到不水文,至少讲一些知识点,明日继续,大家加油。


收起阅读 »

Swift:解包的正确姿势

iOS
嗯,先来一段感慨 在掘金里面看见iOS各路大神各种底层与runtime,看得就算工作了好几年的我也一脸蒙圈,于是只好从简单的入手。 文章最初发布在简书上面,有段时间了,考虑以后大部分时间都会在掘金学习,于是把文章搬过来了。稍微做了点润色与排版。 对于Swift...
继续阅读 »

嗯,先来一段感慨


在掘金里面看见iOS各路大神各种底层与runtime,看得就算工作了好几年的我也一脸蒙圈,于是只好从简单的入手。


文章最初发布在简书上面,有段时间了,考虑以后大部分时间都会在掘金学习,于是把文章搬过来了。稍微做了点润色与排版。


对于Swift学习而言,可选类型Optional是永远绕不过的坎,特别是从OC刚刚转Swift的时候,可能就会被代码行间的?与!,有的时候甚至是??搞得稀里糊涂的。


这篇文章会给各位带来我对于可选类型的一些认识以及如何进行解包,其中会涉及到Swift中if let以及guard let的使用以及思考,还有涉及OC部分的nullablenonnull两个关键字,以及一点点对两种语言的思考。


var num: Int? 它是什么类型?


在进行解包前,我们先来理解一个概念,这样可能更有利于对于解包。


首先我们来看看这样一段代码:



var num: Int?

num = 10

if num is Optional<Int> {

print("它是Optional类型")

}else {

print("它是Int类型")

}



请先暂时不要把这段代码复制到Xcode中,先自问自答,num是什么类型,是Int类型吗?


好了,你可以将这段代码复制到Xcode里去了,然后在Xcode中的if上一定会出现这样一段话:



'is' test is always true



num不是Int类,它是Optional类型


那么Optional类型是啥呢--可选类型,具体Optional是啥,Optional类型的本质实际上就是一个带有泛型参数的enum类型,各位去源码中仔细看看就能了解到,这个类型和Swift中的Result类有异曲同工之妙。


var num: Int?这是一个人Optional的声明,意思不是“我声明了一个Optional的Int值”,而是“我声明了一个Optional类型,它可能包含一个Int值,也可能什么都不包含”,也就是说实际上我们声明的是Optional类型,而不是声明了一个Int类型!


至于像Int!或者Int?这种写法,只是一种Optional类型的糖语法写法。


以此类推String?是什么类型,泛型T?是什么类型,答案各位心中已经明了吧。


正是因为num是一个可选类型。所以它才能赋值为nil, var num: Int = nil。这样是不可能赋值成功的。因为Int类型中没有nil这个概念!


这就是Swift与OC一个很大区别,在OC中我们的对象都可以赋值为nil,而在Swift中,能赋值为nil只有Optional类型!


解包的基本思路,使用if let或者guard let,而非强制解包


我们先来看一个简单的需求,虽然这个需求在实际开发中意义不太大:


我们需要从网络请求获取到的一个人的身高(cm为单位)以除以100倍,以获取m为单位的结果然后将其结果进行返回。


设计思路:


由于实际网络请求中,后台可能会返回我们的身高为空(即nil),所以在转模型的时候我们不能定义Float类型,而是定义Float?便于接受数据。


如果身高为nil,那么nil除以100是没有意义的,在编译器中Float?除以100会直接报错,那么其返回值也应该为nil,所以函数的返回值也是Float?类型


那么函数应该设计成为这个样子是这样的:



func getHeight(_ height: Float?) -> Float?



如果一般解包的话,我们的函数实现大概会写成这样:



func getHeight(_ height: Float?) -> Float? {

if height != nil {

return height! / 100

}

return nil

}



使用!进行强制解包,然后进行运算。


我想说的是使用强制解包固然没有错,不过如果在实际开发中这个height参数可能还要其他用途,那么是不是每使用一次都要进行强制解包?


强制解包是一种很危险的行为,一旦解包失败,就有崩溃的可能,也许你会说这不是有if判断,然而实际开发中,情况往往比想的复杂的多。所以安全的解包行为应该是通过if let 或者guard let来进行。



func getHeight(_ height: Float?) -> Float? {

if let unwrapedHeight = height {

return unwrapedHeight / 100

}

return nil

}



或者:



func getHeight(_ height: Float?) -> Float? {

guard let unwrapedHeight = height else {

return nil

}

return unwrapedHeight / 100

}



那么if let和guard let 你更倾向使用哪个呢?


在本例子中,其实感觉二者的差别不大,不过我个人更倾向于使用guard let。




原因如下:


在使用if let的时候其大括号类中的情况才是正常情况,而外部主体是非正常情况的返回的nil;


而在使用guard let的时候,guard let else中的大括号是异常情况,而外部主体返回的是正常情况。


对于一个以返回结果为目的的函数,函数主体展示正常返回值,而将异常抛出在判断中,这样不仅逻辑更清晰,而且更加易于代码阅读。




解包深入


有这么一个需求,从本地路径获取一个json文件,最终将其转为字典,准备进行转模型操作。


在这个过程中我们大概有这么几个步骤:


1. 获取本地路径 


func path(forResource name: String?, ofType ext: String?) -> String?


2. 将本地路径读取转为Data 


init(contentsOf url: URL, options: Data.ReadingOptions = default) throws


3. JSON序列化


class func jsonObject(with data: Data, options opt: JSONSerialization.ReadingOptions = []) throws -> Any


4. 是否可以转为字典类型


我们可以看到以上几个函数中,获取路径获取返回的路径结果是一个可选类型而转Data的方法是抛出异常,JSON序列化也是抛出异常,至于最后一步的类型转换是使用as? [Sting: Any]这样的操作


这个函数我是这来进行设计与步骤分解的:


函数的返回类型为可选类型,因为下面的4步中都有可能失败进而返回nil。


虽然有人会说第一步获取本地路径,一定是本地有的才会进行读取操作,但是作为一个严谨操作,凡事和字符串打交道的书写都是有隐患的,所以我这里还是用了guard let进行守护。


这个函数看起来很不简洁,每一个guard let 后面都跟着一个异常返回,甚至不如使用if let看着简洁


但是这么写的好处是:在调试过程中你可以明确的知道自己哪一步出错



func getDictFromLocal() -> [String: Any]? {

/// 1 获取路径

guard let path = Bundle.main.path(forResource: "test", ofType:"json") else {

return nil

}

/// 2 获取json文件里面的内容

guard let jsonData = try? Data.init(contentsOf: URL.init(fileURLWithPath: path)) else {

return nil

}

/// 3 解析json内容

guard let json = try? JSONSerialization.jsonObject(with: jsonData, options:[]) else {

return nil

}

/// 4 将Any转为Dict

guard let dict = json as? [String: Any] else {

return nil

}

return dict

}



当然,如果你要追求简洁,这么写也未尝不可,一波流带走



func getDictFromLocal() -> [String: Any]? {

guard let path = Bundle.main.path(forResource: "test", ofType:"json"),

let jsonData = try? Data.init(contentsOf: URL.init(fileURLWithPath: path)),

let json = try? JSONSerialization.jsonObject(with: jsonData, options:[]),

let dict = json as? [String: Any] else {

return nil

}

return dict

}



guard let与if let不仅可以判断一个值的解包,而且可以进行连续操作


像下面这种写法,更加追求的是结果,对于一般的调试与学习,多几个guard let进行拆分,未尝不是好事。


至于哪种用法更适合,因人而异。


可选链的解包


至于可选链的解包是完全可以一步到位,假设我们有以下这个模型。



class Person {

var phone: Phone?

}

class Phone {

var number: String?

}



Person类中有一个手机对象属性,手机类中有个手机号属性,现在我们有位小明同学,我们想知道他的手机号。


小明他不一定有手机,可能有手机而手机并没有上手机号码。



let xiaoming = Person()

guard let number = xiaoming.phone?.number else {

return

}

print(number)



这里只是抛砖引玉,更长的可选链也可以一步到位,而不必一层层进行判断,因为可选链中一旦有某个链为nil,那么就会返回nil。


nullable和nonnull


我们先来看这两个函数,PHImageManager在OC与Swift中通过PHAsset实例获取图片的例子



[[PHImageManager defaultManager] requestImageForAsset:asset targetSize:size contentMode:PHImageContentModeDefault options:options resultHandler:^(UIImage * _Nullable result, NSDictionary * _Nullable info) {

//、 非空才进行操作 注意_Nullable,Swift中即为nil,注意判断

if (result) {

}

}];




PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .default, options: options, resultHandler: { (result: UIImage?, info: [AnyHashable : Any]?) in

guard let image = result else { return }

})



在Swift中闭包返回的是两个可选类型,result: UIImage?与info: [AnyHashable : Any]? 


而在OC中返回的类型是 UIImage * _Nullable result, NSDictionary * _Nullable info


注意观察OC中返回的类型UIImage * 后面使用了_Nullable来修饰,至于Nullable这个单词是什么意思,我想稍微有点英文基础的应该一看就懂--"可以为空",这不恰恰和Swift的可选类型呼应吗?


另外还有PHFetchResult遍历这个函数,我们再来看看在OC与Swift中的表达



PHFetchResult *fetchResult;

[fetchResult enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {

}];




let fetchResult: PHFetchResult

fetchResult.enumerateObjects({ (obj, index, stop) in

})



看见OC中Block中的回调使用了Nonnull来修饰,即不可能为空,不可能为nil,一定有值,对于使用这样的字符修饰的对象,我们就不必为其做健壮性判断了。


这也就是nullable与nonnull两个关键字出现的原因吧--与Swift做桥接使用以及显式的提醒对象的状态


一点点Swift与OC的语言思考


我之前写过一篇文章,是说有关于一个字符串拼接函数的


从Swift来反思OC的语法


OC函数是这样的:



- (NSString *)stringByAppendingString:(NSString *)aString;



Swift中函数是这样的:



public mutating func append(_ other: String)



仅从API来看,OC的入参是很危险的,因为类型是NSString *


那么nil也可以传入其中,而传入nil的后果就是崩掉,我觉得对于这种传入参数为nil会崩掉的函数需要特别提醒一下,应该写成这样:



- (NSString *)stringByAppendingString:(NSString * _Nonnull)aString;

/// 或者下面这样

- (NSString *)stringByAppendingString:(nonnull NSString *)aString;



以便告诉程序员,入参不能为空,不能为空,不能为空,重要的事情说三遍!!!


反观Swift就不会出现这种情况,other后面的类型为String,而不是String?,说明入参是一个非可选类型。


基于以上对于代码的严谨性,所以我才更喜欢使用Swift进行编程。


当然,Swift的严谨使得它失去部分的灵活性,OC在灵活性上比Swift卓越。


作者:season_zhu
链接:https://juejin.cn/post/6931154052776460302

收起阅读 »

iOS 无感知上拉

iOS
本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!RxSwift编写wanandroid客户端现已开源目前RxSwift编写wanandroid客户端已经开源了——项目链接。记得给个star喔!附上一张效果图片:本篇文章是从6月更...
继续阅读 »

本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

RxSwift编写wanandroid客户端现已开源

目前RxSwift编写wanandroid客户端已经开源了——项目链接。记得给个star喔!

附上一张效果图片:

RPReplay_Final1625472730.2021-07-05 16_13_58.gif

本篇文章是从6月更文中热心网友的留言中进行的开发与探索:

Snip20210709_1.png

6月确实因为日更的原因,这个功能没有实现,趁着7月的时候,解决了。

废话了这么多,那么我们进入主题吧。

什么是无感知上拉加载更多

什么是无感知,这个这样理解:在网络情况正常的情况下,用户对列表进行连续的上拉时,该列表可以无卡顿不停出现新的数据。

如果要体验话,Web端很多已经做到了,比如掘金的首页,还有比如掘金iOS的App,列表都是无感知上拉加载更多。

说来惭愧,写了这久的代码,还真的没有认真思考这个功能怎么实现。

如何实现无感知上拉加载更多

我在看见这位网友留言的时候,就开始思考了。

在我看来,有下面几个着手点:

  • 列表滑动时候的是如何知道具体滑动的位置以触发接口请求,添加更多数据?

  • 从UIScrollView的代理回调中去找和scrollView的位置(contentOffset)大小(contentSize)关系密切的回调。

  • 网络上有没有比较成熟的思路?

顺着这条线,我先跑去看了UIScrollViewDelegate的源码:

public protocol UIScrollViewDelegate : NSObjectProtocol {


@available(iOS 2.0, *)
optional func scrollViewDidScroll(_ scrollView: UIScrollView) // any offset changes

@available(iOS 3.2, *)
optional func scrollViewDidZoom(_ scrollView: UIScrollView) // any zoom scale changes

.
.
.
.
.
.
/// 代码很多,这里就不放上来,给大家压力了。
}

直接上结论吧:看了一圈,反正没有和contentSize或者位置相关的回调代理。scrollViewDidScroll这个回调里面虽然可以回参scrollView,但是对于我们需要的信息还不够具体。

思考:既然UIScrollViewDelegate的代理没有现成的代理回调,自己使用KVO去监听试试?

网上的思路(一)

就在我思考的同时,我也在网络上需求实现这个功能的答案,让后看到这样一个思路:

实现方法很简单,需要用到tableView的一个代理方法,就可轻松实现。- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath就是这个方法,自定义显示cell。这个方法不太常用。但是这个方法可在每个cell将要第一次出现的时候触发。然后我们可设置当前页面第几个cell将要出现时,触发请求加载更多数据。

我看了之后,心想着,多写一个TableView的代理,总比写KVO的代码少,先试试再说,于是代码撸起:

extension SwiftCoinRankListController: UITableViewDelegate {

func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
let row = indexPath.row
let distance = dataSource.count - 25
print("row: \(row), distance:\(distance) ")
if row == distance {
loadMore()
}
}
}

本代码可以在开源项目中的SwiftCoinRankListController.swift文件查看具体的逻辑,其主要就是通过cell显示的个数去提前请求加载数据,然后我们看看效果:

620A94AE4920C54C6E1B85E1776AC83C.2021-07-09 17_47_45.gif

Gif可能看起来还好,我说我调试的感受:

虽然做到了上拉无感知,但是当手滑的速度比较快的时候,到底了新的数据没有回来,就会在底部等一段时间。

功能达到了,但是感受却不理想,果然还是监听的细腻程度不够。

网上的思路(二)

然后在继续的搜索中,我看到了另外一个方案:

很多时候我们上拉刷新需要提前加载新数据,这时候利用MJRefreshAutoFooter的属性triggerAutomaticallyRefreshPercent就可以实现,该属性triggerAutomaticallyRefreshPercent默认值为1,然后改成0的话划到底部就会自动刷新,改成-1的话,在快划到底部44px的时候就会自动刷新。

MJRefresh?使用MJRefreshAutoFooter,这个简单,我直接把基类的footer给替换掉就可以了,本代码可以在开源项目中的BaseTableViewController.swift文件查看:

/// 设置尾部刷新控件,更新为无感知加载更多
let footer = MJRefreshAutoFooter()
footer.triggerAutomaticallyRefreshPercent = -1
tableView.mj_footer = footer

再来看看效果:

992BC78FBAC7B8CB36A6DC679897DA21.2021-07-09 18_04_09.gif

直接说感受:

代码改动性少,编写简单,达到预期效果,爽歪歪。比方案一更丝滑,体验好。

到此,功能就实现,难道就完了?

当然,不会,我们去看看源码吧。

MJRefresh代码的追根朔源

首先我们看看MJRefreshAutoFooter.h文件:

image.png

这里有个专门的属性triggerAutomaticallyRefreshPercent去做自动刷新,那么我们去MJRefreshAutoFooter.m中去看看吧:

image.png

注意看喔,这个.m文件有一个- (void)scrollViewContentOffsetDidChange:(NSDictionary *)change方法,并且还调用了super,从这个方法名中我们可以明显的得到当scrollView的contentOffset变化的时候进行回调的监听。,我们顺藤摸瓜,看看super是什么,会不会有新的发现:

image.png

稍微跟着一下源代码,MJRefreshAutoFooter的继承关系如下:

MJRefreshAutoFooter => MJRefreshFooter => MJRefreshComponent

所以这个super的调用我们就去MJRefreshComponent.m里面去看看吧:

image.png

通过上面的截图我们可以得到下面的一些信息与结论:

  • MJRefreshComponent是通过KVO去监听scrollView的contentOffset变化,思路上我们对齐一致了。

  • 该类并没有实现其具体方法,而是将其交由其子类去实现,这一点通过看MJRefreshComponent.h的注释可以得到:

image.png

  • MJRefreshComponent从本质上更像虚基类。

总结

如果不是掘友提出这个问题,我可能都不会太仔细的去研究这个功能,也许继续普普通通的使用一般的上拉加载更多就够了。

这次的实践,其实是从思路到寻找方法,最后再到源码阅读。

思路也许不困难,但是真正一点点实现并完善功能,每一步都并不容易,这次我也仅仅是继续使用了MJRefresh这个轮子。

想起有一天,在群里吹水看见的一张图:

云程序员来了.jpeg

灵魂拷问,直击人心,大部分时间我们不也是云程序员呢?

知行合一方能开拓新的天地。


收起阅读 »

swift 键盘收起

iOS
直接调用就能收起键盘,无需调用其他方法        UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), t...
继续阅读 »







直接调用就能收起键盘,无需调用其他方法    

    UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)

收起阅读 »

iOS 底层原理探索 之 结构体内存对齐

iOS
写在前面: iOS底层原理探究是本人在平时的开发和学习中不断积累的一段进阶之 路的。 记录我的不断探索之旅,希望能有帮助到各位读者朋友。 目录如下:iOS 底层原理探索之 alloc以上内容的总结专栏iOS 底层原理探索 之 阶段总结准备Objective-C...
继续阅读 »


写在前面: iOS底层原理探究是本人在平时的开发和学习中不断积累的一段进阶之
路的。 记录我的不断探索之旅,希望能有帮助到各位读者朋友。

目录如下:

  1. iOS 底层原理探索之 alloc

以上内容的总结专栏


准备

Objective-C ,通常写作ObjC或OC,是扩充C的面向对象编程语言。它主要适用于Mac OS X 和 GNUstep者两个使用OpenStep标准的系统,而在NeXTSTEP和OpenStep中它更是基本语言。

GCC和Clang含Objective-C的编译器,Objective-C可以在GCC以及Clang运作的系统上编译。

我们平时开发用的Objective-C语言,编译后最终会转化成C/C++语言。

为什么要研究结构体的内存对齐呢? 因为作为一名iOS开发人员,随着对于底层的不断深入探究,我们都知道,所有的对象在底层中都是一个结构体。那么结构体的内存空间又会被系统分配多少空间,这个问题,值得我们一探究竟。

首先,从大神Cooci那里盗取了一张各数据类型占用的空间大小图片,作为今天探究结构体内存对齐原理的依据。

image.png

当我们创建一个对象的时候,我们并不需要过多的在意属性的顺序,因为系统会帮我们做优化处理。但是,在创建结构体的时候,就需要我们去分析了,因为这个时候系统并不会帮助我们做优化。

接下来,我们看下面两个结构体:

struct Struct1 {    
double a;
char b;
int c;
short d;
char e;
}struct1;

struct Struct2 {
double a;
int b;
char c;
short d;
char e;
}struct2;


两个结构体拥有的数据类型是相同的,按照图片中double 是8字节, char 是1字节, int 是4字节,short 是2字节, 那么 两个结构体应该是占 16字节的内存空间,也就是分配16字节空间即可,然而,我们看下面的结果:

    printf("%lu--%lu", sizeof(struct1), sizeof(struct2));
------------
24--16

那么,这就是有问题的了,两个拥有相同数据类型的结构体,被系统分配到的内存空间是不一样的,这是为什么呢?今天的重点就是这里,结构体的

内存对齐原则:

1:数据成员对⻬规则:结构(struct)(或联合(union))的数据成员,第
一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置
要从该成员大小或者成员的子成员大小(只要该成员有子成员,比如说是
数组,结构体等)的整数倍开始(比如int为4字节,则要从4的整数倍地址
开始存储。

2:结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从
其内部最大元素大小的整数倍地址开始存储。(struct a里存有struct b,
b里有char,int ,double等元素,那b应该从8的整数倍开始存储)

3:收尾工作:结构体的总大小,也就是sizeof的结果,必须是其内部最大成员
的整数倍,不足的要补⻬。

那么,我们按照以上内存对齐原则再来分析下 struct1 和 struct2 :


struct Struct1 { /// 18 --> 24
double a; //8 [0 1 2 3 4 5 6 7]
char b; //1 [8 ]
int c; //4 [9 [12 13 14 15]
short d; //2 [16 17]
char e; //1 [18]
}struct1;


struct Struct2 { /// 16 --> 16
double a; //8 [0 1 2 3 4 5 6 7]
int b; //4 [8 9 10 11]
char c; //1 [ 12 ]
short d; //2 [14 15]
char e; // 1 [16]
}struct2;


接着,我们看下下面的结构体

struct Struct3 {    
double a;
int b;
char c;
short d;
int e;
struct Struct1 str;
}struct3;


打印输出结果为 48 ,分析如下:

    double a;           //8 [0 1 2 3 4 5 6 7]
int b; //4 [8 9 10 11]
char c; //1 [12]
short d; //2 [ 14 15 ]
int e; //4 [ 16 17 18 19]
struct Struct1 str; //24 [24 ... 47]

所以,struct3 大小为48。


猜想:内存对齐的收尾工作中的内部最大成员指的是什么的大小呢?

接下来我们来一一验证一下

struct LGStruct4 {          /// 40 --> 48 
double a; //8 [0 1 2 3 4 5 6 7]
int b; //4 [8 9 10 11]
char c; //1 [12]
short d; //2 [14 15]
int e; //4 [16 17 18 19]
struct Struct2 str; //16 [24 ... 39]
}struct4;

按照我对于内存对齐原则中收尾工作的理解, 最终的大小 应该是 Struct2 的 大小 16 的整数倍 也就是 48 才对。然而, 结果却是:

    NSLog(@"%lu", sizeof(struct4));
--------
SMObjcBuild[8076:213800] 40

对,是40你没有看错,这样的话,很显然,我理解的就是错误的, 结构体内部最大成员应该指的是这里的 double,那么我们接下来验证一下: 1、

struct Struct2 {    ///16
double a; //8 [0 1 2 3 4 5 6 7]
int b; //4 [8 9 10 11]
char c; //1 [ 12 ]
short d; //2 [14 15]
}struct2;

struct LGStruct4 { /// 24

short d; //2 [0 1]

struct Struct2 str; // 16 [8 ... 23]

}struct4;

结果是 :24


因为,结构体内部最大成员是 double也就是8;并不是按照 LGStruct4中的str长度为16的整数倍来计算,所以最后的结果是24。

总结

结构体内部最大成员指的是结构体内部的数据类型,所以,结构体内包含结构体的时候,并不是按照内部的结构体长度的整数倍来计算的哦。


收起阅读 »

iOS 底层原理探索 之 alloc

iOS
iOS 底层原理探索 之 alloc写在前面: iOS底层原理探究是本人在平时的开发和学习中不断积累的一段进阶之 路的。 记录我的不断探索之旅,希望能有帮助到各位读者朋友。 内容的总结专栏iOS 底层原理探索 之 阶段总结序作为一名iOS开发人员,在平时开发工...
继续阅读 »

iOS 底层原理探索 之 alloc

写在前面: iOS底层原理探究是本人在平时的开发和学习中不断积累的一段进阶之
路的。 记录我的不断探索之旅,希望能有帮助到各位读者朋友。


内容的总结专栏


作为一名iOS开发人员,在平时开发工作中,所有的对象我们使用最多的是alloc来创建。那么alloc底层做了哪些操作呢?接下来我会一步一步探究alloc方法的底层实现。

初探

我们先来看下面的代码

    SMPerson *p1 = [SMPerson alloc];
SMPerson *p2 = [p1 init];
SMPerson *p3 = [p1 init];

NSLog(@"%@-%p-%p", p1, p1, &p1);
NSLog(@"%@-%p-%p", p2, p2, &p2);
NSLog(@"%@-%p-%p", p3, p3, &p3);

打印内容:

    <SMPerson: 0x600000710400>-0x600000710400-0x7ffee6f15088
<SMPerson: 0x600000710400>-0x600000710400-0x7ffee6f15080
<SMPerson: 0x600000710400>-0x600000710400-0x7ffee6f15078

可见,在 SMPerson 使用 alloc 方法从系统中申请开辟内存空间后 init方法并没有对内存空间做任何的处理,地址指针的创建来自于 alloc方法。如下所示:

地址.001.jpeg

注:细心的你一定注意到了,p1、p2、p3都是相差了8个字节。 这是因为,指针占内存空间大小为8字节,p1、p2、p3 都是从栈内存空间上申请的,且栈内存空间是连续的。同时,他们都指向了同一个内存地址。

那么, alloc 是如何开辟内存空间的呢?

首先,第一反应是,我们要Jump to Definition,

2241622899100_.pic_hd.jpg

结果,Xcode中并不能直接跳转后显示其底层实现,所以 并不是我们想要的。

2251622899278_.pic_hd.jpg

WX20210605-214250@2x.png

中探

接下来,我们通过三种方法来一探究竟:

方法1

既然不可以直接跳转到API文档来查看alloc的内部实现,那么我们还可以通过下 符号断点 来探寻 其实现原理。

WX20210605-212725@2x.png

接下来我们就来到此处

WX20210605-213213@2x.png

一个名为 libobjc.A.dylib 的库,至此,我们就应该要去找苹果开源的库,以寻找我们想要的答案。

点击查看苹果开源源码汇总

方法2

我们也可以直接在alloc那一行打一个断点,代码运行到此处后,按住control键 点击 step into, 接下来,就来到里这里

WX20210605-214413@2x.png 我们可以看到一个 objc_alloc 的函数方法到调用,此时,我们再下一个符号断点,同样的,我们还是找到了 libobjc.A.dylib 这个库。

WX20210605-215027@2x.png

方法3

此外,我们还是可以通过汇编来调试和查找相应的实现内容,断点依然是在alloc那一行。

Debug > Debug Workflow > Always Show Disassembly

WX20210605-215336@2x.png

找到 callq 方法调用那一行, WX20210605-215715@2x.png

接着, step into 进去, 我们找到了 objc_alloc 的调用, 之后的操作和 方法2的后续步骤一样,最终,可以找到 libobjc.A.dylib 这个库。 WX20210605-215732@2x.png

深探

下载源码 objc4-818.2

接下来对源码进行分析,

alloc方法会调用到此处

WX20210605-231454@2x.png

接着是 调用 _objc_rootAlloc

WX20210605-231517@2x.png

之后调用 到 callAlloc

WX20210605-231545@2x.png

跟着断点会来到 _objc_rootAllocWithZone

WX20210605-231647@2x.png

之后是 _class_createInstanceFromZone

此方法是重点

WX20210605-231758@2x.png

_class_createInstanceFromZone 方法中,该方法就是一个类初始化所走的流程,重点的地方有三处

第一处是:
    // 计算出开辟内存空间大小
size = cls->instanceSize(extraBytes);

内部实现如下: WX20210605-231838@2x.png 其中在计算内存空间大小时,会调用 cache.fastInstanceSize(extraBytes) 方法,

最终会调用 align16(size + extra - FAST_CACHE_ALLOC_DELTA16) 方法。 align16 的实现如下:

static inline size_t align16(size_t x) {
return (x + size_t(15)) & ~size_t(15);
}

可见, 系统会进行 16字节 的对齐操作,也就是说,一个对象所占用的内存大小至少是16字节。

在这里 我们举个例子: size_t x = 8; 那么 align16操作后的大小计算过程如下:

    (8 + 15) & ~15;

0000 0000 0000 1000 8
0000 0000 0000 1111 15

= 0000 0000 0001 0111 23
1111 1111 1111 0000 ~15

= 0000 0000 0001 0000 16


第二处是:
    ///向系统申请开辟内存空间,返回地址指针;
obj = (id)calloc(1, size);

第三处是:
    /// 将类和指针做绑定
obj->initInstanceIsa(cls, hasCxxDtor);

总结:

所以,最后我们总结一下, alloc的底层调用流程如下:

alloc流程.001.jpeg

就是这样一个流程,系统就帮我们创建出来一个类对象。

补充

image.png

  • lldb 如何打印实力对象中成员为 double 类型的数值: e -f f -- <值>
收起阅读 »

拒绝编译等待 - 动态研发模式 ARK

iOS
背景 pod install 时间长:编译优化绝大部分任务放在了 CocoaPods 上,CocoaPods 承担了更多工作,执行时间因此变长。编译时间长:虽然现阶段绝大部分工程已经从源码编译转型成二进制编译,但编译耗时依旧在十分钟左右,且现有工程基础上已无更...
继续阅读 »



背景

iOS 业界研发模式多为 CocoaPods + Xcode + Git 的多仓组件化开发模型。为追求极致的研发体验、提升研发效率,对该研发模式进行了大量优化,但目前遇到了以下瓶颈,亟需突破:

  • pod install 时间长:编译优化绝大部分任务放在了 CocoaPods 上,CocoaPods 承担了更多工作,执行时间因此变长。

  • 编译时间长:虽然现阶段绝大部分工程已经从源码编译转型成二进制编译,但编译耗时依旧在十分钟左右,且现有工程基础上已无更好优化手段。

  • 超大型工程通病:Xcode Index 慢、爆内存、甚至卡死,链接时间长。

如何处理这些问题?

究其本质,产生这些问题的原因在于工程规模庞大。据此我们停下了对传统模式各节点的优化工作,以"缩小工程规模"为切入点,探索新型研发模式——动态研发模式 ARK。

ARK[1] 是全链路覆盖的动态研发模式,旨在保证工程体验的前提下缩小工程规模:通过基线构建的方式,提供线下研发所需物料;同时通过实时的动态库转化技术,保证本地研发仅需下载和编译开发仓库。

Show Case

动态研发模式本地研发流程图如下。接下来就以抖音产品为例,阐述如何使用 ARK 做一次本地开发。
演示基于字节跳动本地研发工具 MBox[2]

  1. 仓库下载

ARK 研发模式下,本地研发不再拉取主仓代码,取而代之的是 ARK 仓库。ARK 仓库含有与主仓对应的所有配置,一次适配接入后期不需要持续维护。

相较传统 APP 仓库动辄几个 GB 的大小,ARK 仓库贯彻了缩减代码规模这一概念。仓库仅有应用配置信息,不包含任何组件代码。ARK 仓库大小仅 2 MB,在 1 s 内可以完成仓库下载 。

在 MBox 中的使用仅需几步点击操作。首先选择要开发的产品,然后勾选 ark 模式,选择开发分支,最后点击 Create 便可以数秒完成仓库下载。

  1. 开发组件

CocoaPods 下进行组件开发一般是将组件仓库下载到本地,修改 Podfile 对应组件 A 为本地引用 pod A, :path =>'./A' ,之后进行本地开发。而在 MBox 和 ARK 的研发流程中,仅需选择要开发的组件点击 Add 便可进行本地开发。

动态研发模式 ARK 通过解析 Podfile.lock 支持了 Checkout From Commit 功能,该功能根据宿主的组件依赖信息自动拉取相应的组件版本到本地,带来便捷性的同时也保证了编译成功率。

  1. pod install

传统研发模式下 pod install 必须要经历 解析 Podfile 依赖、下载依赖、创建 Pods.xcodeproj 工程、集成 workspace 四个步骤,其中依赖解析和下载依赖两个步骤尤为耗时。

ARK 研发模式下 Podfile 中没有组件,因此依赖解析、下载依赖这两个环节耗时几乎为零。其次由于工程中仅需开发组件步骤中添加的组件,在创建 Pods 工程、集成工程这两个环节中代码规模的降低,对提升集成速度的效果非常显著。

没有依赖信息,编译、链接阶段显然不能成功。ARK 解决方案通过自研 cocoapods-ark 及配套工具链来保证编译、链接、运行的成功,其原理后续会在系列文章中介绍。

  1. 开发组件编译&调试

和传统模式一样通过 Xcode 打开工程的 xcworkspace ,即可正常开发、调试完整的应用。

工程中仅保留开发组件,但是依然有变量、函数、头文件跳转能力;参与 Index、编译的规模变小,Xcode 几乎不存在 loading 状态,大型工程也可以秒开;编译速度大幅提升。在整个动态研发流程中,通过工具链将组件从静态库转化成动态库,链接时间明显缩短。

  1. 查看全源码

ARK 工程下默认只有开发组件的源码,查看全源码是开发中的刚需。动态研发流程提供了 pod doc 异步命令实现该能力,此命令可以在开发时执行,命令执行完成后重启工程即可通过 Document Target 查看工程中其他组件源码。

pod doc 优点:

  • 支持异步和同步,执行过程中不影响本地开发。

  • 执行指令时跳过依赖解析环节,从服务端获取依赖信息,下载源码。

  • 通过 xcodegen 异步生成 Document 工程,大幅降低 pod install 时间。

  • 仅复用 pod installer 中的资源下载、缓存模块。

  • 支持仓库统一鉴权,自动跳过无权限组件仓库。

收益

体验上: 与传统模式开发流程一致,零成本切换动态研发模式。

工具上: 站在巨人的肩膀上,CocoaPods 工具链相关优化在 ARK 同样生效。

时间上: 传统研发模式中,历经各项优化后虽然能将全链路开发时间控制在 20 分钟左右,但这样的研发体验依旧不够友好。开发非常容易在这个时间间隔内被其他事情打断,良好的研发体验应该是连贯的。结合本地开发体验我们认为,一次连贯的开发体验应该将工程集成时间控制在分钟级,当前研发模式成功做到了这一点,将全链路开发时间控制在 5 分钟以内。

成功率: 成功率一直是研发效率中容易被忽视的一个指标。据不完全数据统计,集团内应用的本地全量编译成功率不足五成。一半的同学会在首次编译后执行重试。显然,对于工程新手来说就是噩梦,这意味着很长时间将在这一环节中浪费。而 ARK 从平台基线到本地工具链贯彻 Sandbox 的理念,整体上提高编译成功率。

写在最后

ARK 当前已经在字节跳动内部多业务落地使用。从初期技术方案的探索到实际落地应用,遇到了很多技术难点,也对研发模式有了新的思考。

相关技术文章将陆续分享,敬请期待。

扩展阅读

[1] ARK: https://github.com/kuperxu/KwaiTechnologyCommunication/blob/master/5.WWDC-ARK.pdf
[2] MBox: https://mp.weixin.qq.com/s/5_IlQPWnCug_f3SDrnImCw

作者:字节跳动终端技术——徐纪光
来源:https://blog.csdn.net/YZcoder/article/details/121374743


收起阅读 »

手把手带你,优化一个滚动时流畅的TableView

iOS
手把手带你,优化一个滚动时流畅的TableView这是我参与8月更文挑战的第7天,活动详情查看:8月更文挑战我的专栏iOS 底层原理探索iOS 底层原理探索 之 阶段总结意识到我的问题平时使用手机的时间不算少,每天阅读新闻的时候会感觉到新闻类的app优化的还是...
继续阅读 »

手把手带你,优化一个滚动时流畅的TableView

这是我参与8月更文挑战的第7天,活动详情查看:8月更文挑战


我的专栏

  1. iOS 底层原理探索
  2. iOS 底层原理探索 之 阶段总结

意识到我的问题

平时使用手机的时间不算少,每天阅读新闻的时候会感觉到新闻类的app优化的还是很好的,TableView的Cell滚动的时候不会去加载显示图片内容,当一次滑动结束之后,Cell上的新闻图片便开始逐个的加载显示出来,所以整个滑动的过程是很流畅的。这中体验也是相当nice的。

我最开始的做法

开发中TableView的使用是非常值频繁的,当TableViewCell上需要加载图片的时候,是一件比较头疼的事。因为,用户一边滑动TableView,TableView需要一边从网络获取图片。之前的操作都是放在 cellForRowAtIndexPath 中来处理,这就导致用户在滑动TableView的时候,会特别的卡(尤其是滑动特别快时),而且,手机的CPU使用率也会飙的非常的高。对于用户来说,这显然是一个十分糟糕的体验。

糟糕的图片显示 代码

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {

ImageTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell"];
cell.index = indexPath;

NSMutableDictionary *info = [self.dataSource objectAtIndex:cell.index.row];

NSString *url = [info objectForKey: @"img" ];
NSData *iData = [NSData dataWithContentsOfURL:[NSURL URLWithString: url ]];
cell.img.image = [UIImage imageWithData:iData];
cell.typeL.text = [NSString stringWithFormat:@"%ld-%ld", cell.index.section, cell.index.row];

return cell;
}

糟糕的手机CPU飙升率

未命名.gif

糟糕的用户滑动体验

未命名1.gif

不只是用户,对于开发这来讲,这也是不可以接受的体验。

平时接触并使用的app也非常的多,发现他们多处理方式就是,当用户滑动列表的时候,不再加载图片,等用户的滑动结束之后,会开始逐一的加载图片。这是非常好的优化思路,减轻了CPU的负担,也不会基本不会让用户感觉到页面滚动时候的卡顿。这也就是最开始我描述的我看新闻app的使用体验。

收到这个思路的启发,我们开始着手将上面糟糕的体验作一下优化吧。

总结思路开启优化之路

那么,带着这个优化思路,我开始了对于这个TableView 的优化。

  • 首先,我们只加载当前用户可以看到的cell上的图片。
  • 其次,我们一次只加载一张图片。

要完成以上两点,图片的加载显示就不能在cellForRowAtIndexPath中完成,我们要定义并实现一个图片的加载显示方法,以便在合适的时机,调用刷新内容显示。

loadSeeImage 加载图片的优化

#pragma mark load Images
- (void)loadSeeImage {

//记录本次加载的几张图片
NSInteger loadC = 0;

// 用户可以看见的cells
NSArray *cells = [self.imageTableView visibleCells];

// 调度组
dispatch_group_t group = dispatch_group_create();

for (int i = 0; i < cells.count; i++) {

ImageTableViewCell *cell = [cells objectAtIndex:i];

NSMutableDictionary *info = [self.dataSource objectAtIndex:cell.index.row];
NSString *url = [info objectForKey: @"img" ];

NSString *data = [info objectForKey:@"data"];

if ([data isKindOfClass:[NSData class]]) {


}else {

// 添加调度则到我们的串行队列中去
dispatch_group_async(group, self.loadQueue, ^{

NSData *iData = [NSData dataWithContentsOfURL:[NSURL URLWithString: url ]];
NSLog(@" load image %ld-%ld ", cell.index.section, cell.index.row);
if (iData) {
// 缓存
[info setValue:@"1" forKey:@"isload"];
[info setValue:iData forKey:@"data"];
}
NSString *isload = [info objectForKey:@"isload"];

if ([isload isEqualToString:@"0"]) {

dispatch_async(dispatch_get_main_queue(), ^{

cell.img.image = [UIImage imageNamed:@""];
}); }else {

if (iData) {

dispatch_async(dispatch_get_main_queue(), ^{
//显示加载后的图片
cell.img.image = [UIImage imageWithData:iData];
});
}
}

});

if (i == cells.count - 1) {

dispatch_group_notify(group, dispatch_get_main_queue(), ^{
// 全部加载完毕的通知
NSLog(@"load finished");
});
}

loadC += 1;
}
}

NSLog(@"本次加载了 %ld 张图片", loadC);
}

其次就是 loadSeeImage 调用时机的处理,我们要做到用户在滑动列表之后加载,就是在下面两处加载:

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView   {  

[self loadSeeImage];
}

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {

if (scrollView.isDragging || scrollView.isDecelerating || scrollView.isTracking) {
return;
}
[self loadSeeImage];
}

当然,首次进入页面,列表数据加载完毕后,我们也要加载一次图片的哦。 好的下面看下优化后的结果:

优化xcode.gif

优化phone.gif

CPU占用率比之前最高的时候降低了一半多,app在滑动的时候也没有明显卡顿的地方。 完美。

收起阅读 »

Swift 指针的应用

iOS
Swift与指针由于Swift本身是一门较为现代的语言,支持很多高级特性,所以对于程序员来说,大部分时候不需要用到指针这种更“底层”的特性。而Swift语言的设计者也在尽可能希望开发者能尽量少的使用指针。但是,“慎用”不代表“不能用”,更不代表“没用”。相反,...
继续阅读 »

Swift与指针

由于Swift本身是一门较为现代的语言,支持很多高级特性,所以对于程序员来说,大部分时候不需要用到指针这种更“底层”的特性。而Swift语言的设计者也在尽可能希望开发者能尽量少的使用指针。

但是,“慎用”不代表“不能用”,更不代表“没用”。相反,指针非常有用,在某些场景下还是必不可少的特性。尤其是开发工作和系统底层特性、内存处理、高性能需求息息相关时。

所以,Swift通过在施加某种限制的前提下为开发者暴露了指针的使用接口,本篇文章重点介绍Swift使用指针的相关类型、函数,以及在实践应用中灵活使用指针解决问题的技巧。

类型限定的指针 UnsafePointer

Swift通过UnsafePointer<T>来指向一个类型为T的指针,该指针的内容是 只读 的,对于一个UnsafePointer<T>变量来说,通过pointee成员即可获得T的值。

func call(_ p: UnsafePointer<Int>) {
print("\(p.pointee)")
}
var a = 1234
call(&a) // 打印:1234

以上例子中函数call接收一个UnsafePointer<Int>类型作为参数,变量a通过在变量名前面加上&将其地址传给call。函数call直接打印指针的pointee成员,该成员就是a的值,所以最终打印结果为1234

注1:&aswift提供的语法特性,用于传递指针,但它有严格的适用场景限制。

注2:注意示例中对于变量a使用了var声明,而事实上UnsafePointer是“常量指针”,并不会修改a的内容,即使是这样a还是必须用var声明,如果用let会报错Cannot pass immutable value as inout argument: 'a' is a 'let' constant。这是因为swift规定UnsafePointer作为参数只能接收inout修饰的类型,而inout修饰的类型必然是可写的,所以使用var在所难免。

内容可写的类型限定指针 UnsafeMutablePointer

既然有 内容只读 指针,必须也得有 内容可读写 指针搭配才行,在Swift中,内容可读写的类型限定指针为UnsafeMutablePointer<T>类型,就和名字描述的那样,它和UnsafePointer最大的区别就是它指向的内容是可更改的,并且更改后指向的“数据源”也会被改动。

func modify(_ p: UnsafeMutablePointer<Int>) {
p.pointee = 5678
}
var a = 1234
modify(&a)
print("\(a)") // 打印:5678

在以上的例子中,指针p指向的值被重新赋值为5678,这也使得指针的“源”,即变量a的值发生变化,最终打印a的结果可以看出a被修改为5678

指针的辅助函数 withUnsafePointer

通过函数withUnsafePointer,获得指定类型的对应指针。该函数原型如下:

func withUnsafePointer<T, Result>(to value: inout T, _ body: (UnsafePointer<T>) throws -> Result) rethrows -> Result

这个函数原型看似很复杂很长,其实只要理解它所需要的信息只有两个:

  1. 指针指向类型是什么。
  2. 想要返回的指针地址是什么。
var a = 1234
let p = withUnsafePointer(to: &a) { $0 }
print("\(p.pointee)") // 打印:1234

以上例子是withUnsafePointer最精简的调用例子,我们定义了一个整形a,而p就是指向a的整形指针,事实上它的类型会被自动转换为UnsafePointer<Int>,第二个参数被简化为了{ $0 },它传入了一个代码块,代码块接收一个UnsafePointer<Int>参数,该参数即是a的地址,直接通过$0将它返回,即得到了a的指针,最终它被传给了p

对于第二个参数,或许有人会产生疑问,它似乎是没有意义的参数,大部分时候我们不是直接返回a的地址吗,为什么要多此一举通过代码块返回一次?这个疑问是合理的,绝大多数时候确实第二个参数显得有些多余,当然了,有时候可以通过第二个参数提供指针偏移的灵活性,如下例子可以提供一个案例。

var a = [1234, 5678]
let p = withUnsafePointer(to: &a[0]) { $0 + 1 }
print("\(p.pointee)") // 打印:5678

以上例子中,通过在第二个参数中对地址施加偏移,可以原来指向数组首个元素的地址偏移到第二个地址中。

另外,由于withUnsafePointer带着两个泛型参数,这意味着第二个参数可以是不同的类型。

var a = 1234
let p = withUnsafePointer(to: &a) { $0.debugDescription }
print("\(p)")

以上例子中,withUnsafePointer返回的并不是UnsafePointer<Int>类型,甚至不是指针,而是一个字符串,字符串保存着a对应指针的debug信息。

注1:同样的,和withUnsafePointer相对应的,还有withUnsafeMutablePointer,一样是只读和可读写的区别。读者可以自行测试用法。 注2:基本上Swift指针操作的with系列函数都提供了第二个参数用来灵活的提供函数的返回类型。

获取指针并进行字节级操作 withUnsafeBytes

有时候,我们需要对某块内存进行字节级编程。比如我们用一个32位整形来表示一个32位的内存块,对内存中的每个字节进行读写操作。

通过withUnsafeBytes,可以得到某个类型的数据的字节指针,从而可以对它们进行字节级编程。

var a: UInt32 = 0x12345678
let p = withUnsafeBytes(of: &a) { $0 }
var log = ""
for item in p {
let hex = NSString(format: "%x", item)
log += "\(hex)"
}
print("\(p.count)") // 打印:4
print("\(log)") // 对于小端机器会打印:78563412

在以上例子中,withUnsafeBytes返回了一个类型UnsafeRawBufferPointer,该类型代表着一个字节级的内存块,并提供了等价于数组操作,所以你可以通过下标索引、for循环的方式来处理返回的对象。

例子中的a是一个32位整形,所以p指针的count返回的是4,单位为字节。 在本例中,对内存块p从低到高逐字节的打印每个字节的16进制值。 具体打印出来的结果因运行的机器而异,在大端机器上,打印的结果是12345678,而在小端机器上打印结果则是78563412

注:大端和小端决定了一个基础数据单元在内存中是如何按序存放的,例如小端机器会将基本数据单元的低位放在内存的低位,由低到高排列,而大端机器则相反。具体相关知识可查阅维基百科。大部分情况下,同一台机器采用的字节序列是一致的,某些CPU可以配置大小端的切换。

指向连续内存的指针 UnsafeBufferPointer

Swift的数组提供了函数withUnsafeBufferPointer,通过它我们可以方便的用指针来处理数组。如下例子:

let a: [Int32] = [1, 2, -1, -2, 5, 6]
let p = a.withUnsafeBufferPointer { $0 }
print("\(p.count)") // 打印:6
print("\(p[3])") // 打印:-2

在该例子中,通过withUnsafeBufferPointer,可以获得变量pp的类型为UnsafeBufferPointer<Int32>,它代表着一整块的连续内存,我们可以像看待数组一样看待它,并且它也支持大部分数组操作。

指针的类型转换

介绍了那么多Swift中的指针类型,每一种都有各自的用途,但是在实际开发中,很可能我们需要将一个指针类型转换为特定的指针类型。

以下例子提供了几个类型指针之间的转换

let a: [Int32] = [1, 2, -1, -2, 5, 6]
// 类型 p: UnsafeBufferPointer<Int32>
let p = a.withUnsafeBufferPointer { $0 }
// 类型 p2: UnsafePointer<UInt32>
let p2 = p.baseAddress!.withMemoryRebound(to: UInt32.self, capacity: p.count) { $0 }
// 类型 p3: UnsafeBufferPointer<UInt32>
let p3 = UnsafeBufferPointer(start: p2, count: p.count)
print("\(p3.count)") // 打印:6
print("\(p3[3])") // 打印:4294967294

以上例子中,我们获得了以下三个指针类型

  1. UnsafeBufferPointer<Int32>类型的指针p
  2. UnsafePointer<UInt32>类型的指针p2
  3. UnsafeBufferPointer<UInt32>类型的指针p3

该例子有部分细节必须讲明,首先是baseAddress,通过该成员得到UnsafeBufferPointer基地址,获得的数据类型是UnsafePointer<>

由于a指向的元数据类型是Int32,所以其baseAddress类型即是UnsafePointer<Int32>

在本例中,我们将元数据类型由Int32改为UInt32,这里用到了UnsafePointer的成员函数withMemoryRebound,通过它将UnsafePointer<Int32>转换为UnsafePointer<UInt32>

最后一部分,我们创建了一个新的指针UnsafeBufferPointer,通过其构造函数,我们让该指针的起始位置设定为p2,元素个数设定为p的元素个数,这样就成功得到了一个UnsafeBufferPointer<UInt32>类型。

接下来的打印语句,我们可以看到p3类型的count成员依然是6,而p3[3]打印的结果却是4294967294,而不是数组a对应元素的-2,这是因为从p3的角度来看,它是用UInt32类型来“看待”原先的Int32数据元素。

回调函数的实用性

前面讨论withUnsafePointer时我曾经提过第二个回调参数似乎略显鸡肋,事实上它非常有用,通过回调函数,我们可以对上一段代码进行“优化”。

let a: [Int32] = [1, 2, -1, -2, 5, 6]
// 类型 p: UnsafeBufferPointer<Int32>
let p = a.withUnsafeBufferPointer { $0 }
// 类型 p3: UnsafeBufferPointer<UInt32>
let p3 = p.baseAddress!.withMemoryRebound(to: UInt32.self, capacity: p.count) {
UnsafeBufferPointer(start: $0, count: p.count)
}
print("\(p3.count)") // 打印:6
print("\(p3[3])") // 打印:4294967294

可以看到利用回调函数,我们把原先的p2p3代码合并了,这样可以让withMemoryRebound立刻返回UnsafeBufferPointer<UInt32>类型。

注:事实上该回调还可以不断“套娃”,也就是说可以直接把p3部分的代码和p也进行合并,但是出于可读性考虑,开发者应自己根据需要选择性进行嵌套。

Swift中的空指针:UnsafeRawPointer

就像C语言有void*(即空指针)一样,Swift也有自己的空指针,它通过类型UnsafeRawPointer来获得,我们知道,空指针没有指向特定的类型,又“可以”指向任何类型,灵活性极高,也需要程序员自己能够理解和处理好对应的细节。

同样是将UnsafeBufferPointer<Int32>转换为UnsafeBufferPointer<UInt32>,以下代码通过UnsafeRawPointer来实现。

let a: [Int32] = [1, 2, -1, -2, 5, 6]
let p = a.withUnsafeBufferPointer { $0 }
let p2 = UnsafeRawPointer(p.baseAddress!).assumingMemoryBound(to: UInt32.self)
let p3 = UnsafeBufferPointer(start: p2, count: p.count)
print("\(p3.count)") // 打印:6
print("\(p3[3])") // 打印:4294967294

在该例子中我们通过空指针完成了如下操作:

  1. UnsafeRawPointer通过构造函数接收了p的“基地址”构造了一个空指针类型。
  2. 由于构造的是空指针类型,我们需要对它进行类型转换,通过assumingMemoryBound把它转换成新的数据类型UnsafePointer<UInt32>
  3. 通过UnsafeBufferPointer构造函数重新构造了一个新的指针UnsafeBufferPointer<UInt32>

通过指针动态创建、销毁内存

有时候我们需要动态开辟和管理一块内存,最后释放它,Swift提供了UnsafeMutablePointer的成员函数allocate来处理该工作。

let p = UnsafeMutablePointer<Int32>.allocate(capacity: 1)
p.initialize(to: 0) // 初始化
p.pointee = 32
print("\(p.pointee)") // 打印:32
p.deinitialize(count: 1) // 反初始化
p.deallocate()

以上例子中我们提供了一个存放32位整形的内存块,容量为1(即其容量为1个32位整形,实际就是 4 个字节)。 接下来的代码示例较为简单,即开辟内存,初始化、赋值、反初始化,释放内存的流程。

Swift指针类型和C指针类型的对应关系

Swift的指针类型看似繁多,事实上只是对C指针类型进行了封装和类别整理,并增加了一定程度上的安全性。

下表提供了SwiftC部分指针类型和函数的大致等价关系。

SwiftC描述
UnsafeMutableRawPointervoid*空指针
UnsafeMutablePointerT*类型指针
UnsafeRawPointerconst void*常量空指针
UnsafePointerconst T*常量类型指针
UnsafeMutablePointer.allocate(int32_t*)malloc分配内存

可以看出Swift的指针并不神秘,它只是映射了C语言指针的对应操作(只是乍看一下更复杂)。

进阶实践:C标准库函数的映射调用

Swift提供了大量的C标准库的桥接调用,也就是说,我们可以像调用C语言库函数一样调用Swift函数。这其中包括很多有用的函数,如memcpystrcpy等。

下面通过一段示例程序来展现这类函数的调用。

var n = 10086
// malloc
let p = malloc(MemoryLayout<Int32>.size)!
// memcpy
memcpy(p, &n, MemoryLayout<Int32>.size)
let p2 = p.assumingMemoryBound(to: Int32.self)
print("\(p2.pointee)") // 打印:10086
// strcpy
let str = "abc".cString(using: .ascii)!
if str.count != MemoryLayout<Int32>.size {
return
}
let pstr = p.assumingMemoryBound(to: CChar.self)
strcpy(pstr, str)
print("\(String(cString: pstr))") // 打印:abc
// strlen
print("\(strlen(pstr))") // 打印: 3
// memset
memset(p, 0, MemoryLayout<Int32>.size)
print("\(p2.pointee)") // 打印:0
// strcat
strcat(pstr, "h".cString(using: .ascii)!)
strcat(pstr, "i".cString(using: .ascii)!)
print("\(String(cString: pstr))") // 打印:hi
// strstr
let s = strstr(pstr, "i")!
print("\(String(cString: s))") // 打印:i
// strcmp
print("\(strcmp(pstr, "hi".cString(using: .ascii)!))") // 打印:0
// free
free(p)

以上demo提供了如memsetstrcpyC库函数原型的调用方式。通过该例子可以看出指针操作的灵活性,对于开辟的一块4个字节的内存,我们既可以把它看做一个32位整形,又可以把它看做4个ascii字符,当把它看做4个字符时,我们可以用它存放abc三个字符,并在最后一个字节用\0作为终止符。

总结

指针可以让我们用更底层的视角来看待程序和数据,在某些场景下,通过指针我们有机会开发出更高性能的代码。但同时指针的使用有时也是极复杂易出错的。如何使用好这把双刃剑,全看开发者自身的能力和态度。本文仅仅是抛砖引玉的提供了Swift指针的基本框架和使用技巧,大量细节因为篇幅原因并未提及,还需要读者自行不断研究和学习。

本文的样例代码已上传至我的github,请参见地址:github.com/FengHaiTong… 。


作者:风海铜锣
链接:https://juejin.cn/post/7030789069915291661
来源:稀土掘金

收起阅读 »

Swift热更新(1)- 免费版接入

iOS
SOT学习和使用的成本主要集中在前期,主要涉及编译流程的修改。之前介绍了纯OC项目如何接入「 OC接入例子 」。本文介绍如何给纯Swift项目接入SOT,包括免费版和网站版。本文以开源的「 SwiftMessages 」Demo为例,该工程全部用Swift语言...
继续阅读 »

SOT学习和使用的成本主要集中在前期,主要涉及编译流程的修改。之前介绍了纯OC项目如何接入「 OC接入例子 」。本文介绍如何给纯Swift项目接入SOT,包括免费版和网站版。

本文以开源的「 SwiftMessages 」Demo为例,该工程全部用Swift语言开发。这里把修改好的版本上传到了git上,分支为 「 sotdemo 」,Debug模式下接入了免费版,Release模式接入了网站版,读者也可以直接用该分支测试。

现在开始从头讲解,clone原本的工程后,命令行cd进入根目录,使用版本切换命令:git checkout 1e49de7b3780b699(因本文档制作于21年10月21号,以当日版本为准)。首先进入Demo目录,打开Demo.xcodeproj工程,scheme默认就已经选中了Demo:...

我使用的是Xcode12.4,可以直接编译成功,启动APP能看到画面(模拟器):

......

点击最上面的MESSAGE VIEW控件,会弹出一个错误提示窗口,今天我们就来用SOT热更的方式修改错误提示的文案:...

Step1: 配置编译环境

参考「 免费版 」的step1到step3,step3拷贝的sotconfig.sh放到项目的Demo的目录下:...

用文本编辑器打开sotconfig.sh,修改EnableSot=1:...

Step2: 修改编译选项

添加热更需要的编译选项,添加SOT虚拟机静态库等,步骤如下:

  1. 选中Demo工程,然后选择Demo这个Target,再选择Build Settings:...

  2. Other Linker Flags添加-sotmodule $(PRODUCT_NAME) /Users/sotsdk-1.0/libs/libsot_free.a -sotsaved $(SRCROOT)/sotsaved/$(CONFIGURATION)/$(CURRENT_ARCH) -sotconfig $(SRCROOT)/sotconfig.sh,每个选项的意义如下:

    • -sotmodule是module的名字,可以直接用$(PRODUCT_NAME),也可以自定义名字;
    • -sotsaved是编译中间产物保存的目录,补丁自动化生成需要对比前后编译的产物来生成补丁;
    • -sotconfig指定了项目sotconfig.sh的路径,该脚本控制sot编译器的工作,用$(SRCROOT)引用到
    • /Users/sotsdk-1.0/libs/libsot_free.a是SOT虚拟机静态库的路径,链接的是免费版的虚拟机
  3. Other C Flags以及Other Swift Flags添加-sotmodule $(PRODUCT_NAME) -sotconfig $(SRCROOT)/sotconfig.sh,意义跟上一步是一样的,需要保持一致。经过上面两步,相关的编译配置结果如下图:...

  4. 因为SOT SDK库文件编译时不带Bitcode,所以也需要把Target的Enable Bitcode设为No...


Step3: 增加拷贝补丁脚本

SDK里提供了一个便利脚本,路径在sdk目录的project-script/sot_package.sh,它会把生成的补丁拷贝到Bundle文件夹下,在每次项目编译成功时调用该脚本,添加步骤如下:

...

脚本内容为:sh /Users/sotsdk-1.0/project-script/sot_package.sh "$SOURCE_ROOT/sotconfig.sh" "$SOURCE_ROOT/sotsaved/$CONFIGURATION" Demo...

把Based on dependency analysis的勾去掉


Step4: 链接C++库

SOT需要压缩库和c++标准库的支持,还是在这个页面下,打开Link Binary With Libraries页...

点击加号,分别加入这两,libz.tbdlibc++.tbd...


Step5: 调用SDK API

需要用Swift代码调用OC代码,已经提供了一个样例代码在SDK的swift-call-objc目录中,可以直接添加到Demo工程中。点击Xcode软件的File按钮,接着点击Add Files to "Demo",如下图所示:...

选择到SDK目录swift-call-objc中,同时选中callsot.h和callsot.m两个文件,勾选下面的Copy items if needed,勾选Add to targets:中的Demo target,如下图所示:...

点击Add按钮,添加后会弹出询问:是否创建桥接文件。点击按钮Create Bridging Header:...

然后可以看到项目中多了3个文件,分别是callsot.h,callsot.m和Demo-Bridging-Header.h:...

打开Demo-Bridging-Header.h,加入一行代码#import "callsot.h"...

打开AppDelegate.swift,加入两行代码let sot = CallSot()sot.initSot()...


测试热更

Step1: 热更注入

按上面配置完之后,确保sotconfig.sh的配置是,EnableSot=1以及GenerateSotShip=0,先Clean Build Folder一下,然后再Build:...

然后看编译日志的输出,Link日志可以看到run sot link等输出,会告诉你每个文件里哪些函数可以被热更等信息:......

项目编译成功了,该APP可以正常启动。同时它具备了热更能力,可以加载补丁改变程序的代码逻辑,下面介绍如何生成补丁来测试它。


Step2: 生成补丁

上一步进行了热更注入的编译,当时的代码保存到了Demo/sotsaved这个文件夹下,用来和新代码比较生成补丁。生成补丁步骤如下:

  1. 首先启动SOT生成补丁模式,修改sotconfig.sh为EnableSot=1GenerateSotShip=1
  2. ...
  3. 接下来直接在Xcode里修改源代码,把ViewController.swift文件的”Something is horribly wrong!“改成了”SOT is great“:......
  4. 生成补丁跟OC项目不一样,每次都需要先Clean项目,再Build项目。然后查看编译日志输出,可以看到生成了补丁并且被脚本拷贝到了Bundle目录下,然后再展开Link Demo(x86_64)的编译日志:...可以看到此时的Link是用来生成补丁的,日志里也显示了函数demoBasics被修改了:...
  5. 生成出来的补丁原始文件保存到了Demo/sotsaved/Debug/x86_64/ship/ship.sot,还记得之前加了一个script到Build Phase中吗?它会每次编译结束时,会把这个补丁拷贝到了Bundle目录中,并且添加CPU架构到文件名中。可以在Bundle中看到这个补丁,至此完毕。

Step3: 加载补丁

启动APP,API会判断Bundle内是否有补丁,有则加载,加载成功的日志大概如下,提示有一个模块加载了热更补丁:...之后点击最上面的MESSAGE VIEW控件,发现弹出的文案变成了SOT is great:...

如果去Xcode断点调试demoBasics,会发现无法断住了,因为实际执行补丁代码的是SOT虚拟机。

顺便提一嘴,GenerateSotShip=1时,编译APP用的是保存在sotsaved目录下的代码,所以无论怎么修改Xcode里的代码,如果没有把补丁拷贝到Bundle目录里,那么APP都是最后一次GenerateSotShip=0热更注入时的样子。

如果怀疑,可以把拷贝补丁的Script脚本从Build Phases删除,可以发现怎么改代码都不会生效了。


作者:忒修斯科技
链接:https://juejin.cn/post/7026197659006287903
来源:稀土掘金

收起阅读 »

Swift开发规范

iOS
Swift开发规范前言开发规范的目的是保证统一项目成员的编码风格,并使代码美观,每个公司对于代码的规范也不尽相同,希望该份规范能给大家起到借鉴作用。本文为原创,如需转载请说明原文地址链接。命名规约代码中的命名严禁使用拼音及英文混合的方式,更不允许直接出现中文的...
继续阅读 »

Swift开发规范

前言

开发规范的目的是保证统一项目成员的编码风格,并使代码美观,每个公司对于代码的规范也不尽相同,希望该份规范能给大家起到借鉴作用。本文为原创,如需转载请说明原文地址链接。

命名规约

  • 代码中的命名严禁使用拼音及英文混合的方式,更不允许直接出现中文的方式,最好也不要使用下划线或者美元符号开头;
  • 文件名、class、struct、enum、protocol 命名统一使用 UpperCamelCase 风格;
  • 方法名、参数名、成员变量、局部变量、枚举成员统一使用 lowerCamelCase 风格
  • 全局常量命名使用 k 前缀 + UpperCamelCase 命名;
  • 扩展文件,用“原始类型名+扩展名”作为扩展文件名,其中原始类型名及扩展名也使用 UpperCamelCase 风格,如UIView+Frame.swift
  • 工程中文件夹或者 Group 统一使用 UpperCamelCase 风格,一律使用单数形式;
  • 命名中出现缩略词时,缩略词要么全部大写,要么全部小写,以首字母大小写为准,通用缩略词包括 JSON、URL 等;如class IDUtil {}func idToString() { }
  • 不要使用不规范的缩写,如 AbstractClass“缩写”命名成 AbsClass 等,不怕名称长,就怕名称不明确。
  • 文件名如果有复数含义,文件名应使用复数形式,如一些工具类;

修饰规约

  • 能用 let 修饰的时候,不要使用 var;
  • 修饰符顺序按照 注解、访问限制、static、final 顺序;
  • 尽可能利用访问限制修饰符控制类、方法等的访问限制;
  • 写方法时,要考虑这个方法是否会被重载。如果不会,标记为 final,final 会缩短编译时间;
  • 在编写库的时候需要注意修饰符的选用,遵循开闭原则;

格式规约

  • 类、函数左大括号不另起一行,与名称之间留有空格
  • 禁止使用无用分号
  • 代码中的空格出现地点
    • 注释符号与注释内容之间有空格
    • 类继承, 参数名和类型之间等, 冒号前面不加空格, 但后面跟空格
    • 任何运算符前后有空格
    • 表示返回值的 -> 两边
    • 参数列表、数组、tuple、字典里的逗号后面有一个空格
  • 方法之间空一行
  • 重载的声明放在一起,按照参数的多少从少到多向下排列
  • 每一行只声明一个变量
  • 如果是一个很长的数字时,建议使用下划线按照语言习惯三位或者四位一组分割连接。
  • 表示单例的静态属性,一般命名为 shared 或者 default
  • 如果是空的 block,直接声明{ },括号之间不需换行
  • 解包时推荐使用原有名字,前提是解包后的名字与解包前的名字在作用域上不会形成冲突
  • if 后面的 else\else if, 跟着上一个 if\else if 的右括号
  • switch 中, case 跟 switch 左对齐
  • 每行代码长度应小于 100 个字符,或者阅读时候不应该需要滚动屏幕,在正常范围内可以看到完整代码
  • 实现每个协议时, 在单独的 extension 里来实现

简略规约

  • Swift 会被结构体按照自身的成员自动生成一个非 public 的初始化方法,如果这个初始化方法刚好适合,不要自己再声明
  • 类及结构体初始化方法不要直接调用.init,直接直接省略,使用()
  • 如果只有一个 get 的计算属性,忽略 get
  • 数据定义时,尽量使用字面量形式进行自动推断,如果上下文不足以推断字面量类型时,需要声明赋值类型
  • 省略默认的访问权限(internal)
  • 过滤, 转换等, 优先使用 filter, map 等高阶函数简化代码,并尽量使用最简写
  • 使用闭包时,尽量使用最简写
  • 使用枚举属性时尽量使用自动推断,进行缩写
  • 无用的代码及时删除
  • 尽量使用各种语法糖
  • 访问实例成员或方法时尽量不要使用 self.,特殊场景除外,如构造函数时
  • 当方法无返回值时,不需添加 void

注释规约

  • 文档注释使用单行注释,即///,不使用多行注释,即/***/。 多行注释用于对某一代码段或者设计进行描述
  • 对于公开的类、方法以及属性等必须加上文档注释,方法需要加上对应的Parameter(s)ReturnsThrows 标签,强烈建议使用⌥ ⌘ /自动生成文档模板
  • 在代码中灵活的使用一些地标注释,如MARKFIXMETODO,当同一文件中存在多种类型定义或者多种逻辑时,可以使用Mark进行分组注释
  • 尽量将注释另起一行,而不是放在代码后

其他

  • 不要使用魔法值(即未经定义的常量);
  • 函数参数最多不得超过 8 个;寄存器数目问题,超过 8 个会影响效率;
  • 图形化的字面量,#colorLiteral(...)#imageLiteral(...)只能用在 playground 当做自我练习使用,禁止在项目工程中使用
  • 避免强制解包以及强制类型映射,尽量使用if let 或 guard let进行解包,禁止try!形式处理异常,避免使用隐式解包
  • 避免判断语句嵌套层次太深,使用 guard 提前返回
  • 如果 for 循环在函数体中只有一个 if 判断,使用 for where 进行替换
  • 实现每个协议时, 尽量在单独的 extension 里来实现;但需要考虑到协议的方法是否有 override 的可能,定义在 extension 的方法无法被 override,除非加上@objc 方法修改其派发方式
  • 优先创建函数而不是自定义操作符
  • 尽可能少的使用全局命名空间,如常量、变量、方法等
  • 赋值数组、字典时每个元素分别占用一行时,最后一个选项后面也添加逗号;这样未来如果有元素加入会更加方便
  • 布尔类型属性使用 is 作为属性名前缀,返回值为布尔型类型的方法名使用 is 作为方法名作为前缀
  • 类似注解的修饰词单独占一行,如@objc,@discardableResult 等
  • extension 上不用加任何修饰符,修饰符加在 extension 内的变量或方法上
  • 使用 guard 来提前结束条件,避免形成判断嵌套;
  • 善用字典去减少判断,可将条件与结果分别当做 key 及 value 存入字典中;
  • 封装时善用 assert,方便问题排查;
  • 在闭包中使用 self 时使用捕获列表[weak self]避免循环引用,闭包开始判断 self 的有效性
  • 使用委托和协议时,避免循环引用,定义属性的时候使用 weak 修饰

工具

SwiftLint 工具 提示格式错误

SwiftFormat 工具 提示并修复格式错误

两者大部分格式规范都是一致的,少许规范不一致,两个工具之间使用不冲突,可以在项目中共存。我们通过配置文件可以控制启用或者关闭相应的规则,具体使用规则参照对应仓库的 REAMME.md 文件。

相关规范

Swift 官方 API 设计指南

google 发布的 Swift 编码规范


有一个技术的圈子与一群同道众人非常重要,来我的技术公众号及博客,这里只聊技术干货。


链接:https://juejin.cn/post/6976282985695969294
收起阅读 »

? 我的独立开发的故事

iOS
🐻 我的独立开发的故事我是独立开发者熊大,最近一年尝试了独立开发的滋味,也想和大家聊一聊独立开发的心历路程。 如果你也有开发一款app的想法,那你可以看一看我的独立开发的故事。我做过直播、相机、社交类APP。个人独立app 《imi》《今日计划》2020年,我...
继续阅读 »

🐻 我的独立开发的故事

我是独立开发者熊大,最近一年尝试了独立开发的滋味,也想和大家聊一聊独立开发的心历路程。 如果你也有开发一款app的想法,那你可以看一看我的独立开发的故事。

  • 我做过直播、相机、社交类APP。
  • 个人独立app 《imi》《今日计划》
  • 2020年,我想要尝试一下独立开发的方向。

第一款app的开发周期

做第一款软件《今日计划》时,周一到周六工作,大小周,晚上会有一些开发时间。

总体如下:

  • 每天1小时写app代码 * 60 = 60小时
  • 每周周日有4个小时 * 8 = 32小时
  • 清明节三天 (按照8小时/天tian计算):3*8 = 24小时

一共约120个小时:完成了设计到上线。

我也买了阿里云的ECS,用vapor搭建了后台,维护成本有点高,果断放弃了。

当我开心的把它分享给朋友时,朋友们都说他很丑,于是被贴上一系列标签『丑』、『直男审美』、『搭配有问题』、『太简单了吧』····,总而言之,没什么好的形容词。

(PS:T M D 我自己都感觉有点坑)

 报着期望,又紧急改版一次,更换了icon,改了一些设计。也就是现在的这一版。我在圈子里又推广了一波,登顶效率榜Top20(其实是各位兄弟给面子)。

后来陆陆续续也有一些下载,但由于工作紧张,没能持续更新迭代。

离职风波

《水印相机》这款app目前,摄影榜Top20,很荣幸是我从零带到百万日活的,深知好产品的指数爆发增长。我内心真的想去外边看看,想见识更多优秀的、有趣的人,于是世界那么大,我想出去看看,真的成为了我离职的最主要理由。

从上家公司收获的最大的便是经验,一份让我受用很多年的经验。

离职后,并不缺少内推的机会,但我还没想好该怎么走接下来的路,我在思考,是去大厂深造,还是开启自由职业呢?自己一直是个骄傲的人,毕业时我的薪资就是 xx k,不能为五斗米而折腰,干脆做个自由职业好了。于是把想法讲给周围的人,最后还是找了份工作,公司就在我家的旁边,上下班5分钟。

于是从7月份开始,我就几乎每天晚上有两个小时的时间为开启我的自由职业之路做准备,只要副业收入过万,就开始全职独立开发。

新app上线

2020.08 一个小伙伴,会飞的猪,加入了开发阵营。

2020.10 小满 加了开发阵营。

(由于特殊原因,名字保密)

2021年1月上线了新的免费App《imi-成就最好的自己》,这次的app,至少在UI上取得了程序员的好评,我们还没有正式推广,只是在小圈子里发了一下动态试试水。

我们小团队也开了个新的公·众·号:《独立开发者基地》,感兴趣额可以关注。

惭愧的是,由于新公司较忙,进行了几次通宵加班后,我严重的拖累了小团队的开发进度,本来应该是2020年底就应该上线的。

《imi》

这是一款风格可爱简单的规划、计划类软件,致敬自己,致敬青春。

imi寓意:我就是我,我们一定是不完美的,也许不成功,也许不漂亮,但这就是我,与众不同。

给张图看看:

这个idea是我想的,简单说就是一个计划类软件,里边有

  • 人生节点
  • 座右铭
  • 成就
  • 笔记
  • 喜欢的人
  • 倒计时
  • 指纹解锁
  • 云同步。

设计这款软件希望能让大家觉得有用,不知道软件的初衷是不是个伪命题。让时间见证吧。

独立开发者应该都知道霸榜很久的《时间规划局》,这次《imi》就是冲着它去的,她将作为我们的竞品之一,我想我们这么有情怀的app对标这样的工具类软件,是有点希望的(怕怕)。

希望大家下载: imi-成就 给予我们支持 ^_^

给独立开发者的福利

这个应该算是福利吧,我们小团队,整理出了app的加速库,《今日计划》《imi-成就》两款app都是基于这个加速库开发的。接下来的其他app也会基于这个加速库开发,意味着我们会持续完善、维护这个加速库。里边有很多实用的功能,欢迎star🌟。

加速库SpeedySwift仓库:https://github.com/Tliens/SpeedySwift

imi 中用到的第三方库:

  # Pods for App1125
pod 'HWPanModal', '~> 0.8.1'
pod 'RealmSwift', '~> 10.5.0'
pod 'ZLPhotoBrowser', '~> 4.1.2'
pod 'SwiftDate', '~> 6.3.1'
pod 'IceCream',:path =>'Dev-pods/IceCream' # 数据同步icloud
# pod 'FSPagerView' # 轮播图
# pod 'SwiftyStoreKit' # 内购组件
pod 'Schedule', '~> 2.1.0'
pod 'Hero', '~> 1.5.0'
pod 'BiometricAuthentication'
#依赖库
pod 'UMCCommon', '~> 2.1.4'
#统计 SDK
pod 'UMCAnalytics', '~> 6.1.0'


回顾2020

get的技能:

  • 有幸能主导组件化开发
  • 函数响应式编程
  • go服务端

展望2021

希望大家健康、开心

我们会继续维护,维护今日计划、imi。也会有新的app出现。

最后

天行健君子以自强不息,地势坤君子以厚德载物。

虽大部分努力都没有收获,但热爱诞生创造的婴孩。

与君共勉!!!

写于 2021.01.13 北京·安贞门
链接:https://juejin.cn/post/6917058456184684557
收起阅读 »

Swift-Router 自己写个路由吧,第三方总是太复杂

iOS
Swift-Router 自己写个路由吧,第三方总是太复杂先看看这个路由的使用吧如果是网络地址,会直接自动跳转到 OtherWKWebViewController如果是应用内部的手动调用跳转直接跳转视图控制器EPRouter.pushViewControlle...
继续阅读 »

Swift-Router 自己写个路由吧,第三方总是太复杂

先看看这个路由的使用吧
  1. 如果是网络地址,会直接自动跳转到 OtherWKWebViewController
  2. 如果是应用内部的手动调用跳转
  • 直接跳转视图控制器
    • EPRouter.pushViewController(EPSMSLoginViewController())
  • 先在 RouteDict 注册映射关系再跳转
    • EPRouter.pushAppURLPath("goods/detail?spellId=xxx&productId=xxx")
  1. 又服务器来控制跳转 也得在 RouteDict 注册映射关系,只不过多加了一个 scheme
    • EPRouter.pushURLPath("applicationScheme://goods/detail?spellId=xxx&productId=xxx")

**!!!支持Swift、OC、Storyboard的跳转方式,可以在 loadViewController 看到实现方式 **

EPRouter的全部代码
class EPRouter: NSObject {

    private static let RouteDict:[String:String] = [
        "order/list"            :"OrderListPageViewController",   // 订单列表 segmentIndex
        "order/detail"          :"OrderDetailViewController",     // 订单详情 orderId
        "goods/detail"          :"GoodsDetailViewController",     // 商品详情productId
        "goods/list"            :"GoodsCategoryViewController", // type brandId 跳转到某个分类;跳转到某个品牌
        "goods/search"          :"SearchListViewController", // 搜索商品 text
        "coupon/list"           :"CouponListViewController",      // 优惠券列表
        "cart/list"             :"CartViewController",        // 购物车列表
        "address/list"          :"AddressListViewController",     // 收货地址列表
    ]


// 返回首页,然后指定选中模块
public static func backToTabBarController(index: NSInteger, completion:(()->())?=nil) {

guard let vc = EPCtrlManager.getTopVC(), let nav = vc.navigationController, let tabBarCtrl = nav.tabBarController  else {
return
}

nav.popToRootViewController(animated: false)
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now()+0.1) {
tabBarCtrl.selectedIndex = index
completion?()
}
}


// 销毁n个界面 不建议使用这个方法 可以在pushAppURLPath方法中设置destroyTime达到一样的效果,又可以避免用户侧滑返回
public static func popViewController(animated: Bool, time:NSInteger=1) {

guard let nav = EPCtrlManager.getTopVC()?.navigationController else {
return
}
let vcs = nav.viewControllers
let count = vcs.count
let index = (count - 1) - time
if index >= 0 {
let vc = vcs[index]
nav.popToViewController(vc, animated: true)
} else {
nav.popViewController(animated: true)
}
}


    /// 回到目标控制器
    public static func popViewController(targetVC: UIViewController.Type, animated: Bool, toRootVC: Bool=true) {

        popViewController(targetVCs: [targetVC], animated: animated, toRootVC: toRootVC)
    }

    

    /// 回到目标控制器[vc],从前到后 没有目标控制器是否回到根视图
    public static func popViewController(targetVCs: [UIViewController.Type], animated: Bool, toRootVC: Bool=true) {

        guard let nav = EPCtrlManager.getTopVC()?.navigationController else {
            return
        }
        let vcs = nav.viewControllers
        var canPop = false
        for vc in vcs {
            for tvc in targetVCs {
                if vc.isMember(of: tvc) {
                    canPop = true
                    nav.popToViewController(vc, animated: animated)
                    break
                }
            }
        }
        if !canPop && toRootVC {
            nav.popToRootViewController(animated: animated)
        }
    }

    /// push 一个vc --- destroyTime: push之前要销毁的几个压栈vc
    @objc public static func pushAppURLPath(_ path: String, query: [AnyHashable: Any]=[:], animated: Bool=true, destroyTime:NSInteger=0) {

        var urlString = "applicationScheme://"+path
        if path.contains("http://") || path.contains("https://") {
            urlString = path
        }
        pushURLString(urlString, query: query, animated: animated, destroyTime: destroyTime)
    }


    @objc public static func pushURLString(_ urlString: String, query: [AnyHashable: Any]=[:], animated: Bool=true, destroyTime:NSInteger=0) {

        guard let tvc = loadViewControllerWitURI(urlString, query: query) else {
            return
        }
        pushViewController(tvc, animated: animated, destroyTime: destroyTime)
    }


    @objc public static func pushViewController(_ tvc: UIViewController, query: [AnyHashable: Any]=[:], animated: Bool=true, destroyTime:NSInteger=0) {

        guard let vc = EPCtrlManager.getTopVC() else {
            return
        }

        if let _ = tvc.pushInfo {
            tvc.pushInfo?.merge(query, uniquingKeysWith: { (_, new) in new })
        }else {
            tvc.pushInfo = query
        }
        guard let nav = vc.navigationController else {
            vc.present(tvc, animated: true, completion: nil)
            return
        }
        tvc.hidesBottomBarWhenPushed = true

        if destroyTime > 0 {
            let vcs = nav.viewControllers
            let count = vcs.count
            var index = (count - 1) - destroyTime
            if index < 0 { // destroyTime 很多时,直接从根视图push
                index = 0
            }

            var reVCS = [UIViewController]()
            for vc in nav.viewControllers[0...index] {
                reVCS.append(vc)
            }
            reVCS.append(tvc)
            nav.setViewControllers(reVCS, animated: animated)
        }else {
            nav.pushViewController(tvc, animated: animated)
        }
    }

    public static func loadViewController(_ className: String, parameters: [AnyHashable: Any]? = nil) -> UIViewController? {

        var desVC: UIViewController?
        let spaceName = (Bundle.main.infoDictionary?["CFBundleExecutable"] as? String) ?? "ApplicationName"

        if let vc = storyboardClass(className) { // storyboard
            desVC = vc
        }else if let aClass = NSClassFromString("\(spaceName).\(className)") { // Swift
            if aClass is UIViewController.Type {
                let type = aClass as! UIViewController.Type
                desVC = type.init()
            }
        }else if let aClass = NSClassFromString("\(className)") { // OC
            if aClass is UIViewController.Type {
                let type = aClass as! UIViewController.Type
                desVC = type.init()
            }
        }

        desVC?.pushInfo = parameters
        return desVC
    }


    public static func loadViewController(_ viewController: UIViewController, parameters: [AnyHashable: Any]? = nil) -> UIViewController {

        viewController.pushInfo = parameters
        return viewController

    }

    public static func loadViewControllerWitURI(_ urlString: String, query: [AnyHashable: Any]? = nil) -> UIViewController? {

        

        // 先进行编码,防止有中文的带入, 不行进行二次编码
        var urlString = urlString
        if (URLComponents(string: urlString) == nil) {
            urlString = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? urlString
        }

        guard let url = URLComponents(string: urlString), let scheme = url.scheme else {
            HGLog("无效的地址:\(urlString)")
            return nil
        }

        if scheme == "http" || scheme == "https" {

            let webVC = OtherWKWebViewController()
            webVC._urlStr = urlString
            return webVC

        } else if String(format: "%@://", scheme) == "appcationScheme://" {
            let path = (url.host ?? "") + url.path
            guard  var vcClassName = RouteDict[path] else {
                HGLog("没有配置视图控制器呢。。。:\(urlString)")
                return nil
            }

            var info: [AnyHashable: Any]?
            if query?.count ?? 0 > 0 {
                info = [AnyHashable: Any]()
                for (key, value) in query! {
                    info![key] = value
                }
            }

            if let queryItems = url.queryItems {
                if info == nil {
                    info = [AnyHashable: Any]()
                }
                for item in queryItems {
                    if let value = item.value {
                        info![item.name] = value
                    }
                }
            }
            return loadViewController(vcClassName, parameters: info)
        }

        HGLog("未知scheme:\(urlString)")
        return nil

    }

    

    private static func storyboardClass(_ className: String) -> UIViewController? {

        if className == "VIPWithdrawViewController" { // 提现
            let vc = UIStoryboard.init(name: "VIP", bundle: nil).instantiateViewController(withIdentifier: "withdrawTVC")
            return vc
        }else if className == "VIPRecordListViewController" { // 提现记录
            let vc = UIStoryboard.init(name: "VIP", bundle: nil).instantiateViewController(withIdentifier: "recordListVC")
            return vc
        }
        return nil
    }
}

用来跳转传递数据的扩展属性
extension UIViewController {

    private struct PushAssociatedKeys {
        static var pushInfo = "pushInfo"
    }

    @objc open var pushInfo: [AnyHashable: Any]? {
        get {
            return objc_getAssociatedObject(self, &PushAssociatedKeys.pushInfo) as? [AnyHashable : Any]
        }
        set(newValue) {
            objc_setAssociatedObject(self, &PushAssociatedKeys.pushInfo, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }
}

可见视图控制器的获取
class EPCtrlManager: NSObject {

    public static let `default`: EPCtrlManager = {
        return EPCtrlManager()
    }()

    // MARK: **- 查找顶层控制器、**
    // 获取顶层控制器 根据window
    @objc public static func  getTopVC() -> UIViewController? {

        var window = UIApplication.shared.keyWindow
        //是否为当前显示的window
        if window?.windowLevel != UIWindow.Level.normal{
            let windows = UIApplication.shared.windows
            for  windowTemp in windows{
                if windowTemp.windowLevel == UIWindow.Level.normal{
                    window = windowTemp
                    break
                }
            }
        }
        let vc = window?.rootViewController
        return getTopVC(withCurrentVC: vc)
    }

    ///根据控制器获取 顶层控制器
    private static func  getTopVC(withCurrentVC VC :UIViewController?) -> UIViewController? {

        if VC == nil {
            print("🌶: 找不到顶层控制器")
            return nil
        }

        if let presentVC = VC?.presentedViewController {
            //modal出来的 控制器
            return getTopVC(withCurrentVC: presentVC)
        }else if let tabVC = VC as? UITabBarController {
            // tabBar 的跟控制器
            if let selectVC = tabVC.selectedViewController {
                return getTopVC(withCurrentVC: selectVC)
            }
            return nil
        } else if let naiVC = VC as? UINavigationController {
            // 控制器是 nav
            return getTopVC(withCurrentVC:naiVC.visibleViewController)
        } else {
            // 返回顶控制器
            return VC
        }
    }
}
收起阅读 »

Xcode 的拼写检查,你开启了吗?

iOS
Xcode 的拼写检查,你开启了吗?这是我参与11月更文挑战的第8天,活动详情查看:2021最后一次更文挑战引言作为一名开发人员,当我们编写代码时,我们会更多地关注逻辑和算法,而不是拼写和语法。但它也是我们编码的一个重要部分,特别是当我们从注释生成文档的时候。...
继续阅读 »

Xcode 的拼写检查,你开启了吗?

这是我参与11月更文挑战的第8天,活动详情查看:2021最后一次更文挑战


引言

作为一名开发人员,当我们编写代码时,我们会更多地关注逻辑和算法,而不是拼写和语法。但它也是我们编码的一个重要部分,特别是当我们从注释生成文档的时候。

拼写检查帮助我们找出拼写错误,让我们有更多的时间关注代码逻辑。


拼写检查能识别什么

答案就是代码中与Spelling and Grammer相关的所有内容

  • 变量名
  • 方法
  • 注释
  • 字符串的字面量(包括本地化)

先来看一段代码:

image.png

在上面的代码中,包括类、方法、变量和注释,但没有启用Spelling and Grammer。猛一看去,好像没啥问题,但如果我们仔细检查,就会发现很多拼写错误。

现在让我们启用Spelling and Grammer,看看会发生什么-

image.png

在上面的代码中我们可以看到,当我们启用拼写检查时,它能检测到所有的拼写错误,并用红色高亮显示。现在我们就省去了找错误的时间,可以直接去修改了。


如何开启

image.png

Edit > Format > Spelling and Grammar

可以看到有三个可用的选项,我们依次来看下:

Check Spelling While Typing

启用后,会把项目中的所有输入错误一次性、全部以红色高亮显示,就像上面的例子一样。

另外,开启这个选项后,还可以选中要修改的单词,然后右键,菜单中会出现 Xcode 建议的单词。

image.png

Check Document Now

它将在当前文件中逐个显示输入错误。为了检查当前文件中的所有错误,可以重复这个命令
Edit > Format > Spelling and Grammar > Check Document Now

或者使用快捷键
command 和分号(;)的组合

Show Spelling and Grammar

它会打开所有建议的更改。我们可以单击其中任何一个进行替换。 使用命令
Edit > Format > Spelling and Grammar > Show Spelling and Grammar

或者使用快捷键
command 和冒号(:)的组合

image.png


Learn Spelling 和 Ignore Spelling

有时候我们需要使用一些在系统词典中没有定义的独特词汇,比如应用程序前缀、开发者名称、公司名称等。Xcode 也会检查这些单词的错误。

所以就用Learn Spelling或者Ignore Spelling处理这些特殊的单词。

通过菜单

右键选中要处理的单词

image.png

通过 command + :

image.png


结语

快去探索一下 Edit > Format > Spelling and Grammar 下面的三个选项吧~

收起阅读 »

让你的 Swift 代码更 Swift

iOS
让你的 Swift 代码更 Swift这是我参与11月更文挑战的第5天,活动详情查看:2021最后一次更文挑战引言Swift 有很多其他语言所没有的独特的结构和方法,因此很多刚开始接触 Swift 的开发者并没有发挥它本身的优势。所以,我们就来看一看那些让你的...
继续阅读 »

让你的 Swift 代码更 Swift

这是我参与11月更文挑战的第5天,活动详情查看:2021最后一次更文挑战


引言

Swift 有很多其他语言所没有的独特的结构和方法,因此很多刚开始接触 Swift 的开发者并没有发挥它本身的优势。

所以,我们就来看一看那些让你的 Swift 代码更 Swift 的写法吧~


有条件的 for 循环

现在,我们要对view.subviews中的UIButton做一些不可描述的事情,用 for 循环怎么来遍历呢?

在下面的写法中,更推荐后面两种写法:


for subView in view.subviews {
if let button = subView as? UIButton {
//不可描述的事情
}
}


for case let button as UIButton in view.subviews {
//不可描述的事情
}


for button in view.subviews where button is UIButton {
//不可描述的事情
}



enumerated()

在 Swift 中进行 for 循环,要拿到下标值,一般的写法要么定义局部变量记录下标值,要么遍历 0..<view.subviews.count。其实还有个更方便的写法:enumerated(),可以一次性拿到下标值和遍历的元素。

  • ❌ 第一种肯定是不推荐的,因为还要定义额外的局部变量,容易出错,pass

  • ✅ 第二种在只需要用到下标值的时候,是可以用的,但如果还要用到下标值对应的元素,就还得再取一次,麻烦,pass

  • ✅ 第三种就比较完美,虽然一次性可以拿到下标值和元素,但其中一个用不到就可以用 _


var index: Int = 0
for subView in view.subviews {
//不可描述的事情
index += 1
}


for index in 0..<view.subviews.count {
let subView = view.subviews[index]
//不可描述的事情
}


//index 和 subView 在循环体中都能使用到
for (index, subView) in view.subviews.enumerated() {
//不可描述的事情
}

//只用到 index
for (index, _) in view.subviews.enumerated() {
//不可描述的事情
}

//只用到 subView
for (_, subView) in view.subviews.enumerated() {
//不可描述的事情
}


first(where: )

filter 是 Swift 中几个高级函数之一,过滤集合中的元素时非常的好用,不过在某些情况下,比如获取集合中满足条件的第一个元素时,有一个更好的选择first(where: )

let article1 = ArticleModel(title: "11", content: "内容1", articleID: "11111", comments: [])

let article2 = ArticleModel(title: "11", content: "内容2", articleID: "22222", comments: [])

let article3 = ArticleModel(title: "33", content: "内容3", articleID: "3333", comments: [])

let articles = [article1, article2, article3]


if let article = articles.filter({ $0.articleID == "11111" }).first {
print("\(article.title)-\(article.content)-\(article.articleID)")
}


if let article = articles.first(where: {$0.articleID == "11111"}) {
print("\(article.title)-\(article.content)-\(article.articleID)") //11-内容1-11111
}


contains(where: )

这个和上面的first(where: )几乎一样,比如这里要判断文章列表里是否包含 articleID 为 11111 的文章:


if !articles.filter({ $0.articleID == "11111" }).isEmpty {
//不可描述的事情
}


if articles.contains(where: { $0.articleID == "11111"}) {
//不可描述的事情
}


forEach

当循环体内的逻辑比较简单时,forEach 往往比 for...in...来的更加简洁:

func removeArticleBy(ID: String) {
//删库跑路
}


for article in articles {
removeArticleBy(ID: $0.articleID)
}


articles.forEach { removeArticleBy(ID: $0.articleID) }


计算属性 vs 方法

我们知道计算属性本身不存储数据,而是在 get 中返回计算后的值,在 set 中设置其他属性的值,所以和方法很类似,但比方法更简洁。一起来看下面的示例:


class YourManager {
static func shared() -> YourManager {
//不可描述的事情
}
}

let manager = YourManager.shared()


extension Date {
func formattedString() -> String {
//不可描述的事情
}
}

let string = Date().formattedString()



class YourManager {
static var shared: YourManager {
//不可描述的事情
}
}

let manager = YourManager.shared


extension Date {
var formattedString: String {
//不可描述的事情
}
}

let string = Date().formattedString


协议 vs 子类化

尽量使用协议而不是继承。协议可以让代码更加灵活,因为类可同时遵守多个协议。

此外,结构和枚举不能子类化,但是它们可以遵守协议,这就更加放大了协议的好处

Struct vs Class

尽可能使用 Struct 而不是 Class。Struct 在多线程环境中更安全,更快。

它们最主要的区别, Struct 是值类型,而 Classe 是引用类型,这意味着 Struct 的每个实例都有它自己的唯一副本,而 Class 的每个实例都有对数据的单个副本的引用。

这个链接是苹果官方的文档,解释如何在 Struct 和 Class 之间做出选择。 developer.apple.com/documentati…


结语

让我们的 Swift 代码更 Swift 的方法远不止上面这些,这里要说的是,平时写代码时,要刻意的使用 Swift 强大的特性,才能发挥它本身的价值。

而这些特性就需要大家去多看看官网的例子,或者一些主流的 Swift 第三方库,看看他们是如何运用 Swift 的特性的。

收起阅读 »

2022 年移动开发的最佳 React Native 替代方案

iOS
截至 2021 年 8 月,Android 和 iOS 平台占据移动操作系统市场份额的 99.15%。这些平台多年来一直主导着移动应用市场。结果是各种移动开发技术的兴起,包括跨平台框架。   React Native 是其中最受欢迎的一种...
继续阅读 »

截至 2021 年 8 月,Android 和 iOS 平台占据移动操作系统市场份额的 99.15%。这些平台多年来一直主导着移动应用市场。结果是各种移动开发技术的兴起,包括跨平台框架。  


image.png


React Native 是其中最受欢迎的一种。 


为什么?


React Native 允许开发人员跨平台共享多达 70% 的代码库。更快的开发、降低的成本和易于调试是该框架的一些好处。Facebook 的支持还确保 React Native 保持最佳运行状态。但是,就像其他所有框架一样,它也有其局限性。  


React Native 工程师经常面临兼容性问题和缺乏自定义模块。此外,使用此框架构建的应用程序因其近乎原生的功能而受到的性能影响较小。考虑到这一点,React Native 是一个不错的选择吗?这个问题的答案取决于您的产品要求。为了帮助您做出决定,我们编制了一份 React Native 替代方案列表,这些替代方案可为您的应用程序提供强大、便捷的功能。最后,您将能够知道要使用哪种技术。 


让我们开始吧!


需要考虑的 React Native 替代方案


原生平台:


本机应用程序编程语言是一些最流行的替代方案。它们是用于为操作系统开发移动应用程序的特定于平台的技术。此类操作系统的示例包括 Android、iOS 或 Windows。使用这些语言构建的本机应用程序往往会提供更好的性能和用户体验。开发人员对 Apple 应用程序使用 Swift 和 Objective-C,对原生 Android 应用程序使用 Java 和 Kotlin。


优点:




  • 出色的性能



这些编程语言直接与平台的底层资源交互。有了这个,开发人员可以充分利用系统的图形元素、计算功能或其他组件来构建快速执行的应用程序。 




  • 易于扩展  



在扩展应用程序的功能时,总会有遇到乏味问题的风险。本机代码减少了出现此问题的可能性。它们受 iOS 和 Android IDE 以及 SDK 工具包的支持。利用这一优势,您可以为每个平台实施基本、高级甚至最新的功能,而无需担心兼容性问题。  




  • 更容易使用



根据2021 年 Stack Overflow 开发人员调查,Swift 在其他 38 种编程语言中排名第 8。在类似的列表中,React Native 是 13 个框架中的第 9 个选择。Java 在最常用的语言中排名第 5。React Native 在 13 个最常用的框架中排名第 6。这表明这两个原生代码更易于使用和学习。使用它们来构建应用程序可以减轻中级和有经验的开发人员可能遇到的复杂性。


缺点




  • 开发成本高



Native 主要基于“一个产品,两个应用程序”的概念。因此,它可能会很昂贵,因为您需要两个对 iOS 和 Android 本机代码具有广泛知识的专业开发团队。




  • 耗时



Android 和 iOS 应用程序需要不同的代码库,这使得跨平台重用代码变得不可能。相反,每个产品都需要单独构建、测试、更新和管理。对于时间敏感的项目,这种缓慢的开发和部署过程是一个主要缺点。 




  • 稀缺人才库



尽管 Java 甚至在本机应用程序开发之外也被广泛采用,但该类别中的其他语言则相反。Stack Overflow 发现,Swift 和 Kotlin 分别被 5.1% 和 8.32% 的开发人员使用。或许,这可能归功于这些编程语言的年轻化。Objective-C 以 2.8% 位居榜首。但 React Native 遥遥领先,为 14.51%。因此,找到Swift 开发人员或其他对 Kotlin 和 Objective-C 具有广泛知识的编码人员可能会令人望而生畏。 


想阅读 React Native 和 Swift 之间的详细比较吗?阅读这篇文章


可以使用 Native Tech Stack 构建哪些应用程序/产品?


本机技术非常适合游戏应用程序、特定于操作系统的媒体播放器或其他需要完全访问设备功能的应用程序。


Flutter


image.png


Flutter 是 Google 于 2018 年创建并正式推出的一项年轻的开源技术。与 React Native 类似,Flutter 支持使用一个代码库来构建跨平台的类原生应用程序。它是用 Dart 开发的,Dart 是一种同样由 Google 提供的面向对象语言。多年来,Flutter 的受欢迎程度稳步上升,超过了其主要竞争对手 React Native。


优点




  • 更快的开发



与 React Native 一样,Flutter 允许更快的开发和部署时间。您可以从一个代码构建两个应用程序(iOS 和 Android)。它的小部件和交互式资产(例如,热重载)减轻了诸如测试和调试之类的繁琐任务。此外,Dart 是 Flutter 的编程语言。它快速、简洁,并且无需额外的抽象即可编译为本机代码。这总结了通过更短的上市时间实现快速开发和竞争优势。 




  • 优质的跨平台体验



Flutter 的 Material 和 Cupertino 小部件与 Apple 和 Google 的设计指南兼容。开发人员可以利用这些现成的 UI 元素在两个平台上构建具有令人印象深刻的界面的应用程序。更重要的是,Flutter 的渲染引擎 Skia 允许对每个像素进行完整的管理。这反过来又确保了使用 Flutter 构建的 UI 在多个平台或操作系统版本上启动时保持一致。




  • 轻松调试



使用热重载,无需重新启动整个应用程序即可查看更改。相反,Flutter 开发人员可以进行和查看实时更改,而无需在此之后重新编译代码。只需为两个平台构建一个应用程序这一事实确保检测到和修复的任何错误都将反映在两个版本中。




  • 低成本



就像使用 React Native 一样,使用 Flutter 开发应用程序的成本低于使用原生应用程序。这是因为您可以使用小型开发团队在更短的时间内为 iOS 和 Android 构建一个应用程序。  


缺点




  • 重量级应用



使用 Flutter 构建的应用程序文件很大。这些应用程序可能加载缓慢并占用空间和电池性能。为了扩大规模,开发人员可能经常使用较少的包和库,从而在某些功能上妥协。结果是质量低劣的产品。 




  • 技术不成熟



作为一个年轻的框架,Flutter 还没有广泛的资源基础。这意味着您可能找不到开发所需的第三方库和包。Flutter 不成熟的另一个缺点是它的增长潜力。未来不太有利的变化可能会给框架带来一些复杂性,使其更难管理。鉴于谷歌终止项目的历史,Flutter 也有可能不会持续下去。  




  • 对 iOS 功能的支持不佳



Flutter 允许快速、无缝地开发 Android 应用程序。但 iOS 的情况并非如此。访问平台的本机组件可能会出现问题。这使得几乎不可能实现特殊的 iOS 功能,例如引导访问或默认页面转换等简单功能。 


想阅读 React Native 和 Flutter 的详细比较吗?阅读这篇文章


Flutter 可以构建哪些应用/产品?


您可以使用 Flutter 开发需要快速或实时访问的产品。它包括客户服务、金融服务提供商、电子商务公司或任何接受当面付款的商家的应用程序。


Xamarin


image.png


另一种常见的 React Native 替代方案是 Xamarin。它是微软提供的跨平台技术。它始于 2011 年的 MonoTouch 和 Mono for Android,直到微软于 2016 年收购它。 Xamarin 使用 C# 语言和 .NET 框架来开发 iOS、Android 和 Windows 移动应用程序。 


优点




  • 快速发展



借助 Xamarin 的一种产品、一种技术堆栈方法,开发人员可以跨平台重用多达 90% 的代码。您无需在开发环境之间切换,因为您可以在 Visual Studio 中构建 Xamarin 应用程序。更重要的是,该框架允许访问所有支持平台上的公共资源。总而言之,开发时间更短,成本更低。 




  • 灵活的



Xamarin 的组件存储使开发人员可以访问跨平台的标准化 UI 控件、集成的开源库和第三方服务。借助这些广泛的资源,您可以选择多个元素或在您的应用中实现所需的功能。 




  • 出色的性能



Xamarin.Essentials 库提供对本机组件的访问。程序员可以使用 Xamarin.iOS 和 Xamarin.Android 分别构建 iOS 和 Android 应用程序。这些导致产品在性能上接近本机应用程序。React Native 在这方面并不接近。您还可以在运行时将应用程序的 UI 转换为原生元素,以确保接近原生的设计和性能。




  • 易于扩展



调试和维护更容易,因为开发人员可以从一个源代码跨平台发现和更改。此外,Xamarin 与其支持平台的 SDK 和 API 集成。一旦更改可用,这使得在 iOS 和 Android 应用程序中更新或实施新功能变得容易。  




  • 广泛的技术支持



Microsoft 提供学习资源和综合解决方案,使开发人员能够测试、监控和保护他们的应用程序。它包括 Azure 云、Xamarin Insights 和 Xamarin TestCloud。


缺点




  • 不适合图形繁重的应用程序 



在 Xamarin 中,开发人员主要可以共享业务逻辑而不是 UI 代码。这只是意味着您需要为每个平台构建一个单独的 UI。考虑到这一点,构建需要复杂动画或大量交互 UI 的游戏应用程序或其他产品会更慢且乏味。 




  • 有限的社区 



在最近的 Stack Overflow 开发人员调查中,只有 5.8% 的受访者使用 Xamarin。因此,可能很难聘请具有丰富经验和知识的Xamarin 开发人员。但是,随着框架的不断发展,这种劣势可能不会持续很长时间。如果您有紧急需求,请联系我们,让您与经过预先审查的 Xamarin 专家联系。 




  • 昂贵的许可证



Xamarin 加快了开发时间,降低了成本。但是,考虑到其 IDE(Microsoft Visual Studio)的价格,这种优势可能不那么令人印象深刻。对于商业项目,Enterprise 和 Professional 许可证是理想的选择。Enterprise 第一年的年度定价为每位用户 5,999 美元,然后续订 2,569 美元。首次专业订阅者将在以后支付 1,999 美元和 799 美元。 




  • 固有限制



尽管 Xamarin 是为原生应用开发量身定制的,但它并不是纯粹的原生应用。因此,它有几个限制。这包括对开源库的限制访问、更新或集成特定于平台的新 API 的延迟以及更大的应用程序大小。 


可以使用 Xamarin 构建哪些应用程序/产品?


Xamarin 在具有繁重逻辑或简单 UI 的应用程序上表现良好。它包括用于调查、项目管理、旅行、杂货或跟踪的应用程序。 


NativeScript


image.png


与 React Native 类似,该框架使用 JavaScript 为 iOS 和 Android 构建跨平台移动应用程序。它还支持 TypeScript、Angular 和相关框架。使用 NativeScript 构建的应用程序会生成完全原生的应用程序。 


优点




  • 原生功能



NativeScript 将 iOS 和 Android API 注入到 JS 虚拟机中,以便更容易地与原生资源集成。这使开发人员可以快速访问插件、Android SDK、iOS 依赖项管理器——Cocoapods 和其他相关技术,以构建具有本机性能的应用程序。它还带来了直观的用户界面和更好的用户体验。




  • 更广泛的开发人才



NativeScript 使用 JS 和 CSS 的一个子集,它们都是成熟的。对这些技术有一定了解的开发人员可以更快地构建本机应用程序。此外,这个 NativeScript 支持各种 JS 框架,例如 Angular、Vue.js 或 TypeScript。 




  • 更少的开发时间



使用 NativeScript 构建时,开发人员可以在模拟器屏幕上实时查看代码更改。因此,此后您无需重新编译应用程序。再加上 NativeScript 中的单一代码库方法,这意味着每次修改都可以应用于其他平台。因此,该框架提高了开发速度。 


缺点




  • 本土专业知识



根据您的项目范围,您可能需要实现高级本机功能。这需要在特定于平台的 UI 标记和元素方面具有专业知识的软件顾问




  • 插件质量不确定



虽然 NativeScript 上有几个免费插件,但并不是全部都经过验证。这使开发人员面临使用有问题的开源插件的风险,这些插件可能会导致严重的瓶颈或更糟糕的最终产品。




  • 比本机更大的应用程序大小



无论 NativeScript 应用程序与真正的 Native 多么接近,它们的大小都相对较大。NativeScript 上空白 android 项目的默认大小为 12MB。但这仍然低于 React Native 的默认 APK 大小,它可以高达 23MB 


可以使用 NativeScript 构建哪些应用程序/产品?


NativeScript 最适合需要利用硬件组件功能的实时应用程序或产品。它包括用于流媒体、实时馈送和简单游戏的应用程序。 


Ionic


image.png


Ionic 是一种 React Native 替代方案,可让您构建跨平台应用程序。这个开源 SDK 最初是基于 Apache Cordova 和 AngularJS 构建的。但后来,它增加了对 React、Vue.js 和 Angular 等其他 JS 框架的支持。 


优点




  • 原生功能




使用 Apache Cordova 和 Capacitor 插件,Ionic 可以访问移动操作系统的相机、蓝牙、麦克风、指纹扫描仪、GPS 等功能。此外,Ionic 的 UI 组件及其内置的自适应样式通过对设计进行轻微更改来确保应用程序保持原生的感觉。 




  • 跨平台体验



Ionic 利用网络标准和通用 API 为任何平台构建应用程序。有了这个,开发人员可以构建一个应用程序,然后使用一个代码库将它定制到所有支持的平台。 




  • 更短的开发时间



使用 Ionic 的预构建功能,无需为每个开发构建 UI 组件。相反,开发人员可以重用或自定义每个元素,在更短的时间内构建功能性应用程序。 


缺点




  • 不适合游戏应用



与大多数跨平台框架一样,Ionic 可能不适合具有高级图形的应用程序。这是因为 Ionic 使用 CSS,这在开发 3D 游戏应用程序时受到限制。在这种情况下,本地化可能是最好的选择。 




  • 兼容性问题



集成的本机插件可能会相互冲突,从而产生大大减慢开发过程的问题。 




  • 安全问题




开发跨平台意味着您需要同时考虑 Web 和本机移动应用程序的安全性。尽管现有解决方案可以解决此问题,但对于需要高端安全性的应用程序而言,这可能既乏味又昂贵。 


想要阅读 React Native 和 Ionic 之间的详细比较吗?阅读这篇文章


可以使用 Ionic 构建哪些应用程序/产品?


Ionic 可用于需要即时信息或类似本机功能的应用程序。这包括用于新闻、生活方式、流媒体和金融服务的应用程序。 


Apache Cordova


image.png


Apache Cordova 由 Nitobi 创建,于 2011 年被 Adobe 收购,并更名为 PhoneGap。随后,它作为 PhoneGap 的开源版本发布。Apache Cordova 使开发人员能够使用 HTML、CSS 和 JavaScript 构建移动应用程序。可以通过命令行界面 (CLI) 使用此 React Native 替代方案开发跨平台应用程序。对于接近本机的应用程序,您可以使用 Cordova 以平台为中心的工作流程。 


优点




  • 丰富的插件集



开发人员在使用 Apache Cordova 进行构建时有大量插件可供选择。这些插件提供对本机设备 API 的访问,从而更轻松地在应用程序中实现广泛的功能,以获得更好的性能和用户体验。 




  • 无障碍技能集



Cordova 使用的标准技术 JS、CSS 和 HTML 已经成熟。具有这些技术编程背景的移动开发人员可以快速适应构建 Apache Cordova 应用程序。易于找到开发人员、温和的学习曲线和快速的上市时间潜力是直接的好处。




  • 跨平台支持



本着“一次编写,随处运行”的原则,代码可以跨平台重用。这确保了应用程序可以适应任何平台的UI。此外,无需将特定于平台的编程语言作为一个代码库来学习可以胜任。


缺点




  • 特定于平台的限制



因为 Apache Cordova 应用程序不是纯原生的,它们依赖插件来利用设备的功能。这些第三方自定义插件可能不容易获得、更新或跨平台兼容。 




  • 可能需要本地开发人员



如前所述,使用 Cordova 构建的应用程序可能会遇到某些插件的兼容性问题。您可能需要可以从头开始编写自定义插件的专业本机开发人员。这转化为延长的开发时间和成本。 




  • 潜在的性能问题



使用 Cordova 的默认功能构建高性能应用程序可能很困难。这是因为其技术中存在的限制会减慢应用程序的速度。此类缺点在于其 WebView 和移动浏览器组件以及 JavaScript 中缺乏多线程功能。


可以使用 Apache Cordova 构建哪些应用程序/产品?


您可以使用 Cordova 开发结合本机组件和 WebView 以访问设备 API 的应用程序。它包括用于健身、运动、跟踪和市场的应用程序。 


Framework 7


image.png


Framework 7 是您应该考虑的另一个 React Native 替代方案。它是一个开源 HTML 框架,用于构建具有近乎本机功能的混合 Web 和移动应用程序。Framework 7 兼容 Android 和 iOS 平台。


优点




  • 反应灵敏



从基本元素到高级元素,Framework 7 具有广泛的 UI 组件。开发人员可以访问诸如延迟加载、无限滚动、复选框列表等控件。使用这些资源构建具有干净、本机界面的动态应用程序。




  • 多框架支持



Framework 7 可以与 Angular、React 和 Vue.js 等 JS 框架一起使用。这些结构为开发过程贡献了它们的力量和简单性




  • 对开发者友好



开发人员不仅限于自定义标签。在使用 Framework 7 时,他们可以轻松地使用由 JS 和 CSS 补充的纯 HTML 代码。这意味着至少具有这些语言甚至 jQuery 中级知识的程序员可以扩展。 


缺点




  • 有限的平台支持



目前,Framework 7 仅支持 iOS 和 Android 平台。希望为其他平台开发应用程序的开发人员可能会评估其他框架。




  • iOS 专用



Framework 7 最初是为 Apple 环境开发的。这开辟了在为 Android 开发时遇到渲染问题的可能性。




  • 最少的文档



用户可以轻松找到有关如何在此框架中实现任何元素集的资源。然而,大多数高级需求可能没有现成的答案,因为文档不像其他框架那样广泛。  


Framework 7 可以构建哪些应用程序/产品?


Framework 7 可用于构建依赖于设备硬件的渐进式 Web 应用程序或 iOS 和 Android 应用程序。 


jQuery Mobile


image.png


jQuery Mobile 是一个开源 JavaScript 库,用于开发跨平台移动应用程序和网站。它利用了 jQuery 的特性,jQuery 以实现动画、AJAX 和文档对象模型 (DOM) 操作的简便性和快速性而闻名。  


优点




  • 较低的学习曲线



这项技术建立在 jQuery Core 之上,大多数程序员可能已经在过去使用过它。这使得它更容易学习和使用。




  • 跨平台、跨浏览器兼容性



使用 jQuery Mobile 框架,您可以构建与流行的桌面浏览器和平台兼容的高度响应的应用程序和网站。其支持的平台包括 iOS、Android、Windows、WebOS 和 Blackberry。 




  • 出色的动画页面过渡效果



基于渐进式增强原理,jQuery Mobile 导航系统允许页面通过 Ajax 加载到 DOM。这确保了页面得到改进,然后以高质量的过渡显示。




  • 简单方便



开发人员只需几行代码即可处理 HTML 事件、AJAX 请求和 DOM 操作。这在 JavaScript 中需要更长的行。 




  • 轻量级



由于其有限的图像依赖性,jQuery Mobile 的最小大小为 40 KB。这有助于它的速度。 


缺点




  • 最小主题



jQuery 移动版中可用的 CSS 主题使自定义应用程序变得容易。然而,它们是有限的。开发人员可能会构建与使用此技术构建的其他产品不同的应用程序。




  • 使用其他框架非常耗时




jQuery Mobile 与 PhoneGap 等其他移动应用程序框架相结合,以获得更好的性能。但这会减慢开发过程。 




  • 移动设备运行速度较慢



即使在最新的 iOS 和 Android 平台上,这项技术也明显变慢。如果您希望开发一个快速的移动应用程序,您可能需要考虑其他替代方案。


可以使用 jQuery Mobile 构建哪些应用程序/产品?


jQuery Mobile 是针对旧浏览器、内容管理系统或其他需要一些动画和较少用户交互的产品的应用程序的理想选择。 


PhoneGap


image.png


渐进式 Web 应用程序 (PWA)


渐进式 Web 应用程序是应用程序软件,可以像常规网站一样在 Web 浏览器上加载和执行。它结合了 Web 功能和本机应用程序的功能(例如推送通知和对硬件功能的访问),以提供出色的用户体验。与传统应用程序不同,PWA 无法从应用程序商店安装到设备中。相反,它可以添加为用户的主屏幕。渐进式 Web 应用程序使用 HTML、JavaScript 和 CSS 等标准 Web 技术构建。 


优点:




  • 反应灵敏



PWA 可以轻松适应多种设备的屏幕尺寸,无论是平板电脑、台式机、Android 和 iOS 移动设备,还是其他直接尺寸。 




  • 安全的



利用 HTTPS,在 PWA 上广播的信息被加密。在大多数情况下,如果没有安全连接,用户将无法访问某些功能,例如地理定位。这提供了高端安全性和针对路径攻击或其他网络威胁的更多保护。 




  • 极具吸引力的用户体验



PWA 是使用渐进改进原则构建的。这些应用程序在符合标准的浏览器上提供更好的用户体验,在不符合标准的浏览器上至少提供可接受的界面。此外,这些应用程序通过现代网络标准提供本机应用程序功能和感觉。这些功能进一步丰富了移动体验。 




  • 减少对网络的依赖



构建渐进式 Web 应用程序的最大优势之一是它们能够在连接速度缓慢的情况下运行。如果用户访问过某个站点,即使没有网络,他们也可以访问该内容。这可以通过 Service Workers、缓存 API 和离线存储站点资产的客户端存储技术实现。也就是说,PWA 利用这一点来享受更快的加载速度。 




  • 易于访问和维护



作为一个基于网络的应用程序,PWA 享有更高的知名度,因为它可以被搜索引擎发现和排名,给他们更多的知名度。此外,用户无需额外安装即可轻松进行测试和升级,因为这些应用程序可以在线访问。 


缺点




  • 对硬件组件的访问受限



虽然它可以访问相当多的功能,但 PWA 无法完全使用设备的大量硬件组件。对高级相机控制、通话功能、蓝牙的支持,并且某些功能在某些设备中仍然不发达。




  • 弱 iOS 支持



iOS 设备中 PWA 的一个常见缺点是缺乏推送通知支持。这使得无法通过新内容或更新重新吸引 iOS 用户,从而导致转化次数减少。 




  • 没有可靠的第三方控制



因为 PWA 不能从应用商店下载,所以没有监管标准。因此,其大多数类本机应用程序的 UI 质量可能不一致。 


哪些应用程序/产品可以构建为渐进式 Web 应用程序?


PWA 最适用于由于网络缓慢而易于失败的软件、需要更高流量的应用程序或很少使用的应用程序。它包括为电子商务公司、叫车服务、市场代理等提供的产品。


Bootstrap


image.png


Bootstrap 是一个结合了 Javascript、CSS 和 HTML 的工具包。它广泛用于开发响应式、移动优先的网页和完全嵌入浏览器的渐进式 Web 应用程序 (PWA)。 


什么是 PWA?


渐进式 Web 应用程序是应用程序软件,可以像常规网站一样在 Web 浏览器上加载和执行。它结合了 Web 功能和本机应用程序的功能(例如推送通知和对硬件功能的访问),以提供出色的用户体验。与传统应用程序不同,PWA 无法从应用程序商店安装到设备中。相反,它可以添加为用户的主屏幕。 


优点




  • 高度响应



Bootstrap 的流体网格系统是其主要优势之一。它具有定义明确的类和各种简单的布局。一旦实施,它将在所有平台上提供一致的外观。这些组件也可以定制以匹配每个项目的设计。 




  • 广泛的文档



Bootstrap被称为“世界上最流行的 HTML、CSS 和 JS 库”,拥有丰富的文档。考虑到这一点,移动开发人员很可能会为此框架找到基本和高级问题的解决方案。 




  • 对jQuery插件的内置支持




通过这些内置插件,Bootstrap 可以从 JS API 访问更多 UI 组件。工具提示和对话框等界面也可以提高预先存在的界面的性能。 




  • 安稳



Bootstrap 的 PWA 通过 HTTPS 广播信息。在大多数情况下,如果没有安全连接,用户将无法访问某些功能,例如地理定位。这提供了针对大多数网络威胁的高端安全性和更多保护。


缺点




  • 设备功能有限



在默认模式下使用 Bootstrap 可以将几个未使用的元素和代码加载到您的项目中。这会转化为较大的应用程序大小和缓慢的加载时间。




  • 其他自定义设置




使用此框架构建需要智能手机广泛功能的 Web 应用程序并不是一个好的选择。原因是用 JS 和 Bootstrap 编写的 Web 应用程序无法完全访问设备的传感器和功能。




  • 可能对开发人员不友好



使用 Bootstrap 默认组件开发的 Web 应用看起来很相似。要自定义应用程序,您需要手动覆盖样式表。这个额外的步骤通常会破坏使用这个框架的目的。 




  • 可能对开发人员不友好



某些任务(例如访问预定义的类或自定义)可能需要更长的时间来学习。 


可以使用 Bootstrap 构建哪些应用程序/产品?


Bootstrap 主要用于设计响应式网页和网络应用程序。 


image.png


image.png


最后


在竞争激烈的移动应用程序开发世界中,错过跨多个平台构建应用程序是一个很大的风险。选择正确的替代方案可以帮助您在重要的平台上保持存在感,同时降低开发成本。


链接:https://juejin.cn/post/7036615302007750692
来源:稀土掘金
收起阅读 »

Swift 中的 Self & Self.Type & self

iOS
Swift 中的 Self & Self.Type & self这是我参与11月更文挑战的第13天,活动详情查看:2021最后一次更文挑战你可能在写代码的时候已经用过很多次 self 这个关键词了,但是你有没有想过什么是 self 呢?今天我们...
继续阅读 »

Swift 中的 Self & Self.Type & self

这是我参与11月更文挑战的第13天,活动详情查看:2021最后一次更文挑战


你可能在写代码的时候已经用过很多次 self 这个关键词了,但是你有没有想过什么是 self 呢?今天我们就来看看:

  • 什么是 self、Self 和 Self.Type?
  • 都在什么情况下使用?

self

这个大家用的比较多了,self 通常用于当你需要引用你当前所在范围内的对象时。所以,例如,如果在 Rocket 的实例方法中使用 self,在这种情况下,self 将是该 Rocket 的实例。这个很好理解~

struct Rocket {
    func launch() {
        print("10 秒内发射 \(self)")
    }
}

let rocket = Rocket()
rocket.launch() //10 秒内发射 Rocket()

但是,如果要在类方法或静态方法中使用 self,该怎么办?在这种情况下,self 不能作为对实例的引用,因为没有实例,而 self 具有当前类型的值。这是因为静态方法和类方法存在于类型本身而不是实例上。

class Dog {
    class func bark() {
        print("\(self) 汪汪汪!")
    }
}

Dog.bark() //Dog 汪汪汪!


struct Cat {
    static func meow() {
        print("\(self) 喵喵喵!")
    }
}

Cat.meow() // Cat 喵喵喵!


元类型

还有个需要注意的地方。所有的值都应该有一个类型,包括 self。就像上面提到的,静态和类方法存在于类型上,所以在这种情况下,self 就拥有了一种类型:Self.Type。比如:Dog.Type 就保存所有 Dog 的类型值。

包含其他类型的类型称为元类型

有点绕哈,简单来说,元类型 Dog.Type 不仅可以保存 Dog 类型的值,还可以保存它的所有子类的值。比如下面这个例子,其中 Labrador 是 Dog 的一个子类。

class Dog {
    class func bark() {
        print("\(self) 汪汪汪!")
    }
}

class Labrador: Dog {

}

Labrador.bark() //Labrador 汪汪汪!

如果你想将 type 本身当做一个属性,或者将其传递到函数中,那么你也可以将 type 本身作为值使用。这时候,就可以这样用:Type.self。

let dogType: Dog.Type = Labrador.self

func saySomething(dog: Dog.Type) {
    print("\(dog) 汪汪汪!")
}

saySomething(dog: dogType) // Labrador 汪汪汪!


Self

最后,就是大写 s 开头的 Self。在创建工厂方法或从协议方法返回具体类型时,非常的有用:

struct Rocket {
    func launch() {
        print("10 秒内发射 \(self)")
    }
}

extension Rocket {
    static func makeRocket() -> Self {
        return Rocket()
    }
}

protocol Factory {
    func make() -> Self
}

extension Rocket: Factory {
    func make() -> Rocket {
        return Rocket()
    }
}

收起阅读 »

iOS小技能:快速创建OEM项目app

iOS
iOS小技能:快速创建OEM项目app这是我参与11月更文挑战的第29天,活动详情查看:2021最后一次更文挑战。引言贴牌生产(英语:Original Equipment Manufacturer, OEM)因采购方可提供品牌和授权,允许制造方生产贴有该品牌的...
继续阅读 »

iOS小技能:快速创建OEM项目app

这是我参与11月更文挑战的第29天,活动详情查看:2021最后一次更文挑战

引言

贴牌生产(英语:Original Equipment Manufacturer, OEM)

因采购方可提供品牌和授权,允许制造方生产贴有该品牌的产品,所以俗称“贴牌生产”。

需求背景: SAAS平台级应用系统为一个特大商户,提供专属OEM项目,在原有通用app的基础上进行定制化开发

例如去掉开屏广告,删除部分模块,保留核心模块。更换专属app icon以及主题色

I 上架资料

  1. 用户协议及隐私政策
  2. App版本、 审核测试账号信息
  3. icon、名称、套装 ID(bundle identifier)
  4. 关键词:
  5. app描述:
  6. 技术支持网址使用:

kunnan.blog.csdn.net/article/det…

II 开发小细节

  1. 更换基础配置信息,比如消息推送证书、第三方SDK的ApiKey、启动图、用户协议及隐私政策。
  2. 接口修改:比如登录接口新增SysId请求字段用于区分新旧版、修改域名(备案信息)
  3. 废弃开屏广告pod 'GDTMobSDK' ,'4.13.26'

1.1 更换高德定位SDK的apiKey

    NSString *AMapKey = @"";
[AMapServices sharedServices].apiKey = AMapKey;


1.2 更新消息推送证书和极光的appKey

  1. Mac 上的“钥匙串访问”创建证书签名请求 (CSR)

a. 启动位于 /Applications/Utilities 中的“钥匙串访问”。

b. 选取“钥匙串访问”>“证书助理”>“从证书颁发机构请求证书”。

c. 在“证书助理”对话框中,在“用户电子邮件地址”栏位中输入电子邮件地址。

d. 在“常用名称”栏位中,输入密钥的名称 (例如,Gita Kumar Dev Key)。

e. 将“CA 电子邮件地址”栏位留空。

f. 选取“存储到磁盘”,然后点按“继续”。

help.apple.com/developer-a…

在这里插入图片描述

  1. 从developer.apple.com 后台找到对应的Identifiers创建消息推送证书,并双击aps.cer安装到本地Mac,然后从钥匙串导出P12的正式上传到极光后台。

docs.jiguang.cn//jpush/clie…在这里插入图片描述

  1. 更换appKey(极光平台应用的唯一标识)
        [JPUSHService setupWithOption:launchOptions appKey:@""
channel:@"App Store"
apsForProduction:YES
advertisingIdentifier:nil];


http://www.jiguang.cn/accounts/lo…

1.3 更换Bugly的APPId

    [Bugly startWithAppId:@""];//异常上报


1.4 app启动的新版本提示

更换appid

    [self checkTheVersionWithappid:@""];


检查版本

在这里插入图片描述


- (void)checkTheVersionWithappid:(NSString*)appid{


[QCTNetworkHelper getWithUrl:[NSString stringWithFormat:@"http://itunes.apple.com/cn/lookup?id=%@",appid] params:nil successBlock:^(NSDictionary *result) {
if ([[result objectForKey:@"results"] isKindOfClass:[NSArray class]]) {
NSArray *tempArr = [result objectForKey:@"results"];
if (tempArr.count) {


NSString *versionStr =[[tempArr objectAtIndex:0] valueForKey:@"version"];
NSString *appStoreVersion = [versionStr stringByReplacingOccurrencesOfString:@"." withString:@""] ;
if (appStoreVersion.length==2) {
appStoreVersion = [appStoreVersion stringByAppendingString:@"0"];
}else if (appStoreVersion.length==1){
appStoreVersion = [appStoreVersion stringByAppendingString:@"00"];
}

NSDictionary *infoDic=[[NSBundle mainBundle] infoDictionary];
NSString* currentVersion = [[infoDic valueForKey:@"CFBundleShortVersionString"] stringByReplacingOccurrencesOfString:@"." withString:@""];

currentVersion = [currentVersion stringByReplacingOccurrencesOfString:@"." withString:@""];
if (currentVersion.length==2) {
currentVersion = [currentVersion stringByAppendingString:@"0"];
}else if (currentVersion.length==1){
currentVersion = [currentVersion stringByAppendingString:@"00"];
}



NSLog(@"currentVersion: %@",currentVersion);


if([self compareVesionWithServerVersion:versionStr]){



UIAlertController *alertController = [UIAlertController alertControllerWithTitle:[NSString stringWithFormat:@"%@%@",QCTLocal(@"Discover_a_new_version"),versionStr] message:QCTLocal(@"Whethertoupdate") preferredStyle:UIAlertControllerStyleAlert];
// "Illtalkaboutitlater"= "稍后再说";
// "Update now" = "立即去更新";
// "Unupdate"= "取消更新";

[alertController addAction:[UIAlertAction actionWithTitle:QCTLocal(@"Illtalkaboutitlater") style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
NSLog(@"取消更新");
}]];
[alertController addAction:[UIAlertAction actionWithTitle:QCTLocal(@"Updatenow") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"itms-apps://itunes.apple.com/app/id%@",appid]];
if (@available(iOS 10.0, *)) {
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:^(BOOL success) {
}];
} else {
// Fallback on earlier vesions
[[UIApplication sharedApplication] openURL:url];
}
}]];
[[QCT_Common getCurrentVC] presentViewController:alertController animated:YES completion:nil];
}
}
}
} failureBlock:^(NSError *error) {
NSLog(@"检查版本错误: %@",error);
}];
}


see also

更多内容请关注 #小程序:iOS逆向,只为你呈现有价值的信息,专注于移动端技术研究领域;更多服务和咨询请关注#公众号:iOS逆向

链接:https://juejin.cn/post/7035926626366029837

收起阅读 »

objc_msgsend(中)方法动态决议

iOS
引入在学习本文之前我们应该了解objc_msgsend消息快速查找(上) objc_msgsend(中)消息慢速查找 当快速消息查找和消息慢速查找都也找不到imp时,苹果系统后续是怎么处理的我们一起来学习! 方法动态决议主要做了哪些事情?准...
继续阅读 »


引入

在学习本文之前我们应该了解

当快速消息查找和消息慢速查找都也找不到imp时,苹果系统后续是怎么处理的我们一起来学习! 方法动态决议主要做了哪些事情?

准备工作

resolveMethod_locked动态方法决议

1.png

  • 赋值imp = forward_imp

  • 做了个单例判断动态控制执行流程根据behavior方法只执行一次。

2.png

对象方法的动态决议

3.png

类方法的动态决议

3.png

lookUpImpOrForwardTryCache

4.png

cache_getImp

5.png

  • 苹果给与一次动态方法决议的机会来挽救APP
  • 如果是类请用resolveInstanceMethod
  • 如果是元类请用resolveClassMethod

如果都没有处理那么imp = forward_imp ,const IMP forward_imp = (IMP)_objc_msgForward_impcache;

_objc_msgForward_impcache探究

6.png

  • __objc_forward_handler主要看这个函数处理

__objc_forward_handler

7.png

代码案例分析

   int main(int argc, const char * argv[]) {
    @autoreleasepool {
        LGTeacher *p = [LGTeacher alloc];
        [t sayHappy];
[LGTeacher saygood];
    }
    return 0;
}


崩溃信息

2021-11-28 22:36:39.223567+0800 KCObjcBuild[12626:762145] +[LGTeacher sayHappy]: unrecognized selector sent to class 0x100008310

2021-11-28 22:36:39.226012+0800 KCObjcBuild[12626:762145] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '+[LGTeacher sayHappy]: unrecognized selector sent to class 0x100008310'

复制代码

动态方法决议处理对象方法找不到

代码动态决议处理imp修复崩溃

@implementation LGTeacher

-(void)text{
    NSLog(@"%s", __func__ );
}

+(void)say777{

    NSLog(@"%s", __func__ );
}

// 对象方法动态决议

+(BOOL**)resolveInstanceMethod:(SEL)sel{

    if (sel == @selector(sayHappy)) {

        IMP imp =class_getMethodImplementation(self, @selector(text));
        Method m = class_getInstanceMethod(self, @selector(text));
        const char * type = method_getTypeEncoding(m);
        return** class_addMethod(self, sel, imp, type);
    }
    return [super resolveInstanceMethod:sel];

}

//类方法动态决议

+ (BOOL)resolveClassMethod:(SEL)sel{
    if (sel == @selector(saygood)) {
        IMP  imp7 = class_getMethodImplementation(objc_getMetaClass("LGTeacher"), @selector(say777));
        Method m  = class_getInstanceMethod(objc_getMetaClass("LGTeacher"), @selector(say777));
        const char type = method_getTypeEncoding(m);
        return class_addMethod(objc_getMetaClass("LGTeacher"), sel, imp7, type);
    }

    return [super resolveClassMethod:sel];

}

@end


运行打印信息

2021-11-29 16:30:46.403671+0800 KCObjcBuild[27071:213498] -[LGTeacher text]

2021-11-29 16:30:46.404186+0800 KCObjcBuild[27071:213498] +[LGTeacher say777]

  • 找不到imp我们动态添加一个imp ,但这样处理太麻烦了。
  • 实例方法方法查找流程 类->父类->NSObject->nil
  • 类方法查找流程 元类->父类->根元类-NsObject->nil

最终都会找到NSobject.我们可以在NSObject统一处理 所以我们可以给NSObject创建个分类

@implementation NSObject (Xu)
+(BOOL)resolveInstanceMethod:(SEL)sel{
if (@selector(sayHello) == sel) {
NSLog(@"--进入%@--",NSStringFromSelector(sel));
IMP imp = class_getMethodImplementation(self , @selector(sayHello2));
Method meth = class_getInstanceMethod(self , @selector(sayHello2));
const char * type = method_getTypeEncoding(meth);
return class_addMethod(self ,sel, imp, type);;

}else if (@selector(test) == sel){
NSLog(@"--进入%@--",NSStringFromSelector(sel));
IMP imp = class_getMethodImplementation(object_getClass([self class]), @selector(newTest));
Method meth = class_getClassMethod(object_getClass([self class]) , @selector(newTest));
const char * type = method_getTypeEncoding(meth);
return class_addMethod(object_getClass([self class]) ,sel, imp, type);;
}
return NO;
}

- (void)sayHello2{
NSLog(@"--%s---",__func__);
}

+(void)newTest{
NSLog(@"--%s---",__func__);
}

@end


实例方法是类方法调用,系统都自动调用了resolveInstanceMethod方法,和上面探究的吻合。 动态方法决议优点

  • 可以统一处理方法崩溃的问题,出现方法崩溃可以上报服务器,或者跳转到首页
  • 如果项目中是不同的模块你可以根据命名不同,进行业务的区别
  • 这种方式叫切面编程熟成AOP

方法动态决议流程图

9.png

问题

  • resolveInstanceMethod为什么调用两次?
  • 统一处理方案怎么处理判断问题,可能是对象方法崩溃也可能是类方法崩溃,怎么处理?
  • 动态方法决议后苹果后续就没有处理了吗?

链接:https://juejin.cn/post/7035965819955707935
收起阅读 »

系统学习iOS动画 —— 渐变动画

iOS
系统学习iOS动画 —— 渐变动画这是我参与11月更文挑战的第22天,活动详情查看:2021最后一次更文挑战这个是希望达成的效果,主要就是下面字体的渐变动画以及右拉手势动画:先创建需要的控件:class ViewController: UIViewContro...
继续阅读 »

系统学习iOS动画 —— 渐变动画

这是我参与11月更文挑战的第22天,活动详情查看:2021最后一次更文挑战

这个是希望达成的效果,主要就是下面字体的渐变动画以及右拉手势动画:

请添加图片描述

先创建需要的控件:

class ViewController: UIViewController {
let timeLabel = UILabel()

override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
view.addSubview(timeLabel)
view.backgroundColor = .gray
timeLabel.text = "9:42"
timeLabel.font = UIFont.systemFont(ofSize: 72)
timeLabel.textColor = .white
timeLabel.frame = CGRect(x: 0, y: 100, width: timeLabel.intrinsicContentSize.width, height: timeLabel.intrinsicContentSize.height)
timeLabel.center.x = view.center.x

}


}

然后创建一个文件,然后写一个继承自UIView的类来编写动画的界面。

import UIKit
import QuartzCore

class AnimatedMaskLabel: UIView {

}

CAGradientLayer是CALayer的另一个子类,专门用于渐变的图层。这里创建一个CAGradientLayer来做渐变。这里

  • startPoint和endPoint定义了渐变的方向及其起点和终点
  • Colors是渐变的颜色数组
  • location: 每个渐变点的位置,范围 0 - 1 ,默认为0。

let gradientLayer: CAGradientLayer = {
let gradientLayer = CAGradientLayer()

// Configure the gradient here
gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
let colors = [
UIColor.yellow.cgColor,
UIColor.green.cgColor,
UIColor.orange.cgColor,
UIColor.cyan.cgColor,
UIColor.red.cgColor,
UIColor.yellow.cgColor
]
gradientLayer.colors = colors

let locations: [NSNumber] = [
0.0, 0.0, 0.0, 0.0, 0.0, 0.25
]
gradientLayer.locations = locations

return gradientLayer
}()


在layoutSubviews里面为gradient设置frame,这里设置宽度为三个屏幕宽度大小来让动画看起来更加顺滑。

 override func layoutSubviews() {
gradientLayer.frame = CGRect(
x: -bounds.size.width,
y: bounds.origin.y,
width: 3 * bounds.size.width,
height: bounds.size.height)
}

接着需要声明一个text,当text被赋值的时候,将文本渲染为图像,然后使用该图像在渐变图层上创建蒙版。

 var text: String! {
didSet {
setNeedsDisplay()

let image = UIGraphicsImageRenderer(size: bounds.size)
.image { _ in
text.draw(in: bounds, withAttributes: textAttributes)
}

let maskLayer = CALayer()
maskLayer.backgroundColor = UIColor.clear.cgColor
maskLayer.frame = bounds.offsetBy(dx: bounds.size.width, dy: 0)
maskLayer.contents = image.cgImage

gradientLayer.mask = maskLayer
}
}

这里还需要为文本创建一个文本属性

  let textAttributes: [NSAttributedString.Key: Any] = {
let style = NSMutableParagraphStyle()
style.alignment = .center
return [
.font: UIFont(
name: "HelveticaNeue-Thin",
size: 28.0)!,
.paragraphStyle: style
]
}()

最后在didMoveToWindow中添加gradientLayer为自身子view并且为gradientLayer添加动画。

  override func didMoveToWindow() {
super.didMoveToWindow()
layer.addSublayer(gradientLayer)

let gradientAnimation = CABasicAnimation(keyPath: "locations")
gradientAnimation.fromValue = [0.0, 0.0, 0.0, 0.0, 0.0, 0.25]
gradientAnimation.toValue = [0.65, 0.8, 0.85, 0.9, 0.95, 1.0]
gradientAnimation.duration = 3.0
gradientAnimation.repeatCount = Float.infinity

gradientLayer.add(gradientAnimation, forKey: nil)
}

接下来在viewController中添加这个view。 声明一个animateLabel

    let animateLabel = AnimatedMaskLabel()

之后在viewDidLoad里面添加animateLabel在子view并且设置好各属性,这样animateLabel就有一个渐变动画了。

view.addSubview(animateLabel)
animateLabel.frame = CGRect(x: 0, y: UIScreen.main.bounds.size.height - 200, width: 200, height: 40)
animateLabel.center.x = view.center.x
animateLabel.backgroundColor = .clear
animateLabel.text = "Slide to reveal"

接下来为animateLabel添加滑动手势,这里设置滑动方向为向右滑动。

   let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSlide))
swipeGesture.direction = .right
animateLabel.addGestureRecognizer(swipeGesture)


然后在响应方法里面添加动画,这里先创建一个临时变量并且让其在屏幕外面,然后第一次动画的时候让timeLabel上移,animateLabel下移,然后让image跑到屏幕中间。完了之后在创建一个动画让timeLabel和animateLabel复原,把image移动到屏幕外,然后把image移除掉。

  @objc func handleSlide() {
// reveal the meme upon successful slide
let image = UIImageView(image: UIImage(named: "meme"))
image.center = view.center
image.center.x += view.bounds.size.width
view.addSubview(image)

UIView.animate(withDuration: 0.33, delay: 0.0,
animations: {
self.timeLabel.center.y -= 200.0
self.animateLabel.center.y += 200.0
image.center.x -= self.view.bounds.size.width
},
completion: nil
)

UIView.animate(withDuration: 0.33, delay: 1.0,
animations: {
self.timeLabel.center.y += 200.0
self.animateLabel.center.y -= 200.0
image.center.x += self.view.bounds.size.width
},
completion: {_ in
image.removeFromSuperview()
}
)
}

这样动画就完成了,完整代码:

import UIKit

class ViewController: UIViewController {
let timeLabel = UILabel()
let animateLabel = AnimatedMaskLabel()

override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
view.addSubview(timeLabel)
view.addSubview(animateLabel)

view.backgroundColor = .gray
timeLabel.text = "9:42"
timeLabel.font = UIFont.systemFont(ofSize: 72)
timeLabel.textColor = .white
timeLabel.frame = CGRect(x: 0, y: 100, width: timeLabel.intrinsicContentSize.width, height: timeLabel.intrinsicContentSize.height)
timeLabel.center.x = view.center.x


animateLabel.frame = CGRect(x: 0, y: UIScreen.main.bounds.size.height - 200, width: 200, height: 40)
animateLabel.center.x = view.center.x
animateLabel.backgroundColor = .clear
animateLabel.text = "Slide to reveal"
let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSlide))
swipeGesture.direction = .right
animateLabel.addGestureRecognizer(swipeGesture)

}

@objc func handleSlide() {
// reveal the meme upon successful slide
let image = UIImageView(image: UIImage(named: "meme"))
image.center = view.center
image.center.x += view.bounds.size.width
view.addSubview(image)

UIView.animate(withDuration: 0.33, delay: 0.0,
animations: {
self.timeLabel.center.y -= 200.0
self.animateLabel.center.y += 200.0
image.center.x -= self.view.bounds.size.width
},
completion: nil
)

UIView.animate(withDuration: 0.33, delay: 1.0,
animations: {
self.timeLabel.center.y += 200.0
self.animateLabel.center.y -= 200.0
image.center.x += self.view.bounds.size.width
},
completion: {_ in
image.removeFromSuperview()
}
)
}

}


import UIKit
import QuartzCore


class AnimatedMaskLabel: UIView {

let gradientLayer: CAGradientLayer = {
let gradientLayer = CAGradientLayer()

// Configure the gradient here
gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
let colors = [
UIColor.yellow.cgColor,
UIColor.green.cgColor,
UIColor.orange.cgColor,
UIColor.cyan.cgColor,
UIColor.red.cgColor,
UIColor.yellow.cgColor
]
gradientLayer.colors = colors

let locations: [NSNumber] = [
0.0, 0.0, 0.0, 0.0, 0.0, 0.25
]
gradientLayer.locations = locations

return gradientLayer
}()

var text: String! {
didSet {
setNeedsDisplay()

let image = UIGraphicsImageRenderer(size: bounds.size)
.image { _ in
text.draw(in: bounds, withAttributes: textAttributes)
}

let maskLayer = CALayer()
maskLayer.backgroundColor = UIColor.clear.cgColor
maskLayer.frame = bounds.offsetBy(dx: bounds.size.width, dy: 0)
maskLayer.contents = image.cgImage

gradientLayer.mask = maskLayer
}
}

let textAttributes: [NSAttributedString.Key: Any] = {
let style = NSMutableParagraphStyle()
style.alignment = .center
return [
.font: UIFont(
name: "HelveticaNeue-Thin",
size: 28.0)!,
.paragraphStyle: style
]
}()

override func layoutSubviews() {
layer.borderColor = UIColor.green.cgColor
gradientLayer.frame = CGRect(
x: -bounds.size.width,
y: bounds.origin.y,
width: 3 * bounds.size.width,
height: bounds.size.height)
}

override func didMoveToWindow() {
super.didMoveToWindow()
layer.addSublayer(gradientLayer)

let gradientAnimation = CABasicAnimation(keyPath: "locations")
gradientAnimation.fromValue = [0.0, 0.0, 0.0, 0.0, 0.0, 0.25]
gradientAnimation.toValue = [0.65, 0.8, 0.85, 0.9, 0.95, 1.0]
gradientAnimation.duration = 3.0
gradientAnimation.repeatCount = Float.infinity

gradientLayer.add(gradientAnimation, forKey: nil)
}
}



收起阅读 »

iOS中加载xib

iOS
iOS中加载xib「这是我参与11月更文挑战的第27天,活动详情查看:2021最后一次更文挑战」关于 xib 或 storyboard共同点都用来描述软件界面都用 interface builder 工具来编辑本质都是转换成代码去创建控件不同点xib是轻量级的...
继续阅读 »

iOS中加载xib

「这是我参与11月更文挑战的第27天,活动详情查看:2021最后一次更文挑战

关于 xib 或 storyboard

  • 共同点
    • 都用来描述软件界面
    • 都用 interface builder 工具来编辑
    • 本质都是转换成代码去创建控件
  • 不同点
    • xib是轻量级的,用来描述局部UI界面
    • storyboard是重量级的,用来描述整个软件的多个界面,并且能够展示多个界面的跳转关系

加载xib

xib 文件在编译的后会变成 nib 文件

11975486-4f7dfbf345c0bff5.png

  • 第一种加载方式
    NSArray * xibArray = [[NSBundle mainBundle]loadNibNamed:NSStringFromClass(self) owner:nil options:nil] ;
    return xibArray[0];

  • 第二种加载方式
    UINib *nib = [UINib nibWithNibName:NSStringFromClass(self) bundle:nil];
    NSArray *xibArray = [nib instantiateWithOwner:nil options:nil];
    return xibArray[0];

    xibArray中log打印 log.png

控制器加载xib

  1. 首先需要对 xib 文件进行一些处理,打开 xib 文件

  2. 点击 "File‘s Owner",设置 Class 为 xxxViewControler 点击

  3. 右键 "Files‘s Owner",里面有个默认的IBOutlet变量view,看一下后面有没有做关联,如果没有就拉到下面的View和视图做个关联

    Files‘s Owner与View做关联

  • 第一种加载方式,传入指定的 xib(如CustomViewController)

    CustomViewController *custom = [[CustomViewController alloc]initWithNibName:@"CustomViewController" bundle:nil];

  • 第二种加载方式,不指定 xib

    CustomViewController *custom = [[CustomViewController alloc]initWithNibName:nil bundle:nil];

    • 第一步:寻找有没有和控制器类名同名的xib,如果有就去加载(XXViewController.xib)

      控制器类名同名的xib.png

    • 第二步:寻找有没有和控制器类名同名但是不带Controller的xib,如果有就去加载(XXView.xib)

      11975486-e40e19dd11cafbc5.png

    • 第三步:如果没有找到合适的 xib,就会创建一个 view(白色View,为系统自己创建的)


xib自定义控件与代码自定义的区别

这是自定义的一个 view,我们通过不同的初始化方式去判断它的执行方法

#import "CustomViw.h"
@implementation CustomViw
- (instancetype)init{
self = [super init];
if (self) {
NSLog(@"%s",__func__);
}
return self;
}

- (instancetype)initWithFrame:(CGRect)frame{
if (self = [super initWithFrame:frame]) {
NSLog(@"%s",__func__);
}
return self;
}

- (instancetype)initWithCoder:(NSCoder *)aDecoder{

if (self = [super initWithCoder:aDecoder]) {
}
NSLog(@"%s",__func__);
return self;
}

- (void)awakeFromNib{
[super awakeFromNib];
NSLog(@"%s",__func__);
}
@end

  • 通过 init 方法初始化自定义控件

    @implementation ViewController
    - (void)viewDidLoad {
    [super viewDidLoad];
    CustomViw *customView = [[CustomViw alloc] init];
    }
    @end

    log:

    通过init方法初始化自定义控件log打印.png

  • 通过加载 xib 方法初始化自定义控件

    @implementation ViewController
    - (void)viewDidLoad {
    [super viewDidLoad];
    CustomViw *customView = [[[NSBundle mainBundle]loadNibNamed:NSStringFromClass([CustomViw class]) owner:nil options:nil] lastObject];
    }
    @end

    log(打印三次是因为CustomViw的xib文件里有三个View) 通过加载xib方法初始化自定义控件log打印.png

小结:

  • 通过代码初始化自定义控件是不会自动加载xib的,它会执行 initWithFrame 和 init
  • 通过加载 xib 初始化自定义控件,仅仅执行 initWithCoder 和 awakeFromNib,如果要通过代码修改 xib 的内容,一般建议放在 awakeFromNib 方法内

控件封装

一般封装一个控件,为了让开发者方便使用,通常会在自定义的控件中编写俩个方法初始化方法,这样不管是通过 init 还是加载xib都可以实现相同的效果

#import "CustomViw.h"
@implementation CustomViw

- (instancetype)initWithFrame:(CGRect)frame{
if (self = [super initWithFrame:frame]) {
[self setup];
}
return self;
}

- (void)awakeFromNib{
[super awakeFromNib];
[self setup];
}

- (void)setup{
[self setBackgroundColor:[UIColor redColor]];
}
@end

收起阅读 »

iOS中的Storyboard

iOS
iOS中的Storyboard「这是我参与11月更文挑战的第28天,活动详情查看:2021最后一次更文挑战」关于StoryboardStoryboard 是最先在 iOS5 中引入的一项新特性,它的出现使得开发人员大幅缩减构建App用户界面所需的时间关于Sto...
继续阅读 »


iOS中的Storyboard

「这是我参与11月更文挑战的第28天,活动详情查看:2021最后一次更文挑战

关于Storyboard

Storyboard 是最先在 iOS5 中引入的一项新特性,它的出现使得开发人员大幅缩减构建App用户界面所需的时间

关于Storyboard的加载方式

  • 一般在新建工程后,我们便可以看到Xcode会默认加载 Storyboard,但是在实际开发中,我们更常用的是自己新建 Storyboard,所以,这里主要讲手动创建控制器时,加载 Storyboard 的方式

  • 通常在新建的项目中,我们首先要将Xcode加载 Storyboard 去掉

    这里写图片描述

  • 关于 Storyboard 创建控制器

    第一种:

    这里写图片描述

    self.window = [[UIWindow alloc]initWithFrame:[UIScreen mainScreen].bounds];
    UIStoryboard *sb = [UIStoryboard storyboardWithName:@"Main" bundle:nil];
    UIViewController *vc = [sb instantiateInitialViewController];
    self.window.rootViewController = vc;
    [self.window makeKeyAndVisible];

    第二种:

    这里写图片描述

    self.window = [[UIWindow alloc]initWithFrame:[UIScreen mainScreen].bounds];
    UIStoryboard *sb = [UIStoryboard storyboardWithName:@"Main" bundle:nil];
    UIViewController *vc = [sb instantiateViewControllerWithIdentifier:@"WAKAKA"];
    self.window.rootViewController = vc;
    [self.window makeKeyAndVisible];


关于UIStoryboardSegue

在 Storyboard 中,用来描述界面跳转的线,都属于 UIStoryboardSegue 的对象(简称:Segue

这里写图片描述

Segue的属性

  • 唯一标识(identifier
  • 来源控制器(sourceViewController
  • 目标控制器(destinationViewController

Segue的类型

  • 自动型(点击某控件,不需要进行某些判断可直接跳转的)

    这里写图片描述

  • 手动型(点击某控件,需要进行某些判断才跳转的) 这里写图片描述

  • 手动设置 Segue 需要设置

    这里写图片描述

    使用 perform 方法执行对应的 Segue

    //根据Identifier去storyboard中找到对应的线,之后建立一个storyboard的对象
    [self performSegueWithIdentifier:@"showinfo" sender:nil];

    如果需要做传值或跳转到不同的UI,需要在这个方法里代码实现

    - (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender{
    //比较唯一标识
    if ([segue.identifier isEqualToString:@"showInfo"]) {
    //来源控制器
    UINavigationController *nvc = segue.sourceViewController;
    //目的控制器
    ListViewController *vc = segue.destinationViewController;
    vc.info = @
    "show";
    }
    }

    链接:https://juejin.cn/post/7035408728509644814
收起阅读 »

iOS小技能: 图片的平铺和拉伸、图片的加载方式、内容模式

iOS
iOS小技能: 图片的平铺和拉伸、图片的加载方式、内容模式这是我参与11月更文挑战的第28天,活动详情查看:2021最后一次更文挑战。引言例子:按照比例显示图片全部内容,并自动适应高度I 图片的平铺和拉伸 #import "UIImage+ResizableI...
继续阅读 »

iOS小技能: 图片的平铺和拉伸、图片的加载方式、内容模式

这是我参与11月更文挑战的第28天,活动详情查看:2021最后一次更文挑战

引言

例子:按照比例显示图片全部内容,并自动适应高度

I 图片的平铺和拉伸


#import "UIImage+ResizableImage.h"

@implementation UIImage (ResizableImage)


+ (UIImage*)resizableImageWithName:(NSString *)name {
NSLog(@"%s--%@",__func__,name);
UIImage *image = [UIImage imageNamed:name];
//裁剪图片方式一:
//Creates and returns a new image object with the specified cap values.
/*right cap is calculated as width - leftCapWidth - 1
bottom cap is calculated as height - topCapWidth - 1
*/

return [image stretchableImageWithLeftCapWidth:image.size.width*0.5 topCapHeight:image.size.height*0.5];
//方式二:
// CGFloat top = image.size.width*0.5f-1;
// CGFloat left = image.size.height*0.5f-1;
// UIEdgeInsets insets = UIEdgeInsetsMake(top, left, top, left);
// UIImage *capImage = [image resizableImageWithCapInsets:insets resizingMode:UIImageResizingModeTile];
//
}




/**
CGFloat top = 0; // 顶端盖高度
CGFloat bottom = 0 ; // 底端盖高度
CGFloat left = 0; // 左端盖宽度
CGFloat right = 0; // 右端盖宽度

// UIImageResizingModeStretch:拉伸模式,通过拉伸UIEdgeInsets指定的矩形区域来填充图片
// UIImageResizingModeTile:平铺模式,通过重复显示UIEdgeInsets指定的矩形区域来填充图片


@param img <#img description#>
@param top <#top description#>
@param left <#left description#>
@param bottom <#bottom description#>
@param right <#right description#>
@return <#return value description#>
*/

- (UIImage *) resizeImage:(UIImage *) img WithTop:(CGFloat) top WithLeft:(CGFloat) left WithBottom:(CGFloat) bottom WithRight:(CGFloat) right
{
UIImage * resizeImg = [img resizableImageWithCapInsets:UIEdgeInsetsMake(self.size.height * top, self.size.width * left, self.size.height * bottom, self.size.width * right) resizingMode:UIImageResizingModeStretch];

return resizeImg;
}



//返回一个可拉伸的图片
- (UIImage *)resizeWithImageName:(NSString *)name
{
UIImage *normal = [UIImage imageNamed:name];

// CGFloat w = normal.size.width * 0.5f ;
// CGFloat h = normal.size.height *0.5f ;

CGFloat w = normal.size.width*0.8;
CGFloat h = normal.size.height*0.8;
//传入上下左右不需要拉升的编剧,只拉伸中间部分
return [normal resizableImageWithCapInsets:UIEdgeInsetsMake(h, w, h, w)];

// [normal resizableImageWithCapInsets:UIEdgeInsetsMake(<#CGFloat top#>, <#CGFloat left#>, <#CGFloat bottom#>, <#CGFloat right#>)]

// 1 = width - leftCapWidth - right
// 1 = height - topCapHeight - bottom

//传入上下左右不需要拉升的编剧,只拉伸中间部分,并且传入模式(平铺/拉伸)
// [normal :<#(UIEdgeInsets)#> resizingMode:<#(UIImageResizingMode)#>]

//只用传入左边和顶部不需要拉伸的位置,系统会算出右边和底部不需要拉升的位置。并且中间有1X1的点用于拉伸或者平铺
// 1 = width - leftCapWidth - right
// 1 = height - topCapHeight - bottom
// return [normal stretchableImageWithLeftCapWidth:w topCapHeight:h];
}




@end


II 图片的加载方式

优先选择3x图像,而不是2x图像时使用initWithContentsOfFile

 NSString *path = [[NSBundle mainBundle] pathForResource:@"smallcat" ofType:@"png"];
UIImage *image = [[UIImage alloc]initWithContentsOfFile:path];
// 在ipone5 s、iphone6和iphone6 plus都是优先加载@3x的图片,如果没有@3x的图片,就优先加载@2x的图片



  • 优先加载@2x的图片
  • [UIImage imageNamed:@"smallcat"]

iphone5s和iphone6优先加载@2x的图片,iphone6 plus是加载@3x的图片。

加载图片注意点:如果图片比较小,并且使用非常频繁,可以使用imageName:(eg icon),如果图片比较大,并且使用比较少,可以使用imageWithContentsOfFile:(eg 引导页 相册)。 imageName:

  • 1、当对象销毁的时候,图片占用的内存不会随着一起销毁,内存由系统来管理,程序员不可控制
  • 2、加载的图片,占用的内存非常大
  • 3、相同的图片不会被重复加载到内存

imageWithContentsOfFile:

  • 1、当对象销毁的时候,图片占用的内存会随着一起销毁
  • 2、加载的图片占用的内存较小

3、相同的图片如果被多次加载就会占据多个内存空间

III 内容模式

首先了解下图片的内容模式

3.1 内容模式

  • UIViewContentModeScaleToFill

拉伸图片至填充整个UIImageView,图片的显示尺寸会和imageVew的尺寸一样 。

This will scale the image inside the image view to fill the entire boundaries of the image view.

  • UIViewContentModeScaleAspectFit

图片的显示尺寸不能超过imageView尺寸大小

This will make sure the image inside the image view will have the right aspect ratio and fits inside the image view’s boundaries.

  • UIViewContentModeScaleAspectFill

按照图片的原来宽高比进行缩放(展示图片最中间的内容),配合使用 tmpView.layer.masksToBounds = YES;

This will makes sure the image inside the image view will have the right aspect ratio and fills the entire boundaries of the image view. For this value to work properly, make sure that you have set the clipsToBounds property of the image view to YES.

  • UIViewContentModeScaleToFill : 直接拉伸图片至填充整个imageView

划重点:

  1. UIViewContentModeScaleAspectFit : 按照图片的原来宽高比进行缩放(一定要看到整张图片)

使用场景:信用卡图片的展示

在这里插入图片描述

  1. UIViewContentModeScaleAspectFill : 按照图片的原来宽高比进行缩放(只能图片最中间的内容)

引导页通常采用UIViewContentModeScaleAspectFill


// 内容模式
self.contentMode = UIViewContentModeScaleAspectFill;
// 超出边框的内容都剪掉
self.clipsToBounds = YES;




3.2 例子:商品详情页的实现

  • [商品详情页(按照图片原宽高比例显示图片全部内容,并自动适应高度)

](kunnan.blog.csdn.net/article/det…)

  • 背景图片的拉伸(中间空白的小矩形设置为可拉伸,保证有形状的地方不进行缩放)
- (void)awakeFromNib
{
[super awakeFromNib];
// 拉伸
// self.backgroundView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"bg_dealcell"]];
// 平铺
// self.backgroundColor = [UIColor colorWithPatternImage:[UIImage imageNamed:@"bg_dealcell"]];


[self setAutoresizingMask:UIViewAutoresizingNone];



}




- (void)drawRect:(CGRect)rect
{
// 平铺
// [[UIImage imageNamed:@"bg_dealcell"] drawAsPatternInRect:rect];
// 拉伸
[[UIImage imageNamed:@"bg_dealcell"] drawInRect:rect];
}



背景图片的拉伸(中间空白的小矩形设置为可拉伸,保证有形状的地方不进行缩放)
UIImage *resizableImage = [image resizableImageWithCapInsets:UIEdgeInsetsMake(heightForLeftORRight, widthForTopORBottom, heightForLeftORRight, widthForTopORBottom)];

see also

只为你呈现有价值的信息,专注于移动端技术研究领域;更多服务和咨询请关注#公众号:iOS逆向

链接:https://juejin.cn/post/7035421386168336398

收起阅读 »

美团外卖iOS多端复用的推动、支撑与思考

iOS
前言美团外卖2013年11月开始起步,随后高速发展,不断刷新多项行业记录。截止至2018年5月19日,日订单量峰值已超过2000万,是全球规模最大的外卖平台。业务的快速发展对技术支撑提出了更高的要求:为线上用户提供高稳定的服务体验,保障全链路业务和系统高可用运...
继续阅读 »



前言

美团外卖2013年11月开始起步,随后高速发展,不断刷新多项行业记录。截止至2018年5月19日,日订单量峰值已超过2000万,是全球规模最大的外卖平台。业务的快速发展对技术支撑提出了更高的要求:为线上用户提供高稳定的服务体验,保障全链路业务和系统高可用运行的同时,要提升多入口业务的研发速度,推进App系统架构的合理演化,进一步提升跨部门跨地域团队之间的协作效率。

而另一方面随着用户数与订单数的高速增长,美团外卖逐渐有了流量平台的特征,兄弟业务纷纷尝试接入美团外卖进行推广和发布,期望提供统一标准化服务平台。因此,基础能力标准化,推进多端复用,同时输出成熟稳定的技术服务平台,一直是我们技术团队追求的核心目标。

多端复用的端

这里的“端”有两层意思:

  • 其一是相同业务的多入口

美团外卖在iOS下的业务入口有三个,『美团外卖』App、『美团』App的外卖频道、『大众点评』App的外卖频道。

值得一提的是:由于用户画像与产品策略差异,『大众点评』外卖频道与『美团』外卖频道和『美团外卖』虽经历技术栈融合,但业务形态区别较大,暂不考虑上层业务的复用,故这篇文章主要介绍美团系两大入口的复用。

在2015年外卖C端合并之前,美团系的两大入口由两个不同的团队研发,虽然用户感知的交互界面几乎相同,但功能实现层面的代码风格和技术栈都存在较大差异,同一需求需要在两端重复开发显然不合理。所以,我们的目标是相同功能,只需要写一次代码,做一次估时,其他端只需做少量的适配工作。

  • 其二是指平台上各个业务线

外卖不同兄弟业务线都依赖外卖基础业务,包括但不限于:地图定位、登录绑定、网络通道、异常处理、工具UI等。考虑到标准化的范畴,这些基础能力也是需要多端复用的。

img

图1 美团外卖的多端复用的目标

关于组件化

提到多端复用,不免与组件化产生联系,可以说组件化是多端复用的必要条件之一。大多数公司口中的“组件化”仅仅做到代码分库,使用Cocoapods的Podfile来管理,再在主工程把各个子库的版本号聚合起来。但是能设计一套合理的分层架构,理清依赖关系,并有一整套工具链支撑组件发版与集成的相对较少。否则组件化只会导致包体积增大,开发效率变慢,依赖关系复杂等副作用。

整体思路

A. 多端复用概念图

img

图2 多端复用概念图

多端复用的目标形态其实很好理解,就是将原有主工程中的代码抽出独立组件(Pods),然后各自工程使用Podfile依赖所需的独立组件,独立组件再通过podspec间接依赖其他独立组件。

B. 准备工作

确认多端所依赖的基层库是一致的,这里的基层库包括开源库与公司内的技术栈。

iOS中常用开源库(网络、图片、布局)每个功能基本都有一个库业界垄断,这一点是iOS相对于Android的优势。公司内也存在一些对开源库二次开发或自行研发的基础库,即技术栈。不同的大组之间技术栈可能存在一定差异。如需要复用的端之间存在差异,则需要重构使得技术栈统一。(这里建议重构,不建议适配,因为如果做的不够彻底,后续很大可能需要填坑。)

就美团而言,美团平台与点评平台作为公司两大App,历史积淀厚重。自2015年底合并以来,为了共建和沉淀公共服务,减少重复造轮子,提升研发效率,对上层业务方提供统一标准的高稳定基础能力,两大平台的底层技术栈也在不断融合。而美团外卖作为较早实践独立App,同时也是依托于两大平台App的大业务方,在外卖C端合并后的1年内,我们也做了大量底层技术栈统一的必要工作。

C. 方案选型

在演进式设计与计划式设计中的抉择。

演进式设计指随着系统的开发而做设计变更,而计划式设计是指在开发之前完全指定系统架构的设计。演进的设计,同样需要遵循架构设计的基本准则,它与计划的设计唯一的区别是设计的目标。演进的设计提倡满足客户现有的需求;而计划的设计则需要考虑未来的功能扩展。演进的设计推崇尽快地实现,追求快速确定解决方案,快速编码以及快速实现;而计划的设计则需要考虑计划的周密性,架构的完整性并保证开发过程的有条不紊。

美团外卖iOS客户端,在多端复用的立项初期面临着多个关键点:频道入口与独立应用的复用,外卖平台的搭建,兄弟业务的接入,点评外卖的协作,以及架构迁移不影响现有业务的开发等等,因此权衡后我们使用“演进式架构为主,计划式架构为辅”的设计方案。不强求历史代码一下达到终极完美架构,而是循序渐进一步一个脚印,满足现有需求的同时并保留一定的扩展性。

演进式架构推动复用

术语解释

  • Waimai:特指『美团外卖』App,泛指那些独立App形式的业务入口,一般为project。

  • Channel:特指『美团』App中的外卖频道,泛指那些以频道或者Tab形式集成在主App内的业务入口,一般为Pods。

  • Special:指将Waimai中的业务代码与原有工程分离出来,让业务代码成为一个Pods的形态。

  • 下沉:即下沉到下层,这里的“下层”指架构的基层,一般为平台层或通用层。“下沉”指将不同上层库中的代码统一并移动到下层的基层库中。

在这里先贴出动态的架构演进过程,让大家有一个宏观的概念,后续再对不同节点的经历做进一步描述。

图3 演进式架构动态图

原始复用架构

如图4所示,在过去一两年,因为技术栈等原因我们只能采用比较保守的代码复用方案。将独立业务或工具类代码沉淀为一个个“Kit”,也就是粒度较小的组件。此时分层的概念还比较模糊,并且以往的工程因历史包袱导致耦合严重、逻辑复杂,在将UGC业务剥离后发现其他的业务代码无法轻易的抽出。(此时的代码复用率只有2.4%。)

鉴于之前的准备工作已经完成,多端基础库已经一致,于是我们不再采取保守策略,丰富了一些组件化通信、解耦与过渡的手段,在分层架构上开始发力。

img

图4 原始复用架构

业务复用探索

在技术栈已统一,基础层已对齐的背景下,我们挑选外卖核心业务之一的Store(即商家容器)开始了在业务复用上的探索。如图5所示,大致可以理解为“二合一,一分三”的思路,我们从代码风格和开发思路上对两边的Store业务进行对齐,在此过程中顺势将业务类与技术(功能)类的代码分离,一些通用Domain也随之分离。随着一个个组件的拆分,我们的整体复用度有明显提升,但开发效率却意外的受到了影响。多库开发在版本的发布与集成中增加了很多人工操作:依赖冲突、lock文件冲突等问题都阻碍了我们的开发效率进一步提升,而这就是之前“关于组件化”中提到的副作用。

于是我们将自动发版与自动集成提上了日程。自动集成是将“组件开发完毕到功能合入工程主体打出测试包”之间的一系列操作自动化完成。在这之前必须完成一些前期铺垫工作——壳工程分离。img

图5 商家容器下沉时期

壳工程分离

如图6所示,壳工程顾名思义就是将原来的project中的代码全部拆出去,得到一个空壳,仅仅保留一些工程配置选项和依赖库管理文件。

为什么说壳工程是自动集成的必要条件之一?

因为自动集成涉及版本号自增,需要机器修改工程配置类文件。如果在创建二进制的过程中有新业务PR合入,会造成commit树分叉大概率产生冲突导致集成失败。抽出壳工程之后,我们的壳只关心配置选项修改(很少),与依赖版本号的变化。业务代码的正常PR流程转移到了各自的业务组件git中,以此来杜绝人工与机器的冲突。

img

图6 壳工程分离

壳工程分离的意义主要有如下几点:

  • 让职能更加明确,之前的综合层身兼数职过于繁重。

  • 为自动集成铺路,避免业务PR与机器冲突。

  • 提升效率,后续Pods往Pods移动代码比proj往Pods移动代码更快。

  • 『美团外卖』向『美团』开发环境靠齐,降低适配成本。

img

图7 壳工程分离阶段图

图7的第一张图到第二张图就是上文提到的壳工程分离,将“Waimai”所有的业务代码打包抽出,移动到过渡仓库Special,让原先的“Waimai”成为壳。

第二张图到第三张图是Pods库的内部消化。

前一阶段相当于简单粗暴的物理代码移动,后一阶段是对Pods内整块代码的梳理与分库。

内部消化对齐

在前文“多端复用概念图”的部分我们提到过,所谓的复用是让多端的project以Pods的方式接入统一的代码。我们兼容考虑保留一端代码完整性,降低回接成本,决定分Subpods使用阶段性合入达到平滑迁移。

img

图8 代码下沉方案

图8描述了多端相同模块内的代码具体是如何统一的。此时因为已经完成了壳工程分离,所以业务代码都在“Special”这样的过渡仓库中。

“Special”和“Channel”两端的模块统一大致可分为三步:平移 → 下沉 → 回接。(前提是此模块的业务上已经确定是完全一致。)

平移阶段是保留其中一端“Special”代码的完整性,以自上而下的平移方式将代码文件拷贝到另一端“Channel”中。此时前者不受任何影响,后者的代码因为新文件拷贝和原有代码存在重复。此时将旧文件重命名,并深度优先遍历新文件的依赖关系补齐文件,最终使得编译通过。然后将旧文件中的部分差异代码加到新文件中做好一定的差异化管理,最后删除旧文件。

下沉阶段是将“Channel”处理后的代码解耦并独立出来,移动到下层的Pods或下层的SubPods。此时这里的代码是既支持“Special”也支持“Channel”的。

回接阶段是让“Special”以Pods依赖的形式引用之前下沉的模块,引用后删除平移前的代码文件。(如果是在版本的间隙完成固然最好,否则需要考虑平移前的代码文件在这段时间的diff。)

实际操作中很难在有限时间内处理完一个完整的模块(例如订单模块)下沉到Pods再回接。于是选择将大模块分成一个个子模块,这些子模块平滑的下沉到SubPods,然后“Special”也只引用这个统一后的SubPods,待一个模块完全下沉完毕再拆出独立的Pods。

再总结下大量代码下沉时如何保证风险可控:

  • 联合PM,先进行业务梳理,特殊差异要标注出来。

  • 使用OClint的提前扫描依赖,做到心中有数,精准估时。

  • 以“Special”的代码风格为基准,“Channel”在对齐时仅做加法不做减法。

  • “Channel”对齐工作不影响“Special”,并且回接时工作量很小。

  • 分迭代包,QA资源提前协调。

中间件层级压平

经过前面的“内部消化”,Channel和Special中的过渡代码逐渐被分发到合适的组件,如图9所示,Special只剩下AppOnly,Channel也只剩下ChannelOnly。于是Special消亡,Channel变成打包工程。

AppOnly和ChannelOnly 与其他业务组件层级压平。上层只留下两个打包工程。

img

图9 中间件层级压平

平台层建设

如图10所示,下层是外卖基础库,WaimaiKit包含众多细分后的平台能力,Domain为通用模型,XunfeiKit为对智能语音二次开发,CTKit为对CoreText渲染框架的二次开发。

针对平台适配层而言,在差异化收敛与依赖关系梳理方面发挥重要角色,这两点在下问的“衍生问题解决中”会有详细解释。

外卖基础库加上平台适配层,整体构成了我们的外卖平台层(这是逻辑结构不是物理结构),提供了60余项通用能力,支持无差异调用。

img

图10 外卖平台层的建设

多端通用架构

此时我们把基层组件与开源组件梳理并补充上,达到多端通用架构,到这里可以说真正达到了多端复用的目标。

img

图11 多端通用架构完成

由上层不同的打包工程来控制实际需要的组件。除去两个打包工程和两个Only组件,下面的组件都已达到多端复用。对比下“Waimai”与“Channel”的业务架构图中两个黑色圆圈的部分。

img

图12 “Waimai”的业务架构

img

图13 “Channel”的业务架构

衍生问题解决

差异问题

A.需求本身的差异

三种解决策略:

  • 对于文案、数值、等一两行代码的差异我们使用 运行时宏(动态获取proj-identifier)或预编译宏(custome define)直接在方法中进行if else判断。

  • 对于方法实现的不同 使用Glue(胶水层),protocol提供相同的方法声明,用来给外部调用,在不同的载体中写不同的方法实现。

  • 对于较大差异例如两边WebView容器不一样,我们建多个文件采用文件级预编译,可预编译常规.m文件或者Category。(例如WMWebViewManeger_wm.m&WMWebViewManeger_mt.m、UITableView+WMEstimated.m&UITableView+MTEstimated.m)

进一步优化策略:

用上述三种策略虽然完成差异化管理,但差异代码散落在不同组件内难以收敛,不便于管理。有了平台适配层之后,我们将差异化判断收敛到适配层内部,对上层提供无差异调用。组件开发者在开发中不用考虑宿主差异,直接调用用通用接口。差异的判断或者后续优化在接口内部处理外部不感知。

图14给出了一个平台适配层提供通用接口修改后的例子。

img

图14 平台适配层接口示例

B.多端节奏差异

实际场景中除了需求的差异还有可能出现多端进版节奏的差异,这类差异问题我们使用分支管理模型解决。

前提条件既然要多端复用了,那需求的大方向还是会希望多端统一。一般较多的场景是:多端中A端功能最少,B端功能基本算是是A端的超集。(没有绝对的超集,A端也会有较少的差异点。)在外卖的业务中,“Channel”就是这个功能较少的一端,“Waimai”基本是“Channel”的超集。

两端的差异大致分为了这5大类9小类:

  1. 需求两端相同(1.1、提测上线时间基本相同;1.2、“Waimai”比“Channel”早3天提测 ;1.3、“Waimai”比“Channel”晚3天提测)。

  2. 需求“Waimai”先进版,“Channel”下一版进 (2.1、频道下一版就上;2.2、频道下两版本后再上)。

  3. 需求“Waimai”先进版,“Channel”不需要。

  4. 需求“Channel”先进版,“Waimai”下一版进(4.1、需要改动通用部分;4.2、只改动“ChannelOnly”的部分)。

  5. 需求“Channel”先进版,“Waimai”不需要(只改动“ChannelOnly”的部分)。

img

图15 最复杂场景下的分支模型

也不用过多纠结,图15是最复杂的场景,实际场合中很难遇到,目前的我们的业务只遇到1和2两个大类,最多2条线。

编译问题

以往的开发方式初次全量编译5分钟左右,之后就是差量编译很快。但是抽成组件后,随着部分子库版本的切换间接的增加了pod install的次数,此时高频率的3分钟、5分钟会让人难以接受。

于是在这个节点我们采用了全二进制依赖的方式,目标是在日常开发中直接引用编译后的产物减少编译时间。

img

图16 使用二进制的依赖方式

如图所示三个.a就是三个subPods,分了三种Configuration:

  1. debug/ 下是 deubg 设置编译的 x64 armv7 arm64。

  2. release/ 下是 release 设置编译的 armv7 arm64。

  3. dailybuild/ 下是 release + TEST=1编译的 armv7 arm64。

  4. 默认(在文件夹外的.a)是 debug x64 + release armv7 + release arm64。

这里有一个问题需要解决,即引用二进制带来的弊端,显而易见的就是将编译期的问题带到了运行期。某个宏修改了,但是编译完的二进制代码不感知这种改动,并且依赖版本不匹配的话,原本的方法缺失编译错误,就会带到运行期发生崩溃。解决此类问题的方法也很简单,就是在所有的打包工程中都配置了打包自动切换源码。二进制仅仅用来在开发中获得更高的效率,一旦打提测包或者发布包都会使用全源码重新编译一遍。关于切源码与切二进制是由环境变量控制拉取不同的podspec源。

并且在开发中我们支持源码与二进制的混合开发模式,我们给某个binary_pod修饰的依赖库加上标签,或者使用.patch文件,控制特定的库拉源码。一般情况下,开发者将与自己当前需求相关联的库拉源码便于Debug,不关联的库拉二进制跳过编译。

依赖问题

如图17所示,外卖有多个业务组件,公司也有很多基础Kit,不同业务组件或多或少会依赖几个Kit,所以极易形成网状依赖的局面。而且依赖的版本号可能不一致,易出现依赖冲突,一旦遇到依赖冲突需要对某一组件进行修改再重新发版来解决,很影响效率。解决方式是使用平台适配层来统一维护一套依赖库版本号,上层业务组件仅仅关心平台适配层的版本。

img

图17 平台适配层统一维护依赖

当然为了避免引入平台适配层而增加过多无用依赖的问题,我们将一些依赖较多且使用频度不高的Kit抽出subPods,支持可选的方式引入,例如IM组件。

再者就是pod install 时依赖分析慢的问题。对于壳工程而言,这是所有依赖库汇聚的地方,依赖关系写法若不科学极易在analyzing dependency中耗费大量时间。Cocoapods的依赖分析用的是Molinillo算法,链接中介绍了这个算法的实现方式,是一个具有前向检察的回溯算法。这个算法本身是没有问题的,依赖层级深只要依赖写的合理也可以达到秒开。但是如果对依赖树叶子节点的版本号控制不够严密,或中间出现了循环依赖的情况,会导致回溯算法重复执行了很多压栈和出栈操作耗费时间。美团针对此类问题的做法是维护一套“去依赖的podspec源”,这个源中的dependency节点被清空了(下图中间)。实际的所需依赖的全集在壳工程Podfile里平铺,统一维护。这么做的好处是将之前的树状依赖(下图左)压平成一层(下图右)。

img

图18 依赖数的压平

效率问题

前面我们提到了自动集成,这里展示下具体的使用方式。美团发布工程组自行研发了一套HyperLoop发版集成平台。当某个组件在创建二进制之前可自行选择集成的目标,如果多端复用了,那只需要在发版创建二进制的同时勾选多个集成的目标。发版后会自行进行一系列检查与测试,最终将代码合入主工程(修改对应壳工程的依赖版本号)。

img

图19 HyperLoop自动发版自动集成

img

图20 主工程commit message的变化

以上是“Waimai”的commit对比图。第一张图是以往的开发方式,能看出工程配置的commit与业务的commit交错堆砌。第二张图是进行壳工程分离后的commit,能看出每条message都是改了某个依赖库的版本号。第三张图是使用自动集成后的commit,能看出每条message都是画风统一且机器串行提交的。

这里又衍生出另一个问题,当我们用壳工程引Pods的方式替代了project集中式开发之后,我们的代码修改散落到了不同的组件库内。想看下主工程6.5.0版本和6.4.0版本的diff时只能看到所有依赖库版本号的diff,想看commit和code diff时必须挨个去组件库查看,在三轮提测期间这样类似的操作每天都会重复多次,很不效率。

于是我们开发了atomic diff的工具,主要原理是调git stash的接口得到版本号diff,再通过版本号和对应的仓库地址深度遍历commit,再深度遍历commit对应的文件,最后汇总,得到整体的代码diff。

img

图21 atomic diff汇总后的commit message

整套工具链对多端复用的支撑

上文中已经提到了一些自动化工具,这里整理下我们工具链的全景图。

img

图22 整套工具链

  1. 在准备阶段,我们会用OClint工具对compile_command.json文件进行处理,对将要修改的组件提前扫描依赖。

  2. 在依赖库拉取时,我们有binary_pod.rb脚本里通过对源的控制达到二进制与去依赖的效果,美团发布工程组维护了一套ios-re-sankuai.com的源用于存储remove dependency的podspec.json文件。

  3. 在依赖同步时,会通过sync_podfile定时同步主工程最新Podfile文件,来对依赖库全集的版本号进行维护。

  4. 在开发阶段,我们使用Podfile.patch工具一键对二进制/源码、远端/本地代码进行切换。

  5. 在引用本地代码开发时,子库的版本号我们不太关心,只关心主工程的版本号,我们使用beforePod和AfterPod脚本进行依赖过滤以防止依赖冲突。

  6. 在代码提交时,我们使用git squash对多条相同message的commit进行挤压。

  7. 在创建PR时,以往需要一些网页端手动操作,填写大量Reviewers,现在我们使用MTPR工具一键完成,或者根据个人喜好使用Chrome插件。

  8. 在功能合入master之前,会有一些jenkins的job进行检测。

  9. 在发版阶段,使用Hyperloop系统,一键发版操作简便。

  10. 在发版之后,可选择自动集成和联合集成的方式来打包,打包产物会自动上传到美团的“抢鲜”内测平台。

  11. 在问题跟踪时,如果需要查看主工程各个版本号间的commit message和code diff,我们有atomic diff工具深度遍历各个仓库并汇总结果。

感想总结

  • 多端复用之后对PM-RD-QA都有较大的变化,我们代码复用率由最初的2.4%*达到了*84.1%,让更多的PM投入到了新需求的吞吐中,但研发效率提升增大了QA的工作量。一个大的尝试需要RD不断与PM和QA保持沟通,选择三方都能接受的最优方案。

  • 分清主次关系,技术架构等最终是为了支撑业务,如果一个架构设计的美如画天衣无缝,但是落实到自己的业务中确不能发挥理想效果,或引来抱怨一片,那这就是个失败的设计。并且在实际开发中技术类代码修改尽量选择版本间隙合入,如果与业务开发的同学产生冲突时,都要给业务同学让路,不能影响原本的版本迭代速度。

  • 时刻对 “不合理” 和 “重复劳动”保持敏感。新增一个埋点常量要去改一下平台再发个版是否成本太大?一处订单状态的需求为什么要修改首页的Kit?实际开发中遇到别扭的地方多增加一些思考而不是硬着头皮过去,并且手动重复两次以上的操作就要思考有没有自动化的替代方案。

  • 一旦决定要做,在一些关键节点决不能手软。例如某个节点为了不Block别人,加班不可避免。在大量代码改动时也不用过于紧张,有提前预估,有Case自测,还有QA的三轮回归来保障,保持专注,放手去做就好。

作者简介

尚先,美团资深工程师。2015年加入美团,目前作为美团外卖iOS端平台化虚拟小组组长,主要负责业务架构、持续集成和工程化相关工作,致力于提升研发效率与协作效率。


作者:美团技术团队
来源:https://juejin.cn/post/6844903629753679886

收起阅读 »

iOS集成

IM 和 客服 并存开发指南—iOS篇 ...
继续阅读 »




IM 和 客服 并存开发指南—iOS篇











 如果觉得哪里描述的不清晰,可评论内指出,会不定期更新。


 一、SDK 介绍

      HelpDesk.framework 为 客服SDK(带实时音视频)

      HelpDeskLite.framework 为 客服SDK(不带实时音视频)

      Hyphenate.framework 为 IM SDK(带实时音视频)

      HyphenateLite.framework 为 IM SDK(不带实时音视频)

      环信客服SDK 基于 IM SDK 3.x , 如果同时集成 客服 和 IM,只需要在初始化、登录、登出操作时使用客服SDK 提供的相应API,IM 的其他API均不受影响。

      UI 部分集成需要分别导入 HelpDeskUI 和 IM demo 中的UI文件(也可以自定义UI)。 下面详细介绍IM 和 客服共存的开发步骤。

二、注意事项

      1、开发过程中,初始化、登录和登出,务必只使用客服访客端SDK的API。

      2、需要联系商务开通客服长连接。

           不开通长连接,会出现用户长时间(一天或几天)不使用app,再打开app会无法正常使用im相关功能的问题,报错信息一般是User is not login。

      3、IM SDK 和客服SDK 都包括了模拟器的CPU 架构,在上传到app store时需要剔除模拟器的CPU 架构,保留  armv7、arm64,参考文档:上传appstore以及打包ipa注意事项。 

三、资源准备

      到环信官网下载客服访客端的开源的商城Demo源码 + SDK,下载链接:http://www.easemob.com/download/cs  选  择“iOS SDK”下载(如下图)。

      

下载客服.png



      到环信官网下载IM的开源的Demo源码 + SDK ,下载链接:http://www.easemob.com/download/im 选择 iOS SDK(如下图)。

      

下载IM.png




下载的 IM SDK+Demo 和 客服SDK+Demo 中都有 IM 的
Hyphenate.framework 或 HyphenateLite.framework,为了保持版本的匹配,我们只使用 IM Demo 中的
UI, 而不使用 IM SDK 中 的 Hyphenate.framework 或 HyphenateLite.framework 文件。

四、集成步骤

      1、阅读客服访客端SDK集成文档,集成客服,地址:http://docs.easemob.com/cs/300visitoraccess/iossdk。 

      2、阅读 IM 的集成文档,地址:http://docs-im.easemob.com/im/ios/sdk/prepare 

      3、将 IM Demo 中的 UI 文件按照自己的需求分模块导入到工程中

      4、将 IM 的 UI 所依赖的第三方库集成到项目中(IM集成文档内有说明)

      5、在pch文件中引入 EMHeaders.h 

          #ifdef __OBJC__ 

            //包含实时音视频功能 

            #import  

            // 若不包含实时音视频,则替换为 

            // #import  

            #import "HelpDeskUI.h" 

            #import "EMHeaders.h" 

         #endif

      6、由于HelpDeskUI 和 IM UI 中都使用了 第三方库,如果工程中出现三方库重复的问题,可将重复文件删除,如果部分接口已经升级或弃用可自行升级、调整。

提供的兼容Demo介绍:

     1、Demo集成了初始化sdk、登录、退出登录、IM单聊、联系客服的简单功能,处理了第三方库冲突的问题。

     2、pch文件中的appkey等信息需要换成开发者自己的。

     3、Demo源码下载地址: https://pan.baidu.com/s/1v1TUl-fqJNLQrtsJfWYGzw 

         提取码: kukb 
收起阅读 »

ios客服云集成常见报错

注意:向自己工程中添加环信SDK和UI文件的时候,不要直接向xcode中拖拽添加,先把SDK和UI文件粘贴到自己工程的finder目录中,再从finder中向xcode中拖拽添加,避免出现找不到SDK或者UI文件的情况。   1、很多同学在首次“导入...
继续阅读 »
注意:向自己工程中添加环信SDK和UI文件的时候,不要直接向xcode中拖拽添加,先把SDK和UI文件粘贴到自己工程的finder目录中,再从finder中向xcode中拖拽添加,避免出现找不到SDK或者UI文件的情况。

 

1、很多同学在首次“导入SDK”或“更新SDK重新导入SDK”后,Xcode运行报以下的error:

dyld: Library not loaded: @rpath/Hyphenate.framework/Hyphenate

  Referenced from:
/Users/shenchong/Library/Developer/CoreSimulator/Devices/C768FE68-6E79-40C8-8AD1-FFFC434D51A9/data/Containers/Bundle/Application/41EA9A48-4DD5-4AA4-AB3F-139CFE036532/CallBackTest.app/CallBackTest

  Reason: image not found

       这个原因是工程未加载到 framework,正确的处理方式是在TARGETS → General → Embedded
Binaries 中添加HelpDesk.framework和Hyphenate.framework依赖库,且 Linked
Frameworks and Libraries中依赖库的Status必须是Required。

1访客端_image_not_found.png



 

2、运行之后,自变量为nil,这就有可能是因为上面所说的依赖库的status设置为了Optional,需要改成Required。

2访客端自变量为nil.png



 

3、打包后上传到appstore报错

(1)ERROR ITMS-90535: "Unexpected CFBundleExecutable Key. The bundle at
'Payload/toy.app/HelpDeskUIResource.bundle' does not contain a bundle
executable. If this bundle intentionally does not contain an executable,
consider removing the CFBundleExecutable key from its Info.plist and
using a CFBundlePackageType of BNDL. If this bundle is part of a
third-party framework, consider contacting the developer of the
framework for an update to address this issue."

方法:把HelpDeskUIResource.bundle里的Info.plist删掉就即可。

3访客端打包90535.png



(2)This bundle is invalid. The value for key CFBundleShortVersionString
‘1.2.2.1’in the Info.plist must be a period-separated list of at most
three non-negative integers. 

4访客端打包90060.png



把sdk里的plist文件的版本号改成3位数即可

5访客端打包1.2_.2_.1位置_.png



(3)Invalid Mach-O Format.The Mach-O in bundle
“SMYG.app/Frameworks/Hyphenate.framework” isn’t consistent with the
Mach-O in the main bundle.The main bundle Mach-O contains armv7(bitcode)
and arm64(bitcode),while the nested bundle Mach-O contains
armv7(machine code) and arm64(machine code).Verify that all of the
targets for a platform have a consistent value for the ENABLE_BITCODE
build setting.”

6访客端打包90636.png



将TARGETS-Build Settings-Enable Bitcode改为NO

7访客端打包bitcode改为NO.png



(4)还有很多同学打包失败,看不出什么原因

8访客端打包需剔除.png



那么可以先看看有没有按照文档剔除x86_64 i386两个平台

文档链接:http://docs.easemob.com/cs/300visitoraccess/iossdk#%E4%B8%8A%E4%BC%A0appstore%E4%BB%A5%E5%8F%8A%E6%89%93%E5%8C%85ipa%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9

 

4、那么剔除x86_64 i386时会遇到can't open input file的错误,这是因为cd的路径错误,把“/HelpDesk.framework”删掉。是cd到framework所在的路径,不是cd到framework

9访客端剔除cd错误.png



 

5、下图中的报错,需要创建一个pch文件,并且在pch文件添加如下判断,将环信的和自己的头文件都引入到#ifdef内部,参考文档:iOS访客端sdk集成准备工作

   #ifdef __OBJC__

   #endif

(swift项目也需这样操作)

10pch加判断1.png



11pch加判断2.png



pch加判断3.png




6、集成环信HelpDeskUI的时候,由于HelpDeskUI内部使用了第三方库,如果与开发者第三方库产生冲突,可将HelpDeskUI中冲突的第三方库删除,如果第三方库中的接口有升级的部分,请酌情进行升级。

12第三方库冲突.png



 

7、集成1.2.2版本demo中的HelpDeskUI,Masonry报错:Passing ‘CGFloat’(aka ‘double’) to parameter of incompatible type ‘__strong id’

需要在pch中添加#define MAS_SHORTHAND_GLOBALS

注意:要在#import "Masonry.h"之前添加此宏定义

13访客端Masonry报错.png



 

8、Xcode11运行demo,PSTCollectionView第三方库会有如下报错

iOS13中PSTCollectionView报错.png



标明下类型就行了,如图

iOS13中PSTCollectionView报错1.png



 

9、Xcode12.3编译报错(Building for iOS, but the linked and embedded framework......)

xcode12.3报错1_.jpg
解决方案:
e1d64313718a467a6bc19b70fadd4543.png
 或者打开xcode,左上方点击File --- Workspace Settings,按照截图修改试下(不建议)

xcode12.3报错2_.jpg
收起阅读 »

ios客服云集成常见问题

1、UI上很多地方显示英文,比如聊天页面的工具栏 把客服demo中配置的国际化文件添加到您自己的工程中。拖之前要打开国际化文件,全部选中这三个,再进行拖入。   2、进入聊天页面没有加载聊天记录 这种情况一般出现在只使用了 HDMessageView...
继续阅读 »




1、UI上很多地方显示英文,比如聊天页面的工具栏

显示英文1.png



把客服demo中配置的国际化文件添加到您自己的工程中。拖之前要打开国际化文件,全部选中这三个,再进行拖入。

显示英文2.png



 

2、进入聊天页面没有加载聊天记录

这种情况一般出现在只使用了 HDMessageViewController 没有使用 HDChatViewController 的时候

在HDMessageViewController 的 viewDidLoad 方法中, 将 [self
tableViewDidTriggerHeaderRefresh]; 的注释打开,再在这句代码之前加上
self.showRefreshHeader = YES; 

 

3、发送表情却显示字符串

访客端表情符号.png



把下面这段代码添加到appdelegate中就可以了

[[HDEmotionEscape sharedInstance] setEaseEmotionEscapePattern:@"\\[[^\\[\\]]{1,3}\\]"];

[[HDEmotionEscape sharedInstance] setEaseEmotionEscapeDictionary:[HDConvertToCommonEmoticonsHelper emotionsDictionary]];

 

4、文本消息,收发双方的布局不一样,如图

文本消息布局错误1.png



参考一下截图修改即可

文本消息布局错误2.png




5、客服能收到访客的消息,访客收不到客服的消息

(1)客服和im同时使用的话,初始化sdk、登录、登出用的是im的api会出现这种情况。必须使用客服的api。

(2)IM sdk升级为客服sdk,不兼容导致的,这种情况可以线上发起会话咨询。

      
6、发送的消息,出现在聊天页面的左侧

一般是由于当前访客没有登录或者登录失败,断点仔细检查下。

7、修改聊天页面导航栏标题
修改_title的值

ff6ff7a40cfea125e0d59e70efb131b8.png









收起阅读 »

接手一个不合格的业务线代码,我是如何去维护以及重构的

iOS
项目背景IM聊天功能作为整个产品业务功能的补充和重要支撑,相信很多的App都会集成这么一个业务功能在,很多App的的IM功能相信都是集成的第三方提供的的SDK服务。相信作为产品业务的有力支撑,IM的消息对于各个公司来说都有不同的业务需求,也就是说普通的图片、文...
继续阅读 »

项目背景

IM聊天功能作为整个产品业务功能的补充和重要支撑,相信很多的App都会集成这么一个业务功能在,很多App的的IM功能相信都是集成的第三方提供的的SDK服务。

相信作为产品业务的有力支撑,IM的消息对于各个公司来说都有不同的业务需求,也就是说普通的图片、文字、红包甚至语音这种常用的消息类型并不能有力支撑起一个IM的业务,今天说的这个的IM业务功能正式在这种背景下。

曾经接手了一个IM模块业务功能,刚开始是起因于解决线上的一个bug,于是开始梳理了一下代码逻辑,于是。。。懵逼了好久好久好久。 虽然IM的代码已经在线上跑了很久了,在我开始解决bug之前貌似有大概有大半年将近一年的的时间少有人来维护,从架构设计上、业务代码实现等点来看,可维护性不高。

梳理代码

这个IM的架构设计大概是在几年以前,长连接协议使用的是WebSocket,业务逻辑有一些复杂,目测从数据逻辑到业务逻辑再到UI逻辑,代码量可能会接近5w这个量级,所以说一开始就一行一行的看代码逻辑显然是不太理智的。

梳理第一步

第一步的主要目的是熟悉代码的主要脉络,于是我开始有序的梳理沿着数据流向梳理主干,要点如下:

  • 分析各个数据模型(model)。
  • 整理各个HTTP请求的API。
  • 整理并备注各个Notification的Key,并标记使用场景。
  • 整理各个Delegate回调函数的使用场景。
  • 整理并备注各个枚举值的含义以及使用场景。

因为此部分项目代码开发周期很久了且开发维护人员换了好几茬之后,代码量大且逻辑比较混乱,在一开始梳理的时候大部分时间都花在了备注各种代码上。

梳理第二步

第一步之后,我其实已经对于IM的架构逻辑开始有了一个初步的比较宽泛的了解。第二步的的主要目的是整理在使用的主要的几个组件:

  • 数据库的初始化创建以及使用逻辑。
  • HTTP代码的初始化创建以及使用逻辑。
  • 长连接代码的初始化创建以及使用逻辑,特别是与服务端沟通和保活的部分。
  • 针对以上基础控件的二次封装控件的整理。

梳理了上面几个之后,我陆续整理了如下:

  • 数据库的表结构设计、初始化创建以及销毁等逻辑。
  • 了解HTTP的接口功能,进一步了解了基础的业务设计。
  • 通过和服务端的同事沟通,明确了在当前的长连接协议下,两端是如何保活、沟通数据以及沟通各种状态的
  • 各个API的使用场景以及使用逻辑。

梳理第三步

上面两步,基本上把最核心的工具整理完成,下面就开始将代码逻辑串起来,整理业务逻辑:

  • 群聊的收发消息逻辑。
  • 私聊的收发消息逻辑。
  • 登录、退出登录、更换账号登录以及绑定账号之后的业务逻辑。
  • 自后台唤醒之后重连以及获取最新消息等的业务逻辑。
  • 收到推送消息之后的业务逻辑。

整理好了以上之后,我利用流程图工具ProcessOn创建了大概有10张左右的流程表,数据流向和业务逻辑一清二楚。

特别是聊天的数据流转逻辑,从HTTP请求、长连接推送以及数据库操作,无所不包。但是图整理完之后,对于主要流程几乎是掌握的比较清楚了,即使回头有忘记的,回头查看表之后就会一清二楚,不仅便于我熟悉逻辑,对以后的维护也很有益处。

维护

遇到的困境

虽然代码质量较差,但是代码中的bug还算比较少,所以在解决线上bug这个问题上,没有遇到过多的麻烦。但是其中也有几个bug十分麻烦,找了好久才找到问题的原因。

其中有一个,找原因大概找了一周多的时间,最后定位问题在我们的账号系统上。因为历史原因,我们的IM业务和其他业务是两套账号体系,中间经历过一次账号变更,但是由于某些奇葩的原因(有部分业务经历了hack),导致部分用户的账号没有成功的过渡账号变更,导致了一些问题。

表象原因是代码逻辑混乱,bug原因复杂,无法复现并难以定位问题。 但更深的原因是时间久远,我们对当时的代码设计以及业务逻辑变更没有记录,且逻辑上存在缺陷,导致无法快速定位到问题的原因。

思考

对于一个逻辑复杂的业务,首先要考虑的是易拓展、易维护和模块组件间的高度解耦。

对于一个成熟的业务来说,我认为易于维护性更为重要,因为业务已经进入成熟阶段的话就意味着大块功能增加的几率比较低,那么对于线上bug修复、小修小补的功能上的优化就是主要工作。

当前的这个IM业务就是这样的,大家看梳理的代码的第一步应该能看出来,我耗费了大量的时间去做各种备注,然而,这些重要节点的备注应该在开发阶段就应该写好了。包括对于之前两套账号系统存在的原因,包括变更一次账号的原因,是否需要有一个详细的记录?

那么对于这种这种有助于后期维护的记录,我认为我们需要对重要的业务变更以及业务设计要有一个详细的记录,无论是自己作为记录还是为后面接手的同学做个参考,我认为都是极为重要的。

重构

思索需求

经过最开始的了解和一段时间维护,我发现遇到的最大麻烦是数据逻辑、业务逻辑、和UI操作逻辑混到了一起,简直可以说是牵一发而动全身。特别是数据逻辑十分混乱,因为数据逻辑的混乱,导致我对于后面业务逻辑的变更十分费力,测试成本也是指数级上涨,另外UI逻辑杂乱,适配iPhone X的时候也遇到了一些小麻烦。

现在的架构设计的原因是什么?这样设计业务需求是否合理?是否有优化的空间?

分析现状与判断未来

无论是客户端还是服务端,亦或是两端数据交互上,IM业务的架构设计本身就存在很多问题。因为时间短暂,不太可能一次性解决所有的问题,特别是对于服务端来说,一次性的大规模重构可能性极低。

对于现在来说:

  • UI重构是首当其冲的, 在保证现有逻辑的基础上,重新设计UI层的逻辑结构,保证代码的复用性和可扩展性,为了将来有可能的业务升级留足空间。
  • 梳理基础组件,比如HTTP、长连接协议和数据库,还有其他的一些工具类,通过封装成组件和组件引用来是他们能从业务逻辑中独立出来。便于独立维护、升级甚至完整替换
  • 将原有的数据操作逻辑从UI逻辑中完整抽离出来,需要达到向下对基础组件要有封装和控制,向上对业务逻辑要有承接,而且依然要做到耦合度尽量的低。
  • 因为IM业务在多个App中都存在,功能逻辑上也有非常多的代码重合,所有需要考虑多个项目通用兼容的问题。

架构设计

  • 工厂模式
  • 瘦Model
  • 去model化的Cell
  • 项目优先,分离核心业务模块组成pod
  • 考虑业务变更可能性,尽可能向上保持API稳定性
  • KVO进行反向传值

着手动工

UI层重构

UI层的重构是最先开始的,无论架构怎么变,UI层都是直接面向用户的,直接承载了产品的业务功能实现,所以为了灵活适应业务的升级或变化,给用户一个好用流畅的入口,UI层设计上要尽可能的灵活。耦合尽可能的小,流畅性上要有保证。

重构思路相对简单,重写View和Controller,去除冗余复杂的UI逻辑代码,规范并统一第三方框架使用,封装公用组件,隔离胶水代码,设计灵活的UI结构。

因为IM系统的最主要UI仍然是TableView,所以针对TableView的各种优化就是重中之重,我着重说一下我对于复杂Cell类型的设计方案。

1、共有的组件有很多,比如时间、头像、背景气泡等等,所以说子类继承父类是最基础的方案。
2、弃置Autolayout的UI书写方式,完全用Frame来写UI布局。Cell高度以及内部UI组件的布局和位置,通过异步计算并缓存为LayoutModel,通过这种方式降低计算的重复耗时操作。
3、有的消息类型只是负责展示,但是有的确有相对复杂的业务逻辑,但是为了防止Cell代码的膨胀,采用了瘦Cell的方式分离逻辑,力求使Cell尽量只负责UI的承载和展示,增加helper层处理相关逻辑。
4、因为虽然是相同的一个数据,但是呈现的方式会存才差异化,所以采用瘦Model的形式,通过创建Helper对取到的原始数据进行相对应的加工,直接提供给业务逻辑处理好的数据。在AFNetworking给的Demo中,是一个典型的胖Model的例子,倒不是说他的例子不好,只是随着业务逻辑的复杂以及生数据和熟数据的差异越来越大的时候,胖Mode的代码量会几何级数般的膨胀,所以还是要因地制宜,具体情况具体分析。
5、利用Factory模式分离出关于复杂Cell类型的判断,包括初始化、赋值等。
6、使用KVO取代delegate进行反向传值,用以减少代码耦合。

关于如何保证UI性能以及优化ViewController,可参考我的其他几篇Blog,iOS 性能优化的探索复杂业务下UIViewController的减负工作

最终结果,第一个完整case的UI层Controller代码,从3000行直接缩减到了1200行,Controller中没有复杂的多方数据处理逻辑,复杂的逻辑判断。只作为UI展示以及接口调用,完全剥离了数据逻辑的处理,所有的处理逻辑由下面的数据逻辑层处理。

数据逻辑层重构

我在分析了业务需求并设计了架构之后,决定重构以自下而上的顺序来进行,于是第一部分就是对于数据逻辑层的重构。步骤如下: 此部分,分为以下三层结构:

1、业务数据逻辑层
2、适配器层(adapter)
3、基础组件服务层(server)

1、业务数据逻辑层

这部分的主要作用是直接受到UI层的调用,负责长连接以及短连接的建立,数据库的初始化操作等。 向上直接承接UI逻辑和业务逻辑,是高度面向业务封装的接口。比如在发送照片消息的时候,只需要将调用API传入Image对象,其他的流程比如说是上传资源以及组成message对象等,则不需要上一层调用和考虑。

所以这一层尽可能的会很薄,不会有特别多的逻辑代码。

2、适配器层(adapter)

这部分的主要工作是承上启下,承接上一层的面向业务的封装,调用下一层基础组件服务层的接口,可以说绝大多数的接口封装都集中在这一层。因为我们有些业务的长连接和短连接的使用上不是很合理,所以我将长短连接都封装到了一个网络服务的类中,此后假如长短连接的业务产生了变化,但仍可以保持向上的接口稳定性。

举个例子说,当推送来一条消息之后,是通过长连接,但是需要收到数据之后在进行AFN的操作完成消息体完整数据的获取,之后要存入数据库并且将是否读取状态设置为NO,当用户读取当前消息之后,将这部分消息还要更新为已读状态。

这部分操作涉及到了所有的组件的操作,但是反馈到最上面一层的时候,大概只是新的消息,并且是完整的消息,然后再刷新UI。所以说,这一层的业务量比较大,几乎是要按照各种标准操作,完整的处理好所有组件的接口。

3、基础组件服务层(server)

这部分基本可以说是基于IM本身的业务特点,对于基础组件的调用封装。 包括:

1、数据库部分,对于IM消息的数据结构,封装的对于数据库的创建,以及增删改查等接口。以及基于业务的一些接口,例如一次性设置当前聊天的所有消息为已读状态等接口。
2、AFN部分,这部分相对来说就很简单了,基本上是依赖于AFN封装的接口,比如获取当前User的详细信息等。
3、长连接部分,包括对于长连接协议的创建连接、断连以及心跳超时上报等操作,也包括了发送消息和收到消息回调等底层操作。
复制代码

拆分之后的Manager层代码量所见到原来的40%左右,于是改名为Session层。

基础组件层的重构以及封装

这部分因为属于公用的基础组件,所以相对来说只是基础组件的比如说AFN以及数据库(FMDB)是整个App的组成,所以没什么其他的操作,只是单纯做了一层逻辑上分层。

但是对于长连接我们做了一些定制,比如:

1、增加了重连的逻辑机制。
2、增加创建连接以及断掉连接时候各种状态的判断等。
复制代码

主要任务还是集中在对于协议库本身的逻辑补充和健壮性优化等。

其他操作

1、创建枚举文件,扩展标准化的枚举变量。
2、合并以及分割Model,随着业务的扩展,原来的Model设计已经不符合当下的业务发展,根据现在固定的业务,重新设计了Model的集成关系,对于分化严重的也做了重新分割。
3、分离并封装了胶水代码到一个大的工具类,便于调用和调试。

走过的弯路

1、过度思考代码解耦合而忽略了业务逻辑复杂性,错将组件化各组件的解耦合的逻辑应用在了本来就是高耦合的MVC架构上。尝试使用去model化的Cell,但是实际操作环节发现增加了大量的逻辑判断,无形中将Model本该处理的业务逻辑转接到Controller和View上,表面上看上去API简洁到家,但是上手代码量并不算小,不利于维护。

2、在一开始采用了MVCS的设计重构UI层,简化Controller中对于Model的处理,在Store中进行了主动和被动网络逻辑、本地数据库调用等。但事实上最后通过封装统一入口的方式将数据处理的逻辑全部从UI逻辑层剥离开,下沉到了数据逻辑层,对于UI层来说只需要考虑的是进行了调用获取数据的API操作或者是被动受到了新的数据,不需要考虑数据来自于服务端、Cache还是本地数据库,也不需要考虑后面的逻辑,当然,从另一个角度说仍然是MVCS,只不过Store相对复杂且庞大。

3、对于UI层和下一层的数据沟通,虽然采用了KVO的方式回调,降低了耦合性,但是仍然存在参数复杂的情况下,传递过多的Key的情况,导致解析稍显困难和复杂。

4、Cell的继承,看上去是一个很直观的设计,但是随着重构代码量的增加以及业务变化发现继承过程中会存在很多问题,通过面向协议等方式或许可以解决继承中庞大Api的问题。

总结以及思考

架构设计的时候,一定要预判用户的使用习惯,判断未来的业务导向,尽可能的降低代码侵入性和耦合性。对于性能产生的影响的地方,通过以上几点来设计架构,模块健壮性以及可扩展性是设计之初就要优先考虑到的。

架构设计分层要清晰,API设计要尽可能简洁,避免暴露过多的接口和参数,避免模块之间的紧耦合,UI设计要尽可能灵活。 重构前,需要思考切入点,是从上值下、从下至上,还是模块化抽离。

已不再维护这部分业务,部分逻辑全凭记忆整理,如果有疏漏或错误,还请大家海涵。

Refrence


作者:derek
链接:https://juejin.cn/post/6844904054577954824

收起阅读 »

iOS Operation 自定义的注意点

iOS
问题 碰到一个问题,就是做一个点击后添加动画效果,连续点击则有多个动画效果按顺序执行,通过自定Operation,以队列实现,但是发现每次点击玩上次动画效果还没完全执行完点击之后的动画就出来,不符合需求。 后来查资料得知自定义Operation中有两个属性分...
继续阅读 »
问题


  • 碰到一个问题,就是做一个点击后添加动画效果,连续点击则有多个动画效果按顺序执行,通过自定Operation,以队列实现,但是发现每次点击玩上次动画效果还没完全执行完点击之后的动画就出来,不符合需求。

  • 后来查资料得知自定义Operation中有两个属性分别表示任务是否在执行以及是否执行完毕,如下


@property (readonly, getter=isExecuting) BOOL executing;
@property (readonly, getter=isFinished) BOOL finished;
复制代码


因此在自定义Operation时设置这两个属性,同时在完全当前队列中任务时给予标识表明任务完成,具体代码如下




  • CustomOperation 类


@interface CustomOperation : NSOperation

@end

#import "CustomOperation.h"

@interface CustomOperation ()

@property(nonatomic,readwrite,getter=isExecuting)BOOL executing; // 表示任务是否正在执行
@property(nonatomic,readwrite,getter=isFinished)BOOL finished; // 表示任务是否结束

@end

@implementation CustomOperation

@synthesize executing = _executing;
@synthesize finished = _finished;

- (void)start
{
    @autoreleasepool {
        self.executing = YES;
        if (self.cancelled) {
            [self done];
            return;
        }
        // 执行任务
        __weak typeof(self) weakSelf = self;
        dispatch_time_t delayTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC));
        dispatch_after(delayTime, dispatch_get_main_queue(), ^{
            NSLog(@"动画完毕");
            // 任务执行完毕手动关闭
            [weakSelf done];
        });
    }
}

-(void)done
{
    self.finished = YES;
    self.executing = NO;
}

#pragma mark - setter -- getter
// 监听并设置executing
- (void)setExecuting:(BOOL)executing
{
    [self willChangeValueForKey:@"isExecuting"];
    _executing = executing;
    [self didChangeValueForKey:@"isExecuting"];
}

- (BOOL)isExecuting
{
    return _executing;
}

// 监听并设置finished
- (void)setFinished:(BOOL)finished
{
    if (_finished != finished) {
        [self willChangeValueForKey:@"isFinished"];
        _finished = finished;
        [self didChangeValueForKey:@"isFinished"];
    }
}

- (BOOL)isFinished
{
    return _finished;
}

// 返回YES 标识并发Operation
- (BOOL)isAsynchronous
{
    return YES;
}

@end


  • swift版


class AnimationOperation: Operation {

    var animationView:AnimationView? // 动画view
    var superView:UIView? // 父视图
    var finishCallBack:(()->())? // 完成动画的回调

    override var isExecuting: Bool {
        return operationExecuting
    }

    override var isFinished: Bool {
        return operationFinished
    }

    override var isAsynchronous: Bool {
        return true
    }
// 监听
    private var operationFinished:Bool = false {
        willSet {
            willChangeValue(forKey: "isFinished")
        }
        didSet {
            didChangeValue(forKey: "isFinished")
        }
    }

    private var operationExecuting:Bool = false {
        willSet {
            willChangeValue(forKey: "isExecuting")
        }
        didSet {
            didChangeValue(forKey: "isExecuting")
        }
    }

// 每次点击添加动画队列
    class func addOperationShowAnimationView(animationView:AnimationView,superView:UIView) -> AnimationOperation {

        let operation = AnimationOperation()
        operation.animationView = animationView
        operation.superView = superView
        return operation
    }

    override func start() {
        self.operationExecuting = true
        if isCancelled == true {
            self.done()
            return
        }

        guard let superView = self.superView,
              let subView = self.animationView,
              let callback = self.finishCallBack else {
            print("superView == nil")
            return
        }

        OperationQueue.main.addOperation {[weak self] in
            superView.addSubview(subView)
            subView.finishCallBack = {
                self?.done()
                callback()
            }
            subView.showAnimation()
        }
    }

    

    private func done() {
        self.operationFinished = true
        self.operationExecuting = false
    }

}

作者:取个有意思的昵称
链接:https://juejin.cn/post/7034518171314815007

收起阅读 »

如何系统性治理 iOS 稳定性问题

iOS
字节跳动如何系统性治理 iOS 稳定性问题本文是丰亚东讲师在2021 ArchSummit 全球架构师峰会中「如何系统性治理 iOS 稳定性问题」的分享全文首先做一下自我介绍:我是丰亚东,2016 年 4 月加入字节跳动,先后负责今日头条 App 的工程架构、...
继续阅读 »

字节跳动如何系统性治理 iOS 稳定性问题

本文是丰亚东讲师在2021 ArchSummit 全球架构师峰会中「如何系统性治理 iOS 稳定性问题」的分享全文

首先做一下自我介绍:我是丰亚东,2016 年 4 月加入字节跳动,先后负责今日头条 App 的工程架构、基础库和体验优化等基础技术方向。2017 年 12 月至今专注在 APM 方向,从 0 到 1 参与了字节跳动 APM 中台的建设,服务于字节的全系产品,目前主要负责 iOS 端的性能稳定性监控和优化。 请添加图片描述 本次分享主要分为四大章节,分别是:1.稳定性问题分类;2.稳定性问题治理方法论;3.疑难问题归因;4.总结回顾。其中第三章节「疑难问题归因」是本次分享的重点,大概会占到60%的篇幅。

一、稳定性问题分类

在讲分类之前,我们先了解一下背景:大家都知道对于移动端应用而言,闪退是用户能遇到的最严重的 bug,因为在闪退之后用户无法继续使用产品,那么后续的用户留存以及产品本身的商业价值都无从谈起。 这里有一些数据想和大家分享:有 20% 的用户在使用移动端产品的时候,最无法忍受的问题就是闪退,这个比例仅次于不合时宜的广告;在因为体验问题流失的用户中,有 1/3 的用户会转而使用竞品,由此可见闪退问题是非常糟糕和严重的。 请添加图片描述 字节跳动作为拥有像抖音、头条等超大量级 App 的公司,对稳定性问题是非常重视的。过去几年,我们在这方面投入了非常多的人力和资源,同时也取得了不错的治理成果。过去两年抖音、头条、飞书等 App 的异常崩溃率都有 30% 以上的优化,个别产品的部分指标甚至有 80% 以上的优化。 通过上图中右侧的饼状图可以看出:我们以 iOS 平台为例,根据稳定性问题不同的原因,将已知稳定性问题分成了这五大类,通过占比从高到低排序:第一大类是 OOM ,就是内存占用过大导致的崩溃,这个比例能占到 50% 以上;其次是 Watchdog,也就是卡死,类比于安卓中的 ANR;再次是普通的 Crash;最后是磁盘 IO 异常和 CPU 异常。 看到这里大家心里可能会有一个疑问:字节跳动究竟做了什么,才取得了这样的成果?接下来我会将我们在稳定性治理方面沉淀的方法论分享给大家。

二、稳定性问题治理的方法论

在这里插入图片描述 首先我们认为在稳定性问题治理方面,从监控平台侧视角出发,最重要的就是要有完整的能力覆盖,比如针对上一章节中提到所有类型的稳定性问题,监控平台都应该能及时准确的发现。 另外是从业务研发同学的视角出发:稳定性问题治理这个课题,需要贯穿到软件研发的完整生命周期,包括需求研发、测试、集成、灰度、上线等,在上述每个阶段,研发同学都应该重视稳定性问题的发现和治理。

上图中右侧是我们总结的两条比较重要的治理原则: 第一条是控制新增,治理存量。一般来说新增的稳定性问题可能是一些容易爆发的问题,影响比较严重。存量问题相对来说疑难的问题居多,修复周期较长。 第二条比较容易理解:先急后缓,先易后难。我们应该优先修复那些爆发的问题以及相对容易解决的问题。 在这里插入图片描述 如果我们将软件研发周期聚焦在稳定性问题治理这个方向上,又可以抽象出以下几个环节: 首先第一个环节是问题发现:当用户在线上遇到任何类型的闪退,监控平台都应该能及时发现并上报。同时可以通过报警以及问题的自动分发,将这些问题第一时间通知给开发者,确保这些问题能够被及时的修复。 第二个阶段是归因:当开发者拿到一个稳定性问题之后,要做的第一件事情应该是排查这个问题的原因。根据一些不同的场景,我们又可以把归因分为单点归因、共性归因以及爆发问题归因。 当排查到问题的原因之后,下一步就是把这个问题修复掉,也就是问题的治理。在这里我们有一些问题治理的手段:如果是在线上阶段,我们首先可以做一些问题防护,比如网易几年前一篇文章提到的基于 OC Runtime 的线上 Crash 自动修复的方案大白,基于这种方案我们可以直接在线上做 Crash 防护;另外由于后端服务上线导致的稳定性问题爆发,我们可以通过服务的回滚来做到动态止损。除了这两种手段之外,更多的场景还是需要研发在线下修复 native 代码,再通过发版做彻底的修复。 最后一个阶段也是最近几年比较火的一个话题,就是问题的防劣化。指的是需求从研发到上线之间的阶段,可以通过机架的自动化单元测试/UI自动化测试,以及研发可以通过一些系统工具,比如说 Xcode 和 Instruments,包括一些第三方工具,比如微信开源的 MLeaksFinder 去提前发现和解决各类稳定性问题。

如果我们想把稳定性问题治理做好的话,需要所有研发同学关注上述每一个环节,才能达到最终的目标。 可是这么多环节我们的重点究竟在哪里呢?从字节跳动的问题治理经验来看,我们认为最重要的环节是第二个——线上的问题的归因。因为通过内部的统计数据发现:线上之所以存在长期没有结论,没有办法修复的问题,主要还是因为研发并没有定位到这些问题的根本原因。所以下一章节也是本次分享的重点:疑难问题归因。

三、疑难问题归因

我们根据开发者对这些问题的熟悉程度做了一下排序,分别是:Crash、Watchdog、OOM 和 CPU&Disk I/O。每一类疑难问题我都会分享这类问题的背景和对应的解决方案,并且会结合实战案例演示各种归因工具究竟是如何解决这些疑难问题的。

3.1 第一类疑难问题 —— Crash

在这里插入图片描述 上图中左侧这张饼状图是我们根据 Crash 不同的原因,把它细分成四大类:包括 Mach 异常、 Unix Signal 异常、OC 和 C++ 语言层面上的异常。其中比例最高的还是 Mach 异常,其次是 Signal 异常,OC 和 C++ 的异常相对比较少。 为什么是这个比例呢? 大家可以看到右上角有两个数据。第一个数据是微软发布的一篇文章,称其发布的 70% 以上的安全补丁都是内存相关的错误,对应到 iOS 平台上就是 Mach 异常中的非法地址访问,也就是 EXC_BAD_ACCESS。内部统计数据表明,字节跳动线上 Crash 有 80% 是长期没有结论的,在这部分 Crash 当中,90% 以上都是 Mach 异常或者 Signal 异常。 看到这里,大家肯定心里又有疑问了,为什么有这么多 Crash 解决不了?究竟难在哪里?我们总结了几点这些问题归因的难点:

  • 首先不同于 OC 和 C++ 的异常,可能开发者拿到的崩溃调用栈是一个纯系统调用栈,这类问题显然修复难度是非常大的;
  • 另外可能有一部分Crash是偶发而不是必现的问题,研发同学想在线下复现问题是非常困难的,因为无法复现,也就很难通过 IDE 调试去排查和定位这些问题;
  • 另外对于非法地址访问这类问题,崩溃的调用栈可能并不是第一现场。这里举一个很简单的例子:A业务的内存分配溢出,踩到了B业务的内存,这个时候我们认为 A 业务应该是导致这个问题的主要原因,但是有可能B业务在之后的某一个时机用到了这块内存,发生了崩溃。显然这种问题实际上是 A 业务导致的,最终却崩在了 B 业务的调用栈里,这就会给开发者排查和解决这个问题带来非常大的干扰。

看到这里大家可能心里又有问题:既然这类问题如此难解,是不是就完全没有办法了呢?其实也并不是,下面我会分享字节内部两个解决这类疑难问题非常好用的归因工具。 在这里插入图片描述

3.1.1 Zombie 检测

首先第一个是 Zombie 检测,大家如果用过 Xcode 的 Zombie 监控,应该对这个功能比较熟悉。如果我们在调试之前打开了 Zombie Objects 这个开关,在运行的时候如果遇到了 OC 对象野指针造成的崩溃,Xcode 控制台中会打印出一行日志,它会告诉开发者哪个对象在调用什么消息的时候崩溃了。

这里我们再解释一下 Zombie 的定义,其实非常简单,指的是已经释放的 OC 对象。 Zombie 监控的归因优势是什么呢?首先它可以直接定位到问题发生的类,而不是一些随机的崩溃调用栈;另外它可以提高偶现问题的复现概率,因为大部分偶现问题可能跟多线程的运行环境有关,如果我们能把一个偶现问题变成必现问题的话,那么开发者就可以借助 IDE 和调试器非常方便地排查问题。但是这个方案也有自己的适用范围,因为它的底层原理基于 OC 的 runtime 机制,所以它仅仅适用于 OC 对象野指针导致的内存问题。 在这里插入图片描述 这里再和大家一起回顾一下 Zombie 监控的原理:首先我们会 hook 基类 NSObject 的 dealloc 方法,当任意 OC 对象被释放的时候,hook 之后的那个 dealloc 方法并不会真正的释放这块内存,同时将这个对象的 ISA 指针指向一个特殊的僵尸类,因为这个特殊的僵尸类没有实现任何方法,所以这个僵尸对象在之后接收到任何消息都会 Crash,与此同时我们会将崩溃现场这个僵尸对象的类名以及当时调用的方法名上报到后台分析。 在这里插入图片描述 这里是字节的一个真实案例:这个问题是飞书在某个版本线上 Top 1 的 Crash,当时持续了两个月没有被解决。首先大家可以看到这个崩溃调用栈是一个纯系统调用栈,它的崩溃类型是非法地址访问,发生在视图导航控制器的一次转场动画,可能开发者一开始看到这个崩溃调用栈是毫无思路的。 在这里插入图片描述 那么我们再看 Zombie 功能开启之后的崩溃调用栈:这个时候报错信息会更加丰富,可以直接定位到野指针对象的类型,是 MainTabbarController 对象在调用 retain 方法的时候发生了 Crash。

看到这里大家肯定有疑问了,MainTabbarController 一般而言都是首页的根视图控制器,理论上在整个生命周期内不应该被释放。为什么它变成了一个野指针对象呢?可见这样一个简单的报错信息,有时候还并不足以让开发者定位到问题的根本原因。所以这里我们更进一步,扩展了一个功能:将 Zombie 对象释放时的调用栈信息同时上报上来。 在这里插入图片描述 大家看倒数第二行,实际上是一段飞书的业务代码,是视图导航控制器手势识别的代理方法,这个方法在调用的时候释放了 MainTabbarController。因为通过这个调用栈找到了业务代码的调用点,所以我们只需要对照源码去分析为什么会释放 TabbarController,就可以定位到这个问题的原因。 在这里插入图片描述 上图中右侧是简化之后的源码(因为涉及到代码隐私问题,所以通过一段注释代替)。历史上为了解决手势滑动返回的冲突问题,在飞书视图导航控制器的手势识别代理方法中写了一段 trick 代码,正是这个 trick 方案导致了首页视图导航控制器被意外释放。 排查到这里,我们就找到了问题的根本原因,修复的方案也就非常简单了:只要下掉这个 trick 方案,并且依赖导航控制器的原生实现来决定这个手势是否触发就解决了这个问题。

3.1.2 Coredump

刚才也提到:Zombie 监控方案是有一些局限的,它仅适用于 OC 对象的野指针问题。大家可能又会有疑问: C 和 C++ 代码同样可能会出现野指针问题,在 Mach 异常和 Signal 异常中,除了内存问题之外,还有很多其他类型的异常比如 EXC_BAD_INSTRUCTION和SIGABRT。那么其他的疑难问题我们又该怎么解决呢?这里我们给出了另外一个解决方案 —— Coredump。 在这里插入图片描述 这个先解释一下什么是 Coredump:Coredump 是由 lldb 定义的一种特殊的文件格式,Coredump 文件可以还原 App 在运行到某一时刻的完整运行状态(这里的运行状态主要指的是内存状态)。大家可以简单的理解为:Coredump文件相当于在崩溃的现场打了一个断点,并且获取到当时所有线程的寄存器信息,栈内存以及完整的堆内存。

Coredump 方案它的归因优势是什么呢?首先因为它是 lldb 定义的文件格式,所以它天然支持 lldb 的指令调试,也就是说开发者无需复现问题,就可以实现线上疑难问题的事后调试。另外因为它有崩溃时现场的所有内存信息,这就为开发者提供了海量的问题分析素材。

这个方案的适用范围比较广,可以适用于任意 Mach 异常或者 Signal 异常问题的分析。 在这里插入图片描述 下面也带来一个线上真实案例的分析:当时这个问题出现在字节的所有产品中,而且在很多产品中的量级非常大,排名Top 1 或者 Top 2,这个问题在之前两年的时间内都没有被解决。

大家可以看到这个崩溃调用栈也全是系统库方法,最终崩溃在 libdispatch 库中的一个方法,异常类型是命中系统库断言。 在这里插入图片描述 我们将这次崩溃的 Coredump 文件上报之后,用前面提到的 lldb 调试指令去分析,因为拥有崩溃时的完整内存状态,所以我们可以分析所有线程的寄存器和栈内存等信息。

这里最终我们分析出:崩溃线程的 0 号栈帧(第一行调用栈),它的 x0 寄程器实际上就是 libdispatch 中定义的队列结构体信息。在它起始地址偏移 0x48 字节的地方,也就是这个队列的 label 属性(可以简单理解为队列的名字)。这个队列的名字对我们来说是至关重要的,因为要修复这个问题,首先应该知道究竟是哪个队列出现了问题。通过 memory read 指令我们直接读取这块内存的信息,最终发现它是一个 C 的字符串,名字叫 com.apple.CFFileDescriptor,这个信息非常关键。我们在源码中全局搜索这个关键字,最终发现这个队列是在字节底层的网络库中创建的,这也就能解释为什么字节所有产品都有这个崩溃了。 在这里插入图片描述 最终我们和网络库的同学一起排查,同时结合 libdispatch 的源码,定位到这个问题的原因是 GCD 队列的外部引用计数小于0,存在过度释放的问题,最终命中系统库断言导致崩溃。 在这里插入图片描述 排查到问题之后,解决方案就比较简单了:我们只需要在这个队列创建的时候,使用 dispatch_source_create 的方式去增加队列的外部引用计数,就能解决这个问题。和维护网络库的同学沟通后,确认这个队列在整个 App 的生命周期内不应该被释放。这个问题最终解决的收益是直接让字节所有产品的 Crash 率降低了8%。

3.2 第二类疑难问题 —— Watchdog

我们进入疑难问题中的第二类问题 —— Watchdog 也就是卡死。 在这里插入图片描述 上图中左侧是我在微博上截的两张图,是用户在遇到卡死问题之后的抱怨。可见卡死问题对用户体验的伤害还是比较大的。那么卡死问题它的危害有哪些呢?

首先卡死问题通常发生于用户打开 App 的冷启动阶段,用户可能等待了10 秒什么都没有做,这个 App 就崩溃了,这对用户体验的伤害是非常大的。另外我们线上监控发现,如果没有对卡死问题做任何治理的话,它的量级可能是普通 Crash 的 2-3 倍。另外现在业界普遍监控 OOM 崩溃的做法是排除法,如果没有排除卡死崩溃的话,相应的就会增加 OOM 崩溃误判的概率。

卡死类问题的归因难点有哪些呢?首先基于传统的方案——卡顿监控:认为主线程无响应时间超过3秒~5秒之后就是一次卡死,这种传统的方案非常容易误报,至于为什么误报,我们下一页中会讲到。另外卡死的成因可能非常复杂,它不一定是单一的问题:主线程的死锁、锁等待、主线程 IO 等原因都有可能造成卡死。第三点是死锁问题是一类常见的导致卡死问题的原因。传统方案对于死锁问题的分析门槛是比较高的,因为它强依赖开发者的经验,开发者必须依靠人工的经验去分析主线程到底跟哪个或者哪些线程互相等待造成死锁,以及为什么发生死锁。 在这里插入图片描述 大家可以看到这是基于传统的卡顿方案来监控卡死,容易发生误报。为什么呢?图中绿色和红色的部分是主线程的不同耗时阶段。假如主线程现在卡顿的时间已经超过了卡死阈值,刚好发生在图中的第5个耗时阶段,我们在此时去抓取主线程调用栈,显然它并不是这次耗时的最主要的原因,问题其实主要发生在第4个耗时阶段,但是此时第4个耗时阶段已经过去了,所以会发生一次误报,这可能让开发者错过真正的问题。

针对以上提到的痛点,我们给出了两个解决方案:首先在卡死监控的时候可以多次抓取主线程调用栈,并且记录每次不同时刻主线程的线程状态,关于线程状态包括哪些信息,下一页中会提到。 另外我们可以自动识别出死锁导致的卡死问题,将这类问题标识出来,并且可以帮助开发者自动还原出各个线程之间的锁等待关系。 在这里插入图片描述 首先是第一个归因工具——线程状态,这张图是主线程在不同时刻调用栈的信息,在每个线程名字后面都有三个 tag ,分别指的是三种线程的状态,包括当时的线程 CPU 占用、线程运行状态和线程标志。

上图中右侧是线程的运行状态和线程标志的解释。当看到线程状态的时候,我们主要的分析思路有两种:第一种,如果看到主线程的 CPU 占用为 0,当前处于等待的状态,已经被换出,那我们就有理由怀疑当前这次卡死可能是因为死锁导致的;另外一种,特征有所区别,主线程的 CPU 占用一直很高 ,处于运行的状态,那么就应该怀疑主线程是否存在一些死循环等 CPU 密集型的任务。 在这里插入图片描述 第二个归因工具是死锁线程分析,这个功能比较新颖,所以首先带领大家了解一下它的原理。基于上一页提到的线程状态,我们可以在卡死时获取到所有线程的状态并且筛选出所有处于等待状态的线程,再获取每个线程当前的 PC 地址,也就是正在执行的方法,并通过符号化判断它是否是一个锁等待的方法。

上图中列举了目前我们覆盖到的一些锁等待方法,包括互斥锁、读写锁、自旋锁、 GCD 锁等等。每个锁等待的方法都会定义一个参数,传入当前锁等待的信息。我们可以从寄存器中读取到这些锁等待信息,强转为对应的结构体,每一个结构体中都会定义一个线程id的属性,表示当前这个线程正在等待哪个线程释放锁。对每一个处于等待状态的线程完成这样一系列操作之后,我们就能够完整获得所有线程的锁等待关系,并构建出锁等待关系图。 在这里插入图片描述 通过上述方案,我们可以自动识别出死锁线程。假如我们能判断 0 号线程在等待 3 号线程释放锁, 同时3 号线程在等待0号线程释放锁,那么显然就是两个互相等待最终造成死锁的线程。

大家可以看到这里主线程我们标记为死锁,它的 CPU 占用为 0,状态是等待状态,而且已经被换出了,和我们之前分析线程状态的方法论是吻合的。 在这里插入图片描述 通过这样的分析之后,我们就能够构建出一个完整的锁等待关系图,而且无论是两个线程还是更多线程互相等待造成的死锁问题,都可以自动识别和分析。 在这里插入图片描述 这是上图中死锁问题的一段示意的源码。它的问题就是主线程持有互斥锁,子线程持有 GCD 锁,两个线程之间互相等待造成了死锁。这里给出的解决方案是:如果子线程中可能存在耗时操作,尽量不要和主线程有锁竞争关系;另外如果在串行队列中同步执行 block 的话,一定要慎重。 在这里插入图片描述 上图是通过字节内部线上的监控和归因工具,总结出最常见触发卡死问题的原因,分别是死锁、锁竞争、主线程IO、跨进程通信。

3.3 第三类疑难问题 —— OOM

OOM 就是 Out Of Memory,指的是应用占用的内存过高,最终被系统强杀导致的崩溃。 在这里插入图片描述 OOM 崩溃的危害有哪些呢?首先我们认为用户使用 App 的时间越长,就越容易发生 OOM 崩溃,所以说 OOM 崩溃对重度用户的体验伤害是比较大的;统计数据显示,如果 OOM 问题没有经过系统性的治理,它的量级一般是普通 Crash 的 3-5 倍。最后是内存问题不同于 Crash 和卡死,相对隐蔽,在快速迭代的过程中非常容易劣化。

那么 OOM 问题的归因难点有哪些呢?首先是内存的构成是非常复杂的事情,并没有非常明确的异常调用栈信息。另外我们在线下有一些排查内存问题的工具,比如 Xcode MemoryGraph 和 Instruments Allocations,但是这些线下工具并不适用于线上场景。同样是因为这个原因,如果开发者想在线下模拟和复现线上 OOM 问题是非常困难的。 在这里插入图片描述 这里我们给出解决线上 OOM 疑难问题的归因工具是MemoryGraph。这里的 MemoryGraph 主要指的是在线上环境中可以使用的 MemoryGraph。跟 Xcode MemoryGraph 有一些类似,但是也有不小的区别。最大的区别当然是它能在线上环境中使用,其次它可以对分散的内存节点进行统计和聚合,方便开发者定位头部的内存占用。

这里带领大家再回顾一下线上 MemoryGraph 的基本原理:首先我们会定时的去检测 App 的物理内存占用,当它超过危险阈值的时候,就会触发内存 dump,此时 SDK 会记录每个内存节点符号化之后的信息,以及他们彼此之间的引用关系,如果能判定出是强引用还是弱引用,也会把这个强弱引用关系同时上报上来,最终这些信息整体上报到后台之后,就可以辅助开发者去分析当时的大内存占用和内存泄露等异常问题。

这里我们还是用一个实战案例带领大家看一下 MemoryGraph 到底是如何解决 OOM 问题的。 在这里插入图片描述 分析 MemoryGraph 文件的思路一般是抽丝剥茧,逐步找到根本原因。

上图是 MemoryGraph 文件分析的一个例子,这里的红框标注了不同的区域:左上角是类列表,会把同一类型对象的数量以及它们占用的内存大小做一个汇总;右侧是这个类所有实例的地址列表,右下角区域开发者可以手动回溯对象的引用关系(当前对象被哪些其他对象引用、它引用了哪些其他对象),中间比较宽的区域是引用关系图。

因为不方便播放视频,所以这边就跟大家分享一些比较关键的结论:首先看到类列表,我们不难发现 ImageIO 类型的对象有 47 个,但是这 47 个对象居然占了 500 多 MB 内存,显然这并不是一个合理的内存占用。我们点开 ImageIO 的类列表,以第一个对象为例,回溯它的引用关系。当时我们发现这个对象只有一个引用,就是 VM Stack: Rust Client Callback ,它实际上是飞书底层的 Rust 网络库线程。 排查到这里,大家肯定会好奇:这 47 个对象是不是都存在相同的引用关系呢?这里我们就可以用到右下角路径回溯当中的 add tag 功能,自动筛选这 47 个对象是否都存在相同的引用关系。大家可以看到上图中右上角区域,通过筛选之后,我们确认这 47 个对象 100% 都有相同的引用关系。

我们再去分析 VM Stack: Rust Client Callback这个对象。发现它引用的对象中有两个名字非常敏感,一个是 ImageRequest,另外一个是 ImageDecoder ,从这两个名字我们可以很容易地推断出:应该是图片请求和图片解码的对象。 在这里插入图片描述 我们再用这两个关键字到类列表中搜索,可以发现 ImageRequest 对象有 48 个,ImageDecoder 对象有 47 个。如果大家还有印象的话,上一页中占用内存最大的对象 ImageIO 也是 47 个。这显然并不是一个巧合,我们再去排查这两类对象的引用关系,发现这两类对象也同样是 100% 被 VM Stack: Rust Client Callback 对象所引用。

最终我们和飞书图片库的同学一起定位到这个问题的原因:在同一时刻并发请求 47 张图片并解码,这不是一个合理的设计。问题的根本原因是飞书图片库的下载器依赖了 NSOperationQueue 做任务管理和调度,但是却没有配置最大并发数,在极端场景下就有可能造成内存占用过高的问题。与之相对应的解决方案就是对图片下载器设置最大并发数,并且根据待加载图片是否在可视区域内调整优先级。 在这里插入图片描述 上图是通过字节内部的线上监控和归因工具,总结出来最常见的几类触发 OOM 问题的原因,分别是:内存泄露,这个较为常见;第二个是内存堆积,主要指的是 AutoreleasePool 没有及时清理;第三是资源异常,比如加载一张超大图或者一个超大的 PDF 文件;最后一个是内存使用不当,比如内存缓存没有设计淘汰清理的机制。

3.4 第四类疑难问题 —— CPU 异常和磁盘 I/O 异常

这里之所以把这两类问题合并在一起,是因为这两类问题是高度相似的:首先它们都属于资源的异常占用;另外它们也都不同于闪退,导致崩溃的原因并不是发生在一瞬间,而都是持续一段时间的资源异常占用。 在这里插入图片描述 异常 CPU 占用和磁盘 I/O 占用危害有哪些呢?首先我们认为,这两类问题即使最终没有导致 App 崩溃,也特别容易引发卡顿或者设备发烫等性能问题。其次这两类问题的量级也是不可以被忽视的。另外相比之前几类稳定性问题而言,开发者对这类问题比较陌生,重视程度不够,非常容易劣化。

这类问题的归因难点有哪些呢?首先是刚刚提到它的持续时间非常长,所以原因也可能并不是单一的;同样因为用户的使用环境和操作路径都比较复杂,开发者也很难在线下复现这类问题;另外如果 App 想在用户态去监控和归因这类问题的话,可能需要在一段时间内高频的采样调用栈信息,然而这种监控手段显然性能损耗是非常高的。 在这里插入图片描述 上图中左侧是我们从 iOS 设备中导出的一段 CPU 异常占用的崩溃日志,截取了关键部分。这部分信息的意思是:当前 App 在 3 分钟之内的 CPU 时间占用已经超过80%,也就是超过了 144 秒,最终触发了这次崩溃。

上图中右侧是我截取苹果 WWDC2020 一个 session 中的截图,苹果官方对于这类问题,给出了一些归因方案的建议:首先是 Xcode Organizer,它是苹果官方提供的问题监控后台。然后是建议开发者也可以接入 MetricKit ,新版本有关于 CPU 异常的诊断信息。 请添加图片描述 上图中左侧是磁盘异常写入的崩溃日志,也是从 iOS 设备中导出,依然只截取了关键部分:在 24 小时之内,App 的磁盘写入量已经超过了 1073 MB,最终触发了这次崩溃。

上图中右侧是苹果官方的文档,也给出了对于这类问题的归因建议。同样是两个建议:一个是依赖 Xcode Organizer,另一个是依赖 MetricKit。我们选型的时候最终确定采用 MetricKit 方案,主要考虑还是想把数据源掌握在自己手中。因为 Xcode Organizer 毕竟是一个苹果的黑盒后台,我们无法与集团内部的后台打通,更不方便建设报警、问题自动分配、issue状态管理等后续流程。 请添加图片描述 MetricKit 是苹果提供的官方性能分析以及稳定性问题诊断的框架,因为是系统库,所以它的性能损耗很小。在 iOS 14 系统以上,基于Metrickit,我们可以很方便地获取 CPU 和磁盘 I/O 异常的诊断信息。它的集成也非常方便。我们只需要导入系统库的头文件,设置一个监听者,在对应的回调中把 CPU 和磁盘写入异常的诊断信息上报到后台分析就好了。 请添加图片描述 其实这两类异常的诊断信息格式也是高度类似的,都是记录一段时间内所有方法的调用以及每个方法的耗时。上报到后台之后,我们可以把这些数据可视化为非常直观的火焰图。通过这样直观的形式,可以辅助开发者轻松地定位到问题。对于上图中右侧的火焰图,我们可以简单的理解为:矩形块越长,占用的 CPU 时间就越长。那么我们只需要找到矩形块最长的 App 调用栈,就能定位到问题。图中高亮的红框,其中有一个方法的关键字是 animateForNext,看这个名字大概能猜到这是动画在做调度。

最终我们和飞书的同学一起定位到这个问题的原因:飞书的小程序业务有一个动画在隐藏的时候并没有暂停播放,造成了 CPU 占用持续比较高。解决方案也非常简单,只要在动画隐藏的时候把它暂停掉就可以了。

四、总结回顾

请添加图片描述 在第二章节稳定性问题治理方法论中,我提到“如果想把稳定性问题治理好,就需要将这件事情贯穿到软件研发周期中的每一个环节,包括问题的发现、归因、治理以及防劣化”,同时我们认为线上问题特别是线上疑难问题的归因,是整个链路中的重中之重。针对每一类疑难问题,本次分享均给出了一些好用的归因工具:Crash 有 Zombie 监控和 Coredump;Watchdog 有线程状态和死锁线程分析;OOM 有 MemoryGraph;CPU 和磁盘 I/O 异常有 MetricKit。 请添加图片描述 本次分享提到的所有疑难问题的归因方案,除了MetricKit 之外,其余均为字节跳动自行研发,开源社区尚未有完整解决方案。这些工具和平台后续都将通过字节火山引擎应用开发套件 MARS 旗下的 APM Plus 平台提供一站式的企业解决方案。本次分享提到的所有能力均已在字节内部各大产品中验证和打磨多年,其自身的稳定性以及接入后所带来的业务效果都是有目共睹的,欢迎大家持续保持关注。

链接:https://juejin.cn/post/7034418275728097288

收起阅读 »

Swift路由组件(一)使用路由的目的和实现思想

iOS
Swift路由组件(一)使用路由的目的和实现思想这个为本人原创,转载请注明出处:juejin.cn/post/703216…目的项目开发到一定程度,功能之间的调用会变的越来越复杂这里用一个商品购买的逻辑举例从图上看,问题就是业务之间的跳转很多,而且乱。还有就是...
继续阅读 »

Swift路由组件(一)使用路由的目的和实现思想

这个为本人原创,转载请注明出处:juejin.cn/post/703216…

目的

项目开发到一定程度,功能之间的调用会变的越来越复杂

这里用一个商品购买的逻辑举例

image.png

从图上看,问题就是业务之间的跳转很多,而且乱。还有就是当跳同一个页面时,跳转要带的参数都一致,如何保证?如果代码分散到各个业务里面去跳就难免会到处维护的问题。

这就需要路由了。

而且路由做好了,还能有一个好处就是后端或者前端,他们按路由协议统一处理跳转,app就可以不考虑业务之间的跳转了。

下面是加上路由模块的跳转图。

image.png

这下清晰了。

从图上来看,路由,他主要负责业务的跳转,从一个页面跳转到另一个页面等。

实现的思想

为了能跳,那么就需要知道路。所以可以这样理解,路由他需要知道你要跳转到哪里去,去的地方需要什么入参。

所以得有一个key,map到一个ViewController,然后ViewController需要什么入参,就顺便带过来。

解决这个key的问题,业界比较常见的做法是有一个路由表

  1. 比如维护一个plist文件,开发的时候把对应的key映射controller维护到plist里面,运行的时候一次性load到内存中。然后路由要跳转的时候就只需要查表来跳。
  2. 或者在运行的时候通过业务注册,每个业务把key注册到路由里面去,在内存中维护一个路由表。

两种方法都可以。结果大概是这样。

keyvalue
to_home_pageHomeViewController
to_buy_pageBuyViewController
......

路由跳转他要解决三种跳转逻辑

  1. 通过后端下发,直接让App打开某个原生或者Web页面
    • 比如推送消息,点击消息就可以进入某个原生或者Web页面
    • 比如后端返回的商品卡片,点击商品进入某个原生或者Web页面
  2. 比如活动页面,点击按钮进入某个原生或者Web页面
  3. 比如原生页面的某个按钮,点击按钮进入某个原生或者Web页面

总结起来也就两种,

  1. 一种是远程调用,
  2. 一种是app内部调用。

所谓远程调用就是app提供的跳转能力,允许外面调用的。再者理解,可以被别的app打开调用,比如微信的分享,支付等。相对的内部调用就是app内部由A页面跳转到B页面的。

所以针对上面的用处,从命名上可以做好区分,比如内部调用加native://做为开头,表示是内部调用。外部就加weixinapp://(用app名更容易调用者理解),或者加http/https,毕竟可以直接兼容http://www.baidu.com 这样的网页

之所以要好明确区分,是因为可以利用路由做好统一的权限管理。比如外部调用可以加某一种校验后直接打开,内部调用就加另一种检验,特别是内部跳转要做好权限控制,确保真的是你自己的app调用的内部调用才能打开,防止别人只是用URL Schemes就打开了你的内部页面。

想到这,那是不是可以加多种前缀呢,答案肯定是可以的,具体看不同公司的业务。这里就先加两种先。 如下:

keyvalue
native://to_home_pageHomeViewController
native://to_buy_pageBuyViewController
httpWebViewController
httpsWebViewController
......

上面是说,

  1. 当key是native://to_home_page的时候,就进入主页,打开HomeViewController这个页面。
  2. 当key是http的时候。就进入网页,打开WebViewController这个页面渲染。

看到这,那么路由的定义也就出来了。 统一的入口和传参,如:

YYRouter.push(jumpParams: [:])

然后调用上面的路由表如下:

YYRouter.push(jumpParams: ["to":"native://to_home_page"]) // 去到首页
YYRouter.push(jumpParams: ["to":"http://www.baidu.com"]) // 打开网页
YYRouter.push(jumpParams: ["to":"https://www.baidu.com"]) // 打开网页

想传参数,那就这样。

YYRouter.push(jumpParams: ["to":"native://to_home_page", "name": "名字"])
YYRouter.push(jumpParams: ["to":"http://www.baidu.com&a=1&b=2", "name": "名字"])
YYRouter.push(jumpParams: ["to":"https://www.baidu.com&c=3&d=4", "name": "名字"])

终上一个路由的定义就出来了。

下一编,再讲一个路由的具体实现。 Swift路由组件(二)路由的实现

链接:https://juejin.cn/post/7032164814210203685/

收起阅读 »

Metal 框架之设置加载和存储操作

iOS
Metal 框架之设置加载和存储操作「这是我参与11月更文挑战的第19天,活动详情查看:2021最后一次更文挑战」 概述 通过设置 MTLLoadAction 和 MTLStoreAction 属性,可以定义渲染通道加载和存储 MTLRenderPassAtt...
继续阅读 »

Metal 框架之设置加载和存储操作


「这是我参与11月更文挑战的第19天,活动详情查看:2021最后一次更文挑战


概述


通过设置 MTLLoadAction 和 MTLStoreAction 属性,可以定义渲染通道加载和存储 MTLRenderPassAttachmentDescriptor 对象 的方式。为渲染目标设置适当的操作,在渲染通道的开始(加载)或结束(存储)时,可以避免昂贵且不必要的工作。


在 texture 属性上设置渲染目标的纹理,在 loadAction 和 storeAction 属性上设置它的动作:



let renderPassDescriptor = MTLRenderPassDescriptor()


// Color render target

renderPassDescriptor.colorAttachments[0].texture = colorTexture

renderPassDescriptor.colorAttachments[0].loadAction = .clear

renderPassDescriptor.colorAttachments[0].storeAction = .store

// Depth render target

renderPassDescriptor.colorAttachments[0].texture = depthTexture

renderPassDescriptor.colorAttachments[0].loadAction = .dontCare

renderPassDescriptor.colorAttachments[0].storeAction = .dontCare


// Stencil render target

renderPassDescriptor.colorAttachments[0].texture = stencilTexture

renderPassDescriptor.colorAttachments[0].loadAction = .dontCare

renderPassDescriptor.colorAttachments[0].storeAction = .dontCare



选择加载操作


有多个加载操作选项可用,选择哪一个选项,取决于渲染目标的加载需求。



  • 不需要渲染目标的先前内容,而是渲染到其所有像素时,选择 MTLLoadAction.dontCare


此操作不会产生任何成本,并且在渲染通道开始时像素值始终未定义。


不需要考虑加载操作.png



  • 不需要渲染目标的先前内容,只需渲染其部分像素时,选择 MTLLoadAction.clear


此操作会产生将渲染目标的清除值写入每个像素的成本。


清楚成本.png



  • 需要渲染目标的先前内容,并且只渲染到它的一些像素时,选择 MTLLoadAction.load


此操作会产生从内存中加载每个像素的先前值的成本,明显慢于 MTLLoadAction.dontCare 或 MTLLoadAction.clear。


加载成本.png


选择存储操作


有多个存储操作选项可用,选择哪一个选项,取决渲染目标的存储需求。



  • 不需要保留渲染目标的内容,选择 MTLStoreAction.dontCare


 此操作不会产生任何成本,并且在渲染通道结束时像素值始终未定义。 在渲染通道中,为中间渲染目标选择此操作,之后不需要该中间的结果。 对于深度和模板渲染目标这是正确的选择。


不关心存储.png



  • 确实需要保留渲染目标的内容,选择 MTLStoreAction.store


此操作将每个像素的值存储到内存,会产生成本。 对于可绘制对象,这始终是正确的选择。


存储成本.png



  • 渲染目标是多重采样纹理


当执行多重采样时,可以选择存储渲染目标的多重采样或解析数据。 对于多重采样数据,其存储在渲染目标的 texture 属性中。 对于解析的数据,其存储在渲染目标的 resolveTexture 属性中。 多重采样时,请参考此表选择存储操作: 





































多重采样数据存储解析数据存储需要解析纹理需要的存储操作
是 是 是  MTLStoreAction.storeAndMultisampleResolve
 MTLStoreAction.store
 MTLStoreAction.multisampleResolve
  MTLStoreAction.dontCare

要在单个渲染通道中存储和解析多采样纹理,请始终选择 MTLStoreAction.storeAndMultisampleResolve 操作并使用单个渲染命令编码器。



  • 需要推迟存储选择 


在某些情况下,在收集更多渲染通道信息之前,可能不知道要对特定渲染目标使用哪个存储操作。 要推迟存储操作选择,请在创建 MTLRenderPassAttachmentDescriptor 对象时设置 MTLStoreAction.unknown 值。 设置未知的存储操作,可以避免消耗潜在的成本(因为设置另一个存储操作成本更高)。 但是,在完成对渲染通道的编码之前,必须指定有效的存储操作; 否则,会发生错误。


评估渲染通道之间的操作


可以在多个渲染过程中使用相同的渲染目标。 对于任何两个渲染通道之间的同一渲染目标,可能有多种加载和存储组合,选择哪一种组合,取决于渲染目标从一个渲染通道到另一个渲染通道的需求。



  • 下一个渲染通道中,不需要渲染目标的先前内容 


在第一个渲染通道中,选择 MTLStoreAction.dontCare 以避免存储渲染目标的内容。 在第二个渲染通道中,选择 MTLLoadAction.dontCare 或 MTLLoadAction.clear 以避免加载渲染目标的内容。


渲染通道的评估1.png


渲染通道的评估2.png



  • 需要在下一个渲染通道中使用渲染目标的先前内容


在第一个渲染通道中,选择 MTLStoreAction.store、MTLStoreAction.multisampleResolve 或 MTLStoreAction.storeAndMultisampleResolve 来存储渲染目标的内容。 在第二个渲染通道中,选择 MTLLoadAction.load 以加载渲染目标的内容。


渲染通道间内容传递.png


总结


本文介绍了根据渲染的需要,来设置渲染目标的加载和存储操作,合理的设置这些操作,可以避免昂贵且不必要的工作。通过图文详细介绍了,根据不同的渲染需求,设置不同的加载和存储操作达到的渲染效果。


作者:__sky
链接:https://juejin.cn/post/7033731322850148366
来源:稀土掘金

收起阅读 »

iOS 封装一个简易 UITableView 链式监听点击事件的功能思路与实现

iOS
废话开篇:RxSwift 对于其功能可以说是 swift 语言的高度封装了,但是它里面也用到了一些 OC 特性,比如交换方法实现。RxSwift 对于 UITableView 的点击事件就进行了二次封装,里面就交换了 respondsToSelector 方法...
继续阅读 »

废话开篇:RxSwift 对于其功能可以说是 swift 语言的高度封装了,但是它里面也用到了一些 OC 特性,比如交换方法实现。RxSwift 对于 UITableView 的点击事件就进行了二次封装,里面就交换了 respondsToSelector 方法及重写了消息转发机制下的 forwardInvocationRxSwift 源码太复杂,因此,简单用 OC 写一个 demo,来理解一下 RxSwift 对于 UITableView 的点击事件的绑定。


1、实现原理


1、修改一个对象的 respondsToSelector 方法,当进来判断的 sel 是要求继续进行的方法时,返回 YES,这里很明显就是判断 tableView:didSelectRowAtIndexPath: 这个方法。这里注意的是,一个对象即使没有遵循代理协议而只要你实现了代理方法,那么,它也是可以正常执行的,代理协议的遵守只是方便开发通过编译器提示去实现代理方法的。


也就是说,让对象作为 UITableView 可执行 tableView:didSelectRowAtIndexPath: 方法的代理,但是不去实现这个代理方法。


2、修改一个对象的 forwardInvocation 方法,当一个对象调用方法出现没有实现的时候就要进行消息转发了,那么,在转发的时候截获 tableView:didSelectRowAtIndexPath: 方法的参数,进而转到别的对象去执行后续操作。


2、代码效果


image.png


当点击 cell 的时候,就通过上图中的 block 进行响应。这里其实从风格上有点类似 RX,但是,这里并没有对创建中对象的内存进行管理,下面有提到,一般 RxSwift 会有 Disposable 对象的返回,它就是来控制序列中创建的对象何时释放的类,可以用属性保存 DisposeBag,让序列与当前使用类生命周期一致,也可以在方法执行的最后面直接执行 dispose 销毁。


3、UITableView 的 rxRegistSelected 方法的实现


这里创建一个 UITableView 的分类:


UITableView+KDS.h


image.png


UITableView+KDS.m


image.png


这里圈出1KDSDelegateProxy 类就是 tableView:didSelectRowAtIndexPath: 代理方法处理类,并且圈出2UITableView 提供了一个 delegate,并在此之前调用 saveNeedDelegateSel 保存了需要消息转发的代理方法,这个方法后面解释。


到了这里,UITableView 就可以执行 rxRegistSelected 这个方法了,并且要为这个方法返回的 block1 传一个 UITableViewCell 点击事件响应的 block2block2 是真正执行的点击事件具体实现,block1 仅为仿写 RAC 而写。


4、KDSDelegateProxy 对象的实现内容


KDSDelegateProxy.h


image.png


KDSDelegateProxy.m


image.png


image.png


4、KDSTableViewDelegateProxy


KDSTableViewDelegateProxy 是遵循 UITableViewDelegate 协议的对象,并为该对象保存外界传进来的 cell 点击的代理方法


image.png


5、总结与思考


RxSwift 远比上述复杂的多,换句话说个人能力有限说很难从一个百米高楼中去推断夯实地基的具体细节,因为毕竟不是参与施工人员,所以,这里也仅仅是个人思路。那么,说一下为什么没有类似 Disposable 对象,因为 demo 代码的的对象用的是 static 修饰的,如果不用全局变量,那么,作用域外对象就会销毁,代码也就无法运行了,所以,完全可以封装一个 WSLDisposable 类,在最里层的 block 里作为返回值,在最外层进行 dispose 销毁操作,来临时控制中间过程中的对象生命周期。或者封装类似 WSLDisposeBag,将它作为属性保存在例如控制器下,生命周期与当前 控制器一致。


好了,文章本意也仅分享,代码拙劣,大神勿笑。


作者:头疼脑胀的代码搬运工
链接:https://juejin.cn/post/7033679440613736456
收起阅读 »

iOS中的事件

iOS
iOS中的事件「这是我参与11月更文挑战的第23天,活动详情查看:2021最后一次更文挑战」iOS中的事件在用户使用APP过程中,会产生各种各样的事件,可以分为三大类触摸事件(如点击...)加速器事件(如摇一摇...)远程控制事件(如耳机可以控制手机音量......
继续阅读 »

iOS中的事件

「这是我参与11月更文挑战的第23天,活动详情查看:2021最后一次更文挑战

iOS中的事件

  • 在用户使用APP过程中,会产生各种各样的事件,可以分为三大类
  • 触摸事件(如点击...)
  • 加速器事件(如摇一摇...)
  • 远程控制事件(如耳机可以控制手机音量...)

响应者对象(UIResponder)

说到触摸事件,首先需要了解一个概念:响应者对象

  • 在iOS中不是任何对象都能处理事件,只有继承了 UIResponder 的对象才能接收并处理事件,通常被称为“响应者对象”。如 UIApplicationUIViewControllerUIView 等等

  • UIResponder 内部提供了以下方法来处理事件

  • 触摸事件

    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
    - (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
    - (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
    - (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;

  • 加速器事件

    - (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event;
    - (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event;
    - (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event;

  • 远程控制方法

    - (void)remoteControlReceivedWithEvent:(UIEvent *)event;


UIView 的触摸事件处理

  • UIView 是 UIResponder 的子类,可以覆盖以下4个方法处理不同的触摸事件

  • 一根或者多根手指开始触摸 view,系统会自动调用 view 的下面方法

    - (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event;

  • 一根或者多根手指在 view 上移动,系统会自动调用 view 的下面方法

    - (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;

  • 一根或者多根手指离开 view,系统会自动调用 view 的下面方法

        - (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;

  • 触摸结束前,某个系统事件(例如电话呼入)会打断触摸过程,系统会自动调用 view 的下面方法

    - (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;


UITouch

  • 当用户用一根手指触摸屏幕时,会创建一个与手指关联的 UITouch 对象
  • 一根手指对应一个 UITouch 对象
  • UITouch 的作用:保存着跟手指相关的信息,比如触摸的位置、时间、阶段
  • 当手指移动时,系统会更新同一个 UITouch 对象,使之能够一直保存该手指在的触摸位置
  • 当手指离开屏幕时,系统会销毁相应的 UITouch 对象
  • UITouch 相关属性
    • window 触摸产生时所处的窗口
    • view 触摸产生时所处的视图
    • tapCount 短时间内按屏幕的次数,根据 tapCount 判断单击、双击或更多点击
    • timestamp 记录了触摸事件产生或变化的时间,单位是秒
    • phase 当前触摸事件所处的状态
  • UITouch 相关方法
    • 返回值表示触摸在 view 上的位置,这里返回的位置是针对view的坐标系的(以 view 的左上角为原点(0,0)),调用时如果传入的 view 参数是 nil 的话,返回的时触摸点在 window 的位置

      [touch locationInView:view];

    • 该方法记录了上一个点的位置

      [touch previousLocationInView:view];

注:
iPhone开发中,要避免使用双击事件
如果要在一个 view 中监听多个手指,需要设置属性

//需要view支持多个手
view.multipleTouchEnabled = YES;

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    NSLog(@"%ld",(long)touches.count); //2
}


UIEvent

  • 每产生一个事件,就会产生一个 UIEvent 对象
  • UIEvent 被称为事件对象,用于记录事件产生的时刻和类型
  • UIEvent 相关属性
    • 事件类型
      • type 枚举类型(触摸事件、加速器事件、远程控制事件)
      • subtype
    • timestamp 事件产生时间
  • UIEvent 相关方法
    • UIEvent 提供相应方法用于获取在某个 view 上面的接触对象(UITouch

简单示例

实现需求:一个按钮可以在屏幕任务拖拽

Kapture 2021-11-22 at 22.49.40.gif

1.自定义一个 UIImageView

@implementation InputImageView

- (instancetype)initWithFrame:(CGRect)frame{
    self = [super initWithFrame:frame];
    if (self) {        
        UIImage *image = [UIImage imageNamed:@"inputButton"];
        self.image = image;
        self.userInteractionEnabled = YES;
    }
    return self;
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
UITouch *touch = touches.anyObject;
//获取当前点
CGPoint currentPoint = [touch locationInView:self];
//获取上一个点的位置
CGPoint previousPoint = [touch previousLocationInView:self];
//获取x轴偏移量
    CGFloat offsetX = currentPoint.x - previousPoint.x;
    CGFloat offsetY = currentPoint.y - previousPoint.y;
    //修改view的位置
    self.transform = CGAffineTransformTranslate(self.transform, offsetX, offsetY);
}
@end

2.实际调用

#import "ViewController.h"
#import "InputImageView.h"

@interface ViewController ()
@property (nonatomic,strong) InputImageView *redView;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    InputImageView *inputImageView = [[InputImageView alloc]initWithFrame:CGRectMake(150, 150, 56, 56)];
    [self.view addSubview:inputImageView];
}
@end


收起阅读 »

iOS 获取图片的主题色

iOS
iOS 获取图片的主题色目录1.需求背景2.代码部分3.使用效果及代码地址需求背景有时候我们会有这样的需求,用户从相册选择一张照片,返回展示的时候,除了展示照片还要让整体背景也是和照片相近颜色,最近自己写了一个图片加水印的项目,想加上此功能,然鹅谷歌搜了一圈发...
继续阅读 »

iOS 获取图片的主题色

目录

1.需求背景
2.代码部分
3.使用效果及代码地址

需求背景

  • 有时候我们会有这样的需求,用户从相册选择一张照片,返回展示的时候,除了展示照片还要让整体背景也是和照片相近颜色,最近自己写了一个图片加水印的项目,想加上此功能,然鹅谷歌搜了一圈发现全是OC代码写的,直接使用好像还存在一些问题,所以本文分别用swift和OC实现相关功能。

代码部分

  • 主要逻辑:
  1. 将图片按比例缩小,因为后续遍历图片每个像素点,循环次数是图片width x height,如果直接原图去遍历,可能一次循环就要跑几十万、百万次,需要时间非常久,所以要将图片缩小。
  2. 获取图片的所有像素的RGB值,每组RGB使用数组存储(可以根据自己的需求过滤部分颜色),然后用Set将数组装起来。
  3. 统计Set里面相同次数最多的色值,即是整个图片的主题色

swift实现代码:

ssss.png

调用:

selectedImage.subjectColor({[unowned self] color in
guard let subjectColor = color else { return }
self.view.backgroundColor = subjectColor
})

因为里面是两个for循环,时间复杂度是On^2,如果设置的width和Height比较大的话,会比较耗时,在主线程里面执行可能会卡住,所以使用了gcd开启子线程去执行,完成后回到主线程执行回调。

OC实现代码:

Snipaste_2021-11-24_16-19-45.png

使用效果及代码地址

54786116-bc1d-45ab-8eb7-2af3fe2a5520.gif

demon地址

收起阅读 »

2020-iOS最新面试题解析(原理篇)

runtime怎么添加属性、方法等ivar表示成员变量class_addIvarclass_addMethodclass_addPropertyclass_addProtocolclass_replaceProperty是否可以把比较耗时的操作放在NSNoti...
继续阅读 »

runtime怎么添加属性、方法等


  • ivar表示成员变量
  • class_addIvar
  • class_addMethod
  • class_addProperty
  • class_addProtocol
  • class_replaceProperty


是否可以把比较耗时的操作放在NSNotificationCenter中


  • 首先必须明确通知在哪个线程中发出,那么处理接受到通知的方法也在这个线程中调用
  • 如果在异步线程发的通知,那么可以执行比较耗时的操作;
  • 如果在主线程发的通知,那么就不可以执行比较耗时的操作


runtime 如何实现 weak 属性


首先要搞清楚weak属性的特点


weak策略表明该属性定义了一种“非拥有关系” (nonowning relationship)。
为这种属性设置新值时,设置方法既不保留新值,也不释放旧值。此特质同assign类似;
然而在属性所指的对象遭到摧毁时,属性值也会清空(nil out)


那么runtime如何实现weak变量的自动置nil?


runtime对注册的类,会进行布局,会将 weak 对象放入一个 hash 表中。
用 weak 指向的对象内存地址作为 key,当此对象的引用计数为0的时候会调用对象的 dealloc 方法,
假设 weak 指向的对象内存地址是a,那么就会以a为key,在这个 weak hash表中搜索,找到所有以a为key的 weak 对象,从而设置为 nil。


weak属性需要在dealloc中置nil么


  • 在ARC环境无论是强指针还是弱指针都无需在 dealloc 设置为 nil , ARC 会自动帮我们处理
  • 即便是编译器不帮我们做这些,weak也不需要在dealloc中置nil
  • 在属性所指的对象遭到摧毁时,属性值也会清空


// 模拟下weak的setter方法,大致如下
- (void)setObject:(NSObject *)object
{
objc_setAssociatedObject(self, "object", object, OBJC_ASSOCIATION_ASSIGN);
[object cyl_runAtDealloc:^{
_object = nil;
}];
}


一个Objective-C对象如何进行内存布局?(考虑有父类的情况)


  • 所有父类的成员变量和自己的成员变量都会存放在该对象所对应的存储空间中
  • 父类的方法和自己的方法都会缓存在类对象的方法缓存中,类方法是缓存在元类对象中
  • 每一个对象内部都有一个isa指针,指向他的类对象,类对象中存放着本对象的如下信息
    • 对象方法列表
    • 成员变量的列表
    • 属性列表
  • 每个 Objective-C 对象都有相同的结构,如下图所示

Objective-C 对象的结构图

ISA指针

根类(NSObject)的实例变量

倒数第二层父类的实例变量

...

父类的实例变量

类的实例变量


  • 根类对象就是NSObject,它的super class指针指向nil
  • 类对象既然称为对象,那它也是一个实例。类对象中也有一个isa指针指向它的元类(meta class),即类对象是元类的实例。元类内部存放的是类方法列表,根元类的isa指针指向自己,superclass指针指向NSObject类


一个objc对象的isa的指针指向什么?有什么作用?


  • 每一个对象内部都有一个isa指针,这个指针是指向它的真实类型
  • 根据这个指针就能知道将来调用哪个类的方法


下面的代码输出什么?


@implementation Son : Father
- (id)init
{
self = [super init];
if (self) {
NSLog(@"%@", NSStringFromClass([self class]));
NSLog(@"%@", NSStringFromClass([super class]));
}
return self;
}
@end


  • 答案:都输出 Son
  • 这个题目主要是考察关于objc中对 self 和 super 的理解:
    • self 是类的隐藏参数,指向当前调用方法的这个类的实例。而 super 本质是一个编译器标示符,和 self 是指向的同一个消息接受者
    • 当使用 self 调用方法时,会从当前类的方法列表中开始找,如果没有,就从父类中再找;
    • 而当使用 super时,则从父类的方法列表中开始找。然后调用父类的这个方法
    • 调用[self class] 时,会转化成 objc_msgSend函数
id objc_msgSend(id self, SEL op, ...)
    • 调用 [super class]时,会转化成 objc_msgSendSuper函数
id objc_msgSendSuper(struct objc_super *super, SEL op, ...)
    • 第一个参数是 objc_super 这样一个结构体,其定义如下
struct objc_super {
__unsafe_unretained id receiver;
__unsafe_unretained Class super_class;
};
    • 第一个成员是 receiver, 类似于上面的 objc_msgSend函数第一个参数self
    • 第二个成员是记录当前类的父类是什么,告诉程序从父类中开始找方法,找到方法后,最后内部是使用 objc_msgSend(objc_super->receiver, @selector(class))去调用, 此时已经和[self class]调用相同了,故上述输出结果仍然返回 Son
    • objc Runtime开源代码对- (Class)class方法的实现


    -(Class)class {
return object_getClass(self);
}



收起阅读 »

iOS 面试策略之算法基础1-3节

1. 基本数据结构数组数组是最基本的数据结构。在 Swift 中,以前 Objective-C 时代中将 NSMutableArray 和 NSArray 分开的做法,被统一到了唯一的数据结构 —— Array 。虽然看上去就一种数据结构,其实它的实现有三种:...
继续阅读 »

1. 基本数据结构


数组


数组是最基本的数据结构。在 Swift 中,以前 Objective-C 时代中将 NSMutableArray 和 NSArray 分开的做法,被统一到了唯一的数据结构 —— Array 。虽然看上去就一种数据结构,其实它的实现有三种:


  • ContiguousArray:效率最高,元素分配在连续的内存上。如果数组是值类型(栈上操作),则 Swift 会自动调用 Array 的这种实现;如果注重效率,推荐声明这种类型,尤其是在大量元素是类时,这样做效果会很好。
  • Array:会自动桥接到 Objective-C 中的 NSArray 上,如果是值类型,其性能与 ContiguousArray 无差别。
  • ArraySlice:它不是一个新的数组,只是一个片段,在内存上与原数组享用同一区域。


下面是数组最基本的一些运用。


// 声明一个不可修改的数组
let nums = [1, 2, 3]
let nums = [Int](repeating: 0, count: 5)

// 声明一个可以修改的数组
var nums = [3, 1, 2]
// 增加一个元素
nums.append(4)
// 对原数组进行升序排序
nums.sort()
// 对原数组进行降序排序
nums.sort(by: >)
// 将原数组除了最后一个以外的所有元素赋值给另一个数组
// 注意:nums[0..<nums.count - 1] 返回的是 ArraySlice,不是 Array
let anotherNums = Array(nums[0 ..< nums.count - 1])


不要小看这些简单的操作:数组可以依靠它们实现更多的数据结构。Swift 虽然不像 Java 中有现成的队列和栈,但我们完全可以用数组配合最简单的操作实现这些数据结构,下面就是用数组实现栈的示例代码。


// 用数组实现栈
struct Stack<Element> {
private var stack: [Element]
var isEmpty: Bool { return stack.isEmpty }
var peek: AnyObject? { return stack.last }

init() {
stack = [Element]()
}

mutating func push(_ element: Element) {
stack.append(object)
}

mutating func pop() -> Element? {
return stack.popLast()
}
}

// 初始化一个栈
let stack = Stack<String>()


最后特别强调一个操作:reserveCapacity()。它用于为原数组预留空间,防止数组在增加和删除元素时反复申请内存空间或是创建新数组,特别适用于创建和 removeAll() 时候进行调用,为整段代码起到提高性能的作用。


字典和集合


字典和集合(这里专指HashSet)经常被使用的原因在于,查找数据的时间复杂度为 O(1)。

一般字典和集合要求它们的 Key 都必须遵守 Hashable 协议,Cocoa 中的基本数据类型都

满足这一点;自定义的 class 需要实现 Hashable,而又因为 Hashable 是对 Equable 的扩展,

所以还要重载 == 运算符。


下面是关于字典和集合的一些实用操作:


let primeNums: Set = [3, 5, 7, 11, 13]
let oddNums: Set = [1, 3, 5, 7, 9]

// 交集、并集、差集
let primeAndOddNum = primeNums.intersection(oddNums)
let primeOrOddNum = primeNums.union(oddNums)
let oddNotPrimNum = oddNums.subtracting(primeNums)

// 用字典和高阶函数计算字符串中每个字符的出现频率,结果 [“h”:1, “e”:1, “l”:2, “o”:1]
Dictionary("hello".map { ($0, 1) }, uniquingKeysWith: +)


集合和字典在实战中经常与数组配合使用,请看下面这道算法题:


给一个整型数组和一个目标值,判断数组中是否有两个数字之和等于目标值


这道题是传说中经典的 “2Sum”,我们已经有一个数组记为 nums,也有一个目标值记为 target,最后要返回一个 Bool 值。


最粗暴的方法就是每次选中一个数,然后遍历整个数组,判断是否有另一个数使两者之和为 target。这种做法时间复杂度为 O(n^2)。


采用集合可以优化时间复杂度。在遍历数组的过程中,用集合每次保存当前值。假如集合中已经有了目标值减去当前值,则证明在之前的遍历中一定有一个数与当前值之和等于目标值。这种做法时间复杂度为 O(n),代码如下。


func twoSum(nums: [Int], _ target: Int) -> Bool {
var set = Set<Int>()

for num in nums {
if set.contains(target - num) {
return true
}

set.insert(num)
}

return false
}


如果把题目稍微修改下,变为


给定一个整型数组中有且仅有两个数字之和等于目标值,求两个数字在数组中的序号


思路与上题基本类似,但是为了方便拿到序列号,我们采用字典,时间复杂度依然是 O(n)。代码如下。


func twoSum(nums: [Int], _ target: Int) -> [Int] {
var dict = [Int: Int]()

for (i, num) in nums.enumerated() {
if let lastIndex = dict[target - num] {
return [lastIndex, i]
} else {
dict[num] = i
}
}

fatalError("No valid output!")
}


字符串和字符


字符串在算法实战中极其常见。在 Swift 中,字符串不同于其他语言(包括 Objective-C),它是值类型而非引用类型,它是多个字符构成的序列(并非数组)。首先还是列举一下字符串的通常用法。


// 字符串和数字之间的转换
let str = "3"
let num = Int(str)
let number = 3
let string = String(num)

// 字符串长度
let len = str.count

// 访问字符串中的单个字符,时间复杂度为O(1)
let char = str[str.index(str.startIndex, offsetBy: n)]

// 修改字符串
str.remove(at: n)
str.append("c")
str += "hello world"

// 检测字符串是否是由数字构成
func isStrNum(str: String) -> Bool {
return Int(str) != nil
}

// 将字符串按字母排序(不考虑大小写)
func sortStr(str: String) -> String {
return String(str.sorted())
}

// 判断字符是否为字母
char.isLetter

// 判断字符是否为数字
char.isNumber

// 得到字符的 ASCII 数值
char.asciiValue


关于字符串,我们来一起看一道以前的 Google 面试题。


给一个字符串,将其按照单词顺序进行反转。比如说 s 是 "the sky is blue",

那么反转就是 "blue is sky the"。


这道题目一看好简单,不就是反转字符串的翻版吗?这种方法有以下两个问题


  • 每个单词长度不一样
  • 空格需要特殊处理
    这样一来代码写起来会很繁琐而且容易出错。不如我们先实现一个字符串翻转的方法。


fileprivate func reverse<T>(_ chars: inout [T], _ start: Int, _ end: Int) {
var start = start, end = end

while start < end {
swap(&chars, start, end)
start += 1
end -= 1
}
}

fileprivate func swap<T>(_ chars: inout [T], _ p: Int, _ q: Int) {
(chars[p], chars[q]) = (chars[q], chars[p])
}


有了这个方法,我们就可以实行下面两种字符串翻转:


  • 整个字符串翻转,"the sky is blue" -> "eulb si yks eht"
  • 每个单词作为一个字符串单独翻转,"eulb si yks eht" -> "blue is sky the"
    整体思路有了,我们就可以解决这道问题了


func reverseWords(s: String?) -> String? {
guard let s = s else {
return nil
}

var chars = Array(s), start = 0
reverse(&chars, 0, chars.count - 1)

for i in 0 ..< chars.count {
if i == chars.count - 1 || chars[i + 1] == " " {
reverse(&chars, start, i)
start = i + 2
}
}

return String(chars)
}


时间复杂度还是 O(n),整体思路和代码简单很多。


总结


在 Swift 中,数组、字符串、集合以及字典是最基本的数据结构,但是围绕这些数据结构的问题层出不穷。而在日常开发中,它们使用起来也非常高效(栈上运行)和安全(无需顾虑线程问题),因为他们都是值类型。


2. 链表


本节我们一起来探讨用 Swift 如何实现链表以及链表相关的技巧。


基本概念


对于链表的概念,实在是基本概念太多,这里不做赘述。我们直接来实现链表节点。


class ListNode { 
var val: Int
var next: ListNode?

init(_ val: Int) {
self.val = val
}
}


有了节点,就可以实现链表了。


class LinkedList {
var head: ListNode?
var tail: ListNode?

// 头插法
func appendToHead(_ val: Int) {
let node = ListNode(val)

if let _ = head {
node.next = head
} else {
tail = node
}

head = node
}

// 头插法
func appendToTail(_ val: Int) {
let node = ListNode(val)

if let _ = tail {
tail!.next = node
} else {
head = node
}

tail = node
}
}


有了上面的基本操作,我们来看如何解决复杂的问题。


Dummy 节点和尾插法


话不多说,我们直接先来看下面一道题目。


给一个链表和一个值 x,要求将链表中所有小于 x 的值放到左边,所有大于等于 x 的值放到右边。原链表的节点顺序不能变。

例:1->5->3->2->4->2,给定x = 3。则我们要返回1->2->2->5->3->4


直觉告诉我们,这题要先处理左边(比 x 小的节点),然后再处理右边(比 x 大的节点),最后再把左右两边拼起来。


思路有了,再把题目抽象一下,就是要实现这样一个函数:


func partition(_ head: ListNode?, _ x: Int) -> ListNode? {}


即我们有给定链表的头节点,有给定的x值,要求返回新链表的头结点。接下来我们要想:怎么处理左边?怎么处理右边?处理完后怎么拼接?


先来看怎么处理左边。我们不妨把这个题目先变简单一点:


给一个链表和一个值 x,要求只保留链表中所有小于 x 的值,原链表的节点顺序不能变。


例:1->5->3->2->4->2,给定x = 3。则我们要返回 1->2->2


我们只要采用尾插法,遍历链表,将小于 x 值的节点接入新的链表即可。代码如下:


func getLeftList(_ head: ListNode?, _ x: Int) -> ListNode? { 
let dummy = ListNode(0)
var pre = dummy, node = head

while node != nil {
if node!.val < x {
pre.next = node
pre = node!
}
node = node!.next
}

// 防止构成环
pre.next = nil
return dummy.next
}


注意,上面的代码我们引入了 Dummy 节点,它的作用就是作为一个虚拟的头前结点。我们引入它的原因是我们不知道要返回的新链表的头结点是哪一个,它有可能是原链表的第一个节点,可能在原链表的中间,也可能在最后,甚至可能不存在(nil)。而 Dummy 节点的引入可以巧妙的涵盖所有以上情况,我们可以用 dummy.next 方便得返回最终需要的头结点。


现在我们解决了左边,右边也是同样处理。接着只要让左边的尾节点指向右边的头结点即可。全部代码如下:


func partition(_ head: ListNode?, _ x: Int) -> ListNode? {
// 引入Dummy节点
let prevDummy = ListNode(0), postDummy = ListNode(0)
var prev = prevDummy, post = postDummy

var node = head

// 用尾插法处理左边和右边
while node != nil {
if node!.val < x {
prev.next = node
prev = node!
} else {
post.next = node
post = node!
}
node = node!.next
}

// 防止构成环
post.next = nil
// 左右拼接
prev.next = postDummy.next

return prevDummy.next
}


注意这句 post.next = nil,这是为了防止链表循环指向构成环,是必须的但是很容易忽略的一步。

刚才我们提到了环,那么怎么检测链表中是否有环存在呢?



快行指针


笔者理解快行指针,就是两个指针访问链表,一个在前一个在后,或者一个移动快另一个移动慢,这就是快行指针。来看一道简单的面试题:


如何检测一个链表中是否有环?


答案是用两个指针同时访问链表,其中一个的速度是另一个的 2 倍,如果他们相等了,那么这个链表就有环了,这就是快行指针的实际使用。代码如下:


func hasCycle(_ head: ListNode?) -> Bool { 
var slow = head
var fast = head

while fast != nil && fast!.next != nil {
slow = slow!.next
fast = fast!.next!.next

if slow === fast {
return true
}
}

return false
}


再举一个快行指针一前一后的例子,看下面这道题。


删除链表中倒数第 n 个节点。例:1->2->3->4->5,n = 2。返回1->2->3->5。

注意:给定 n 的长度小于等于链表的长度。


解题思路依然是快行指针,这次两个指针移动速度相同。但是一开始,第一个指针(指向头结点之前)就落后第二个指针 n 个节点。接着两者同时移动,当第二个移动到尾节点时,第一个节点的下一个节点就是我们要删除的节点。代码如下:


func removeNthFromEnd(head: ListNode?, _ n: Int) -> ListNode? {
guard let head = head else {
return nil
}

let dummy = ListNode(0)
dummy.next = head
var prev: ListNode? = dummy
var post: ListNode? = dummy

// 设置后一个节点初始位置
for _ in 0 ..< n {
if post == nil {
break
}
post = post!.next
}

// 同时移动前后节点
while post != nil && post!.next != nil {
prev = prev!.next
post = post!.next
}

// 删除节点
prev!.next = prev!.next!.next

return dummy.next
}


这里还用到了 Dummy 节点,因为有可能我们要删除的是头结点。


总结


这次我们用 Swift 实现了链表的基本结构,并且实战了链表的几个技巧。在结尾处,我还想强调一下 Swift 处理链表问题的两个细节问题:


  • 一定要注意头结点可能就是 nil。所以给定链表,我们要看清楚 head 是不是 optional,在判断是不是要处理这种边界条件。
  • 注意每个节点的 next 可能是 nil。如果不为 nil,请用"!"修饰变量。在赋值的时候,也请注意"!"将 optional 节点传给非 optional 节点的情况。


3. 栈和队列


这期我们来讨论一下栈和队列。在 Swift 中,没有内设的栈和队列,很多扩展库中使用 Generic Type 来实现栈或是队列。正规的做法使用链表来实现,这样可以保证加入和删除的时间复杂度是 O(1)。然而笔者觉得最实用的实现方法是使用数组,因为 Swift 没有现成的链表,而数组又有很多的 API 可以直接使用,非常方便。


基本概念


对于栈来说,我们需要了解以下几点:


  • 栈是后进先出的结构。你可以理解成有好几个盘子要垒成一叠,哪个盘子最后叠上去,下次使用的时候它就最先被抽出去。
  • 在 iOS 开发中,如果你要在你的 App 中添加撤销操作(比如删除图片,恢复删除图片),那么栈是首选数据结构
  • 无论在面试还是写 App 中,只关注栈的这几个基本操作:push, pop, isEmpty, peek, size。


protocol Stack {
/// 持有的元素类型
associatedtype Element

/// 是否为空
var isEmpty: Bool { get }
/// 栈的大小
var size: Int { get }
/// 栈顶元素
var peek: Element? { get }

/// 进栈
mutating func push(_ newElement: Element)
/// 出栈
mutating func pop() -> Element?
}

struct IntegerStack: Stack {
typealias Element = Int

var isEmpty: Bool { return stack.isEmpty }
var size: Int { return stack.count }
var peek: Element? { return stack.last }

private var stack = [Element]()

mutating func push(_ newElement: Element) {
stack.append(newElement)
}

mutating func pop() -> Element? {
return stack.popLast()
}
}


对于队列来说,我们需要了解以下几点:


  • 队列是先进先出的结构。这个正好就像现实生活中排队买票,谁先来排队,谁先买到票。
  • iOS 开发中多线程的 GCD 和 NSOperationQueue 就是基于队列实现的。
  • 关于队列我们只关注这几个操作:enqueue, dequeue, isEmpty, peek, size。


protocol Queue {
/// 持有的元素类型
associatedtype Element

/// 是否为空
var isEmpty: Bool { get }
/// 队列的大小
var size: Int { get }
/// 队首元素
var peek: Element? { get }

/// 入队
mutating func enqueue(_ newElement: Element)
/// 出队
mutating func dequeue() -> Element?
}

struct IntegerQueue: Queue {
typealias Element = Int

var isEmpty: Bool { return left.isEmpty && right.isEmpty }
var size: Int { return left.count + right.count }
var peek: Element? { return left.isEmpty ? right.first : left.last }

private var left = [Element]()
private var right = [Element]()

mutating func enqueue(_ newElement: Element) {
right.append(newElement)
}

mutating func dequeue() -> Element? {
if left.isEmpty {
left = right.reversed()
right.removeAll()
}
return left.popLast()
}
}


栈和队列互相转化


处理栈和队列问题,最经典的一个思路就是使用两个栈/队列来解决问题。也就是说在原栈/队列的基础上,我们用一个协助栈/队列来帮助我们简化算法,这是一种空间换时间的思路。下面是示例代码:


// 用栈实现队列
struct MyQueue {
var stackA: Stack
var stackB: Stack

var isEmpty: Bool {
return stackA.isEmpty
}

var peek: Any? {
get {
shift()
return stackB.peek
}
}

var size: Int {
get {
return stackA.size + stackB.size
}
}

init() {
stackA = Stack()
stackB = Stack()
}

func enqueue(object: Any) {
stackA.push(object);
}

func dequeue() -> Any? {
shift()
return stackB.pop();
}

fileprivate func shift() {
if stackB.isEmpty {
while !stackA.isEmpty {
stackB.push(stackA.pop()!);
}
}
}
}

// 用队列实现栈
struct MyStack {
var queueA: Queue
var queueB: Queue

init() {
queueA = Queue()
queueB = Queue()
}

var isEmpty: Bool {
return queueA.isEmpty
}

var peek: Any? {
get {
if isEmpty {
return nil
}

shift()
let peekObj = queueA.peek
queueB.enqueue(queueA.dequeue()!)
swap()
return peekObj
}
}

var size: Int {
return queueA.size
}

func push(object: Any) {
queueA.enqueue(object)
}

func pop() -> Any? {
if isEmpty {
return nil
}

shift()
let popObject = queueA.dequeue()
swap()
return popObject
}

private func shift() {
while queueA.size > 1 {
queueB.enqueue(queueA.dequeue()!)
}
}

private func swap() {
(queueA, queueB) = (queueB, queueA)
}
}


上面两种实现方法都是使用两个相同的数据结构,然后将元素由其中一个转向另一个,从而形成一种完全不同的数据。


面试题实战


给一个文件的绝对路径,将其简化。举个例子,路径是 "/home/",简化后为 "/home";路径是"/a/./b/../../c/",简化后为 "/c"。


这是一道 Facebook 的面试题。这道题目其实就是平常在终端里面敲的 cd、pwd 等基本命令所得到的路径。


根据常识,我们知道以下规则:


  • “. ” 代表当前路径。比如 “ /a/. ” 实际上就是 “/a”,无论输入多少个 “ . ” 都返回当前目录
  • “..”代表上一级目录。比如 “/a/b/.. ” 实际上就是 “ /a”,也就是说先进入 “a” 目录,再进入其下的 “b” 目录,再返回 “b” 目录的上一层,也就是 “a” 目录。


然后针对以上信息,我们可以得出以下思路:


  1. 首先输入是个 String,代表路径。输出要求也是 String, 同样代表路径;
  2. 我们可以把 input 根据 “/” 符号去拆分,比如 "/a/b/./../d/" 就拆成了一个String数组["a", "b", ".", "..", "d"];
  1. 创立一个栈然后遍历拆分后的 String 数组,对于一般 String ,直接加入到栈中,对于 ".." 那我们就对栈做 pop 操作,其他情况不错处理。


思路有了,代码也就有了


func simplifyPath(path: String) -> String {
// 用数组来实现栈的功能
var pathStack = [String]()
// 拆分原路径
let paths = path.split(separatedBy: "/")

for path in paths {
// 对于 "." 我们直接跳过
guard path != "." else {
continue
}
// 对于 ".." 我们使用pop操作
if path == ".." {
if (!pathStack.isEmpty) {
pathStack.removeLast()
}
// 对于太注意空数组的特殊情况
} else if path != "" {
pathStack.append(path)
}
}
// 将栈中的内容转化为优化后的新路径
return "/" + pathStack.joined(separator: "/")
}


上面代码除了完成了基本思路,还考虑了大量的特殊情况、异常情况。这也是硅谷面试考察的一个方面:面试者思路的严谨,对边界条件的充分考虑,以及代码的风格规范。


总结


在 Swift 中,栈和队列是比较特殊的数据结构,笔者认为最实用的实现和运用方法是利用数组。虽然它们本身比较抽象,却是很多复杂数据结构和 iOS 开发中的功能模块的基础。这也是一个工程师进阶之路理应熟练掌握的两种数据结构。

收起阅读 »

iOS 面试简单准备

1.简历的准备在面试中,我发现很多人都不能写好一份求职简历,所以我们首先谈谈如何写一份针对互联网公司的求职简历。1.简洁的艺术互联网公司和传统企业有着很大的区别,通常情况下,创新和效率是互联网公司比较追求的公司文化,所以体现在简历上,就是超过一页的简历通常会被...
继续阅读 »

1.简历的准备


在面试中,我发现很多人都不能写好一份求职简历,所以我们首先谈谈如何写一份针对互联网公司的求职简历。


1.简洁的艺术


互联网公司和传统企业有着很大的区别,通常情况下,创新和效率是互联网公司比较追求的公司文化,所以体现在简历上,就是超过一页的简历通常会被认为不够专业。


更麻烦的是,多数超过一页的简历很可能在 HR 手中就被过滤掉了。因为 HR 每天会收到大量的简历,一般情况下每份简历在手中的停留时间也就 10 秒钟左右。而超过一页的简历会需要更多的时间去寻找简历中的有价值部分,对于 HR 来说,她更倾向于认为这种人通常是不靠谱的,因为写个简历都不懂行规,为什么还要给他面试机会呢?


那么我们应该如何精简简历呢? 简单说来就是一个字:删!


删掉不必要的自我介绍信息。很多求职者会将自己在学校所学的课程罗列上去,例如:C 语言,数据结构,数学分析⋯⋯好家伙,一写就是几十门,还放在简历的最上面,就怕面试官看不见。对于这类信息,一个字:删!面试官不关心你上了哪些课程,而且在全中国,大家上的课程也都大同小异,所以没必要写出来。


删除不必要的工作或实习、实践经历。如果你找一份程序员的工作,那么你参加了奥运会的志愿者活动,并且拿到了奖励或者你参加学校的辩论队,获得了最佳辩手这些经历通常是不相关的。诸如此类的还有你帮导师代课,讲了和工作不相关的某某专业课,或者你在学生会工作等等。删除不相关的工作、实习或实践内容可以保证你的简历干净。当然,如果你实在没得可写,比如你是应届生,一点实习经历都没有,那可以适当写一两条,保证你能写够一页的简历,但是那两条也要注意是强调你的团队合作能力或者执行力之类的技能,因为这些才是面试官感兴趣的。


删除不必要的证书。最多写个 4、6 级的证书,什么教师资格证,中高级程序员证,还有国内的各种什么认证,都是没有人关心的。


删除不必要的细节。作为 iOS 开发的面试官,很多求职者在介绍自己的 iOS 项目经历的时候,介绍了这个工程用的工作环境是 Mac OS,使用的机器是 Mac Mini,编译器是 Xcode,能够运行在 iOS 什么版本的环境。还有一些人,把这个项目用到的开源库都写上啦,什么 AFNetworking, CocoaPods 啥的。这些其实都不是重点,请删掉。后面我会讲,你应该如何介绍你的 iOS 项目经历。


自我评价,这个部分是应届生最喜欢写的,各种有没有的优点都写上,例如:


性格开朗、稳重、有活力,待人热情、真诚;工作认真负责,积极主动,能吃苦耐劳,勇于承受压力,勇于创新;有很强的组织能力和团队协作精神,具有较强的适应能力;纪律性强,工作积极配合;意志坚强,具有较强的无私奉献精神。对待工作认真负责,善于沟通、协调有较强的组织能力与团队精神;活泼开朗、乐观上进、有爱心并善于施教并行;上进心强、勤于学习能不断提高自身的能力与综合素质。


这些内容在面试的时候不太好考查,都可以删掉。通常如果有 HR 面的话,HR 自然会考查一些你的沟通,抗压,性格等软实力。


我相信,不管你是刚毕业的学生,还是工作十年的老手,你都可以把你的简历精简到一页 A4 纸上。记住,简洁是一种美,一种效率,也是一种艺术。


2.重要的信息写在最前面


将你觉得最吸引人的地方写在最前面。如果你有牛逼公司的实习,那就把实习经历写在最前面,如果你在一个牛逼的实验室里面做科研,就把研究成果和论文写出来,如果你有获得过比较牛逼的比赛名次(例如 Google code jam, ACM 比赛之类),写上绝对吸引眼球。


所以,每个人的简历的介绍顺序应该都是不一样的,不要在网上下载一个模板,然后就一项一项地填:教育经历,实习经历,得奖经历,个人爱好,这样的简历毫无吸引力,也无法突出你的特点。


除了你的个人特点是重要信息外,你的手机号、邮箱、毕业院校、专业以及毕业时间这些也都是非常重要的,一定要写在简历最上面。


3.不要简单地罗列工作经历


不要简单地说你开发了某某 iOS 客户端。这样简单的罗列你的作品集并不能让面试官很好地了解你的能力,当然,真正在面试时面试官可能会仔细询问,但是一份好的简历,应该省去一些面试官额外询问你的工作细节的时间。


具体的做法是:详细的描述你对于某某 iOS 客户端的贡献。主要包括:你参与了多少比例功能的开发? 你解决了哪些开发中的有挑战的问题? 你是不是技术负责人?


而且,通过你反思这些贡献,你也可以达到自我审视,如果你发现这个项目你根本什么有价值的贡献都没做,就打了打酱油,那你最好不要写在简历上,否则当面试官在面试时问起时,你会很难回答,最终让他发现你的这个项目经历根本一文不值时,肯定会给一个负面的印象。


4.不要写任何虚假或夸大的信息


刚刚毕业的学生都喜欢写精通 Java,精通 C/C++,其实代码可能写了不到 1 万行。我觉得你要精通某个语言,至少得写 50 万行这个语言的代码才行,而且要对语言的各种内部机制和原理有了解。那些宣称精通 Java 的同学,连 Java 如何做内存回收,如何做泛型支持,如何做自动 boxing 和 unboxing 的都不知道,真不知道为什么要写精通二字。


任何夸大或虚假的信息,在面试时被发现,会造成极差的面试印象。所以你如果对某个知识一知半解,要么就写 “使用过” 某某,要么就干脆不写。如果你简历实在太单薄,没办法写上了一些自己打酱油的项目,被问起来怎么办? 请看看下面的故事:


我面试过一个同学,他在面试时非常诚实。我问他一些简历上的东西,他如果不会,就会老实说,这个我只是使用了一下,确实不清楚细节。对于一些没有技术含量的项目,他也会老实说,这个项目他做的工作比较少,主要是别人在做。最后他还会补充说,“我自认为自己数据结构和算法还不错,要不你问我这方面的知识吧。”


这倒是一个不错的办法,对于一个没有项目经验,但是聪明并且数据结构和算法基础知识扎实的应届生,其实我们是非常愿意培养的。很多人以为公司面试是看经验,希望招进来就能干活,其实不是的,至少我们现在以及我以前在网易招人,面试的是对方的潜力,潜力越大,可塑性好,未来进步得也更快;一些资质平庸,却经验稍微丰富一点的开发者,相比聪明好学的面试者,后劲是不足的。


总之,不要写任何虚假或夸大的信息,即使你最终骗得过面试官,进了某公司,如果能力不够,在最初的试用期内,也很可能因为能力不足而被开掉。


5.留下更多信息


刚刚说到,简历最好写够一张 A4 纸即可,那么你如果想留下更多可供面试官参考的信息怎么办呢?其实你可以附上更多的参考链接,这样如果面试官对你感兴趣,自然会仔细去查阅这些链接。对于 iOS 面试来说,GitHub 上面的开源项目地址、博客地址都是不错的参考信息。如果你在微博上也频繁讨论技术,也可以附上微博地址。


我特别建议大家如果有精力,可以好好维护一下自己的博客或者 GitHub 上的开源代码。因为如果你打算把这些写到简历上,让面试官去上面仔细评价你的水平,你就应该对上面的内容做到足够认真的准备。否则,本来面试完面试官还挺感兴趣的,结果一看你的博客和开源代码,评价立刻降低,就得不偿失了。


6.不要附加任何可能带来负面印象的信息


任何与招聘工作无关的东西,尽量不要提。有些信息提了可能有加分,也可能有减分,取决于具体的面试官。下面我罗列一下我认为是减分的信息。


1)个人照片


不要在简历中附加个人照片。个人长相属于与工作能力不相关的信息,也许你觉得你长得很帅,那你怎么知道你的样子不和面试官的情敌长得一样? 也许你长得很漂亮,那么你怎么知道 HR 是否被你长得一样的小三把男朋友抢了? 我说得有点极端,那人们对于长相的评价标准确实千差万别,萝卜青菜各有所爱,加上可能有一些潜在的极端情况,所以没必要附加这部分信息。这属于加了可能有加分,也可能有减分的情况。


2)有风险的爱好


不要写各种奇怪的爱好。喜欢打游戏、抽烟、喝酒,这类可能带来负面印象的爱好最好不要写。的确有些公司会有这种一起联机玩游戏或者喝酒的文化,不过除非你明确清楚对于目标公司,写上会是加分项,否则还是不写为妙。


3)使用 PDF 格式


不要使用 Word 格式的简历,要使用 PDF 的格式。我在招 iOS 程序员时,好多人的简历都是 Word 格式的,我都怀疑这些人是否有 Mac 电脑。因为 Mac 下的 office 那么难用,公司好多人机器上都没有 Mac 版 office。我真怀疑这些人真是的想投简历么? PDF 格式的简历通常能展现出简历的专业性。


4)QQ号码邮箱


不要使用 QQ 号开头的 QQ 邮箱,例如 12345@qq.com ,邮箱的事情我之前简单说过,有些人很在乎这个,有些人觉得无所谓,我个人对用数字开头的 QQ 邮箱的求职者不会有加分,但是对使用 Gmail 邮箱的求职者有加分。因为这涉及到个人的工作效率,使用 Gmail 的人通常会使用邮件组,过滤器,IMAP 协议,标签,这些都有助于提高工作效率。如果你非要使用 QQ 邮箱,也应该申请一个有意义的邮箱名,例如 tangqiaoboy@qq.com 。


7.职业培训信息


不要写参加过某某培训公司的 iOS 培训,特别是那种一、两个月的速成培训。这对于我和身边很多面试官来说,绝对是负分。


这个道理似乎有点奇怪,因为我们从小都是由老师教授新知识的。我自己也实验过,掌握同样的高中课本上的知识,自己自学的速度通常比老师讲授的速度要慢一倍的时间。即一个知识点,如果你自己要看 2 小时的书才能理解的话,有好的老师给你讲解的话,只需要一个小时就够了。所以,我一直希望在学习各种东西的时候都能去听一些课程,因为我认为这样节省了我学习的时间。


但是这个道理在程序员这个领域行不通,为什么这么说呢?原因有两点:


  1. 计算机编程相关的知识更新速度很快。同时,国内的 IT 类资料的翻译质量相当差,原创的优秀书籍也很少。所以,我们通常需要靠阅读英文才能掌握最新的资料。拿 iOS 来说,每年 WWDC 的资料都非常重要,而这些内容涉及版权,国内培训机构很难快速整理成教材。
  2. 计算机编程知识需要较多的专业知识积累和实践。学校的老师更多只能做入门性的教学工作。
    如果一个培训机构有一个老师,他强到能够通过自己做一些项目来积累很多专业知识和实践,并且不断地从国外资料上学习最新的技术。那么这个人在企业里面会比在国内的培训机构更有施展自己能力的空间。国内的培训机构因为受众面的原因,基本上还是培养那种初级的程序员新手,所以对老师的新技术学习速度要求不会那么高,自然老师也不会花那么时间在新技术研究上。但是企业就不一样了,企业需要不停地利用新技术来增强自己的产品竞争力,所以对于 IT 企业来说,产品的竞争就是人才的竞争,所以给优秀的人能够开出很高的薪水。
    所以,我们不能期望从 IT 类培训机构中学习到最新的技术,一切只能通过我们自学。当然,自学之后在同行之间相互交流,对于我们的技术成长也是很有用的。小结



上图是本节讨论的总结,在简历准备上,我们需要考虑简历的简洁性等各种注意事项。


2.寻找机会


1.寻找内推机会

其实,最好的面试机会都不是公开渠道的。好的机会都隐藏于各种内部推荐之中。通过内部推荐,你可以更加了解目标工作的团队和内容,另外内部推荐通常也可以跳过简历筛选环节,直接参加笔试或面试。我所在的猿辅导公司为内推设立了非常高的奖金激励,因为我们发现,综合各种渠道来看,内推的渠道的人才的简历质量最高,面试通过率最高的。


所以,如果你是学生,找找你在各大公司的师兄师姐内推;如果你已经工作了,你可以找前同事或者通过一些社交活动认识的技术同行内推。


大部分情况下,如果在目标公司你完全没有认识的人,你也可以找机会来认识几个。比如你可以通过微博、知乎、Twitter、GitHub 来结交新的朋友。然后双方聊天如果愉快的话,我相信内推这种举手之劳的事情对方不会拒绝的。


如果你都工作 5 年以上,还是没有建立足够好的社交圈子帮助你内推,那可能你需要做很多的社交活动交一些朋友。


2.其它常见的渠道

内推之外,其它的公开招聘渠道通常都要差一些。现在也有一些专门针对互联网行业的招聘网站,例如拉勾、100offer 这类,它们也是不错的渠道,可以找到相关的招聘信息。


但因为这类公开渠道简历投放数量巨大,通常 HR 那边就会比较严格地筛选简历,拿我们公司来说,通常在这些渠道看 20 份简历,才会有 1 份愿意约面的简历。而且 HR 会只挑比较好的学校或者公司的候选人,也不排除还有例如笔试这种更多的面试流程。但是面试经验都是慢慢积累的,建议你也可以尝试这些渠道。


3.面试流程


1.流程简述


就我所知,大部分的 iOS 公司的面试流程都大同小异。我们先简述一下大体的流程,然后再详细讨论。


在面试的刚开始,面试官通常会要求你做一个简短的自我介绍。然后面试官可能会和你聊聊你过去的实习项目或者工作内容。接着面试官可能会问你一些具体的技术问题,有经验的面试官会尽量找一些和你过去工作相关的技术问题。最后,有些公司会选择让你当场写写代码,Facebook 会让你在白板上写代码,国内的更多是让你在 A4 纸上写。有一些公司也会问一些系统设计方面的问题,考查你的整体架构能力。在面试快要结束时,通常面试官还会问你有没有别的什么问题。


以上这些流程,不同公司可能会跳过某些环节。比如有一些公司就不会考察当场在白板或 A4 纸上写代码。有些公司可能跳过问简历的环节直接写代码,特别是校园招聘的时候,因为应届生通常项目经验较少。面试流程图如下所示:



2.自我介绍


自我介绍通常是面试中最简单、最好准备的环节。


一个好的自我介绍应该结合公司的招聘职位来做定制。比如公司有硬件的背景,就应该介绍一下在硬件上的经验或者兴趣;公司如果注重算法能力,则介绍自己的算法练习;公司如果注重团队合作,那么你介绍一下自己的社会活动都是可以的。


一个好的自我介绍应该提前写下来,并且背熟。因为候选人通常的紧张感都是来自于面试刚开始的几分钟,如果你刚开始的几分钟讲的结结巴巴,那么这种负面情绪会加剧你面试时的紧张感,从而影响你正常发挥。如果你提前把自我介绍准备得特别流利,那么开始几分钟的紧张感过去之后,你很可能就会很快进入状态,而忘记紧张这个事情了。


即使做到了以上这些仍然是不够的,候选者常见的问题还包括:


  • 太简短
  • 没有重点
  • 太拖沓
  • 不熟练


我们在展开讨论上面这些问题之前,我们可以站在面试官的立场考虑一下:如果你是面试官,你为什么要让候选人做自我介绍?你希望在自我介绍环节考察哪些信息?


在我看来,自我介绍环节相当重要,因为:


  • 首先它考察了候选人的表达能力。大部分的程序员表达能力可能都一般,但是如果连自我介绍都说不清楚,通常就说明表达沟通能力稍微有点问题了。面试官通过这个环节可以初步考察到候选人的表达能力。
  • 它同样也考察了候选人对于面试的重视程度。一般情况下,表达再差的程序员,也可以通过事先拟稿的方式,把自我介绍内容背下来。如果一个人自我介绍太差,说明他不但表达差,而且不重视这次面试。
  • 最后,自我介绍对之后的面试环节起到了支撑作用。因为自我介绍中通常会涉及自己的项目经历,自己擅长的技术等。这些都很可能吸引面试官追问下去。好的候选人会通过自我介绍 “引导” 面试官问到自己擅长的领域知识。如果面试官最后对问题答案很满意,通过面试的几率就会大大增加。


所以我如果是面试官,我希望能得到一个清晰流畅的自我介绍。下面我们来看看候选人在面试中的常见问题。


1)太简短


一个好的自我介绍大概是 3~5 分钟。过短的自我介绍没法让面试官了解你的大致情况,也不足以看出来你的基本表达能力。


如果你发现自己没法说出足够时间的自我介绍。可以考虑在介绍中加入:自己的简单的求学经历,项目经历,项目中有亮点的地方,参与或研究过的一些开源项目,写过的博客,其它兴趣爱好,自己对新工作的期望和目标公司的理解。


我相信每个人经过准备,都可以做到一个 5 分钟长度的自我介绍。


2)没有重点


突破了时间的问题,接下来就需要掌握介绍的重点。通常一个技术面试,技术相关的介绍才是重点。所以求学经历,兴趣爱好之类的内容可以简单提到即可。


对于一个工作过的开发者,你过去做的项目哪个最有挑战,最能展示出你的水平其实自己应该是最清楚的。所以大家可以花时间把这个内容稍微强调一下。


当然你也没必要介绍得太细致,面试官如果感兴趣,自然会在之后的面试过程中和你讨论。


3)太拖沓


有些工作了几年的人,做过的项目差不多有个 3~5 个,面试的时候就忍不住一个一个说。单不说这么多项目在自我介绍环节不够介绍。就是之后的详细讨论环节,面试官也不可能讨论完你的所有项目经历。


所以千万不要做这种 “罗列经历” 的事情,你要做的就是挑一个或者最多两个项目,介绍一下项目大致的背景和你解决的主要问题即可。至于具体的解决过程,可以不必介绍。


4)不熟练


即便你的内容完全合适,时间长度完全合理,你也需要保证一个流利的陈述过程。适当在面试前多排练几次,所有人都可以做到一个流利的自我介绍。


还有一点非常神奇,就是一个人在做一件事情的时候,通常都是开始的前以及刚开始几分钟特别紧张。比如考试,演讲或者面试,通常这几分钟之后,人们进入 “状态” 了,就会忘记紧张了。


将自己的自我介绍背下来,可以保证一个流利顺畅的面试开局,这可以极大舒缓候选人的紧张情绪。相反,一开始自我介绍就结结巴巴,会加剧候选人的紧张情绪,而技术面试如果不能冷静的话,是很难在写代码环节保证逻辑清晰正确的。


所以,请大家务必把这个小环节重视起来,做出一个完美的开局。


3.项目介绍


自我介绍之后,就轮到讨论更加具体的内容环节了,通常面试官都会根据自我介绍或者你的简历,选一个他感兴趣的项目来详细讨论。


这个时候,大家务必需要提前整理出自己参与的项目的具体挑战,以及自己做得比较好的地方。切忌不要说:“这个项目很简单,没什么挑战,那个项目也很简单,没什么好说的”。再简单的事情,都可以做到极致的,就看你有没有一个追求完美的心。


比如你的项目可能在你看来就是摆放几个 iOS 控件。但是,这些控件各自有什么使用上的技巧,有什么优化技巧?其实如果专心研究,还是有很多可以学习的。拿 UITableView 来说,一个人如果能够做到把它的滑动流程度优化好,是非常不容易的。这里面涉及网络资源的异步加载、图片资源的缓存、后台线程的渲染、CALayer 层的优化等等。


这其实也要求我们做工作要精益求精,不求甚解。所以一场成功的面试最最本质上,看得还是自己平时的积累。如果自己平时只是糊弄工作,那么面试时就很容易被看穿。


在这一点上,我奉劝大家在自己的简历上一定要老实。不要在建简历上弄虚作假,把自己没有做过的项目写在里面。


顺便我在这里也教一下大家如何面试别人。如果你是面试官,考察简历的真假最简单的方法就是问细节。一个项目的细节如果问得很深入,候选人有没有做过很容易可以看出来。


举个例子,如果候选人说他在某公司就职期间做了某某项目。你就可以问他:


  • 这个工作具体的产品需求是什么样的?
  • 大概做了多长时间?
  • 整体的软件架构是什么样的?
  • 涉及哪些人合作?几个开发和测试?
  • 项目的时间排期是怎么定的?
  • 你主要负责的部分和合作的人?
  • 项目进行中有没有遇到什么问题?
  • 这个项目最后最大的收获是什么?遗憾是什么?
  • 项目最困难的一个需求是什么?具体什么实现的?


面试官如果做过类似项目,还可以问问通常这个项目常见的坑,看看候选人是什么解决的。


4.写代码


编程能力,说到底还是一个实践的能力,所以说大部分公司都会考察当场写代码。我面试过上百人,见到过很多候选人在自我介绍和项目讨论时都滔滔不绝,侃侃而谈,感觉非常好。但是一到写代码环节就怂了,要么写出来各种逻辑问题和细节问题没处理好,要么就是根本写不出来。


由于人最终招进来就是干活写代码的,所以如果候选人当场写代码表现很差的话,基本上面试就挂掉了。


程序员这个行业,说到底就是一个翻译的工作,把产品经理的产品文档和设计师的 UI 设计,翻译成计算机能够理解的形式,这个形式通常就是一行一行的源码。


当面试官考察你写代码的时候,他其实在考察:


  • 你对语言的熟悉程度。如果候选人连常见的变量定义和系统函数都不熟悉,说明他肯定经验还是非常欠缺。
  • 你对逻辑的处理能力。产品经理关注的是用户场景和核心需求,而程序员关注的是逻辑边界和异常情况。程序的 bug 往往就是边界和特殊情况没有处理好。虽然说写出没有 bug 的程序几乎不可能,但是逻辑清晰的程序员能够把思路理清楚,减少 bug 发生的概率。
  • 设计和架构能力。好的代码需要保证易于维护和修改。这里面涉及很多知识,从简单的 “单一职责” 原则,到复杂的 “好的组合优于继承” 原则,其中设计模式相关的知识最多。写代码的时候多少还是能够看出这方面的能力。另外有些公司,例如 Facebook,会有专门的系统设计(System Design)面试环节,专注于考察设计能力。


5.系统设计


有一些公司喜欢考查一些系统设计的问题,简单来说,就是让你解决一个具体的业务需求,看看你是否能够将业务逻辑梳理清楚,并且拆解成各个模块,设计好模块间的关系。举几个例子,面试官可能让你:


  • 设计一个类似微博的信息流应用。
  • 设计一个本地数据缓存架构。
  • 设计一个埋点分析系统。
  • 设计一个直播答题系统。
  • 设计一个多端的数据同步系统。
  • 设计一个动态补丁的方案。


这些系统的设计都非常考查一个人知识的全面性。通常情况下,如果一个人只知道 iOS 开发的知识,是很难做出相关的设计的。为了能够解决这些系统设计题,我们首先需要有足够的知识面,适度了解一下 Android 端、Web 端以及服务器端的各种技术方案背后的原理。你可以不写别的平台的代码,但是一定要理解它们在技术上能做到什么,不能做到什么。你也不必过于担心,面试官在考查的时候,还是会重点考查 iOS 相关的部分。


我们将在下一小节,展开讨论如何准备和回答系统设计题。


6.提问


提问环节通常在面试结束前,取决于前面的部分是否按时结束,有些时候前面的环节占用了太多时间,也可能没有提问环节了。在后面的章节,我们会展开讨论一下如何提问。



收起阅读 »

腾讯抖音iOS岗位三面面经

1.进程和线程的区别2.死锁的原因3.介绍虚拟内存4.常见排序算法,排序算法稳定的意思,快排的复杂度什么时候退化,基本有序用什么5.TCP可靠性6.http+https算法Z字遍历二叉树,归并排序后面说因为我不会java和安卓,会帮忙转推到iOS的组(面试的这...
继续阅读 »

1.进程和线程的区别


2.死锁的原因


3.介绍虚拟内存


4.常见排序算法,排序算法稳定的意思,快排的复杂度什么时候退化,基本有序用什么


5.TCP可靠性


6.http+https


算法


Z字遍历二叉树,归并排序


后面说因为我不会java和安卓,会帮忙转推到iOS的组(面试的这个组是java客户端)


腾讯PCG iOS一面(1h)


1.聊项目,聊了很久,一开始没有意会面试官想知道什么,最后说是想知道我这么做比起从客户端自己去实现的区别(这个项目是小米实习时候的项目,做的浏览器内核,页面翻译功能,


基本每一个客户端应用都会有一个类似于浏览器内核的东西,对页面进行渲染,呈现,也可以叫渲染引擎,学前端的肯定知道这个东西,他主要是解释html,css,js的。


我做的这个页面翻译功能可以不经过内核直接由客户端工程师用安卓客户端实现整套逻辑,所以这么问我了)


2.实现string类,实现构造,析构,里面加一个kmp


3.介绍智能指针,智能指针保存引用计数的变量存在哪里,引用计数是否线程安全


4.算法:两个只有0和1的数字序列,只能0  1互换,每次当前位互换都会使后面的也换掉(比如,011000,换第二位,成了000111),计算从一个变到另一个需要几步操作。


5.https,验证公钥有效的方法,为什么非对称对称并用


腾讯PCG iOS二面 (40min)


1.算法:


合并排序链表


2.static关键字的作用


3.const关键字的作用


4.成员初始化列表的作用


5.指针和引用的区别


6.又是很久的项目,怎么去学习浏览器内核(chromium内核的代码量有几千万行,而且写的很难懂,用了大量的设计模式,作为一个菜鸡真的很痛苦)


,怎么去调试项目中遇到的问题(这里主要是一个ipc接口没用对),你觉得人家google的是怎么去调的,你为什么和人家做法不一样?


腾讯PCG iOS三面(2h)


1.还是聊了很久项目(已经麻了,做过的东西一定要能说出口)


2.浏览器呈现一个页面经过了哪几步(DOM树,layoutobject树,browser进程绘制)


3.C++多态的实现


4.DNS解析,递归与迭代的区别


5.chromium用的渲染引擎是什么,这个渲染引擎对应的js解释引擎是什么(blink和v8,前几个问题表现有些差,这会在问一些1+1的问题了,哭)


6.平时怎么学习技术的,看过哪些书,有过哪些输出(我把实习时写的项目wiki给截了个图)


然后反问,打开牛客让我写了个代码,他不知道去哪了,我自己在这写,写了一个多钟头,你以为这是道很难的题吗?no,是我那会确实很菜,哈哈


题目是,给一个字符串插入最少的字符,让这个字符串变成回文


腾讯hr面 (40min)


1.有哪些缺点


2.投了哪些,为什么不投阿里头条(实习忙的我面你们都要面不过来了)


3.如何选择offer


4.家是哪的,为什么愿意去深圳


每一个问题都不是简单的答完就完事了,他会跟着问很多


然后反问时我问问题给我说了20分钟


抖音 iOS一面 (1h20min)


上来闲聊了一会


1.算法:


字符串大数相加


写完问我有没有需要优化的地方(内存可以优化一下)


2.string类赋值运算符是深拷贝还是浅拷贝


3.算法:


根据前序和中序输出二叉树的后续遍历


4.C++ deque底层,deque有没有重载[]


5.为什么要内存对齐,内存对齐的规则


6.算法:


上台阶,加了个条件,这次上两级,下次就只能上一级


7.反问+闲聊


抖音 iOS二面 (1h)


十分钟不到的项目


1.进程和线程的区别和联系


2.线程共享哪些内存空间


3.进程内存模型


4.进程间通信方式


5. 虚拟内存,为什么要有虚拟内存,虚拟内存如何映射到物理内存


后面还挖了一些操作系统的问题,记不太清了


5.TCP为什么四次挥手


6.https客户端验证公钥的方法


7.描述并写一下LRU


8.说一下怎么学chromium的,怎么上手项目的


9.C++内存分配,写了一段代码,看里面申请了哪部分内存,申请了多少,代码有什么问题


10.代码里面的内存泄漏怎么解决,智能指针的引用计数怎么实现,那些成员函数会影响到引用计数


11.代码里面有无线程安全问题,线程安全问题的是否会导致程序崩溃,为什么


12.C++虚函数的实现原理,纯虚函数


13.C++引用和指针的区别,引用能否为空


抖音 iOS三面 (1h)


1.lambda表达式,它应用表达式外变量的方式和区别


2.decltype的作用,他和auto有什么不同


3.C++的所有智能指针介绍一下


4.C++thread里面的锁,条件变量,讲一下怎么用他们实现生产者消费者模型


5.C++20有什么新东西(我就知道支持了协程,然后他说我就想问你协程,然后我说,其实我具体不了解,丢人了)


6.右值引用是什么,移动构造函数有什么好处


7.操作系统微内核宏内核(懵)


8.进程间通信的共享内存,如何保证安全性(信号量),结合epoll讲讲共享内存


9.TCP协议切片(懵)


10.TCP协议的流量控制机制,滑窗为0时,怎么办


11.算法


合并K个排序链表


12.合并K个排序数组,讲思路,我说归并,他说,传输参数是数组,不是vector,你如何判断数组的大小



收起阅读 »