注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

环信FAQ

环信FAQ

集成常见问题及答案
RTE开发者社区

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

iOS:NSNotification.Name从OC到Swift的写法演进

iOS
前言 在闲来无事的时候,我会抽时间看看Foundation、UIKit等相关库的Swift代码说明与注释。说实话,有的时候看起来真的很乏味,也不容易理解。 不过有的时候也会觉得Apple这么设计API真是书写的简单漂亮,一脸佩服,今天给大家分享的就是从NSNo...
继续阅读 »

前言


在闲来无事的时候,我会抽时间看看Foundation、UIKit等相关库的Swift代码说明与注释。说实话,有的时候看起来真的很乏味,也不容易理解。


不过有的时候也会觉得Apple这么设计API真是书写的简单漂亮,一脸佩服,今天给大家分享的就是从NSNotification.Name学习一种代码编写方式,并且已经在我自己的项目中进行类似这种写法的落地实战分享。


OC时代的通知名写法


NSNotification想必大家都使用过,在iOS中处理跨非相邻页面、一对多的数据处理的时候,我们通常就是通过发通知传参,告之相关页面处理逻辑。


一般情况下,如果可以避免NSNotification的时候,我都会尽量避免使用,当然,既然系统给你了这个方法,那么在合适的场景使用也会妙手生花。


当然,对于NSNotification的通知名的管理,其实是一个看似简单,实际上可以做得非常优雅的事情。


特别是从OC过渡到Swift的过程,这段简单的代码,其实进行了很大的演变,我们不妨来看看。


下面这个是我早期写OC代码的时候,发送一个通知:

[[NSNotificationCenter defaultCenter] postNotificationName:@"CancelActivateSuccessNotification" object:nil];

大家注意看,通知名,我就是非常简单的使用硬编码字符串@"CancelActivateSuccessNotification" 来表示,硬编码的缺点就不用我多说了,编译器是不会给提示的,写错了,甚至连通知事件都没法收到,总之,这种写法是不好的。


于是,看看系统代码以及AFNetworking,我们会看见这样一种写法:


系统通知名:

UIKIT_EXTERN NSNotificationName const UIApplicationDidFinishLaunchingNotification;

AFNetworking的通知名,也是学习系统通知名的写法进行的扩展:


.h文件




.m文件 



看起来并不是太高明?也许确实如此,只不过通过.h与.m的分隔,将一个硬编码字符串变成了一个全局可以引用、IDE可以快速键入的方式,但是它至少让调用变得简单与安全,这样就足够了。


于是乎,OC时代通知名的写法,我们基本上都会用以上这种方式进行编写:


.h

UIKIT_EXTERN NSString *const CancelActivateSuccessNotification;

.m

NSString *const CancelActivateSuccessNotification = @"CancelActivateSuccessNotification";

Swift时代还是这么写吗?


Swift时代的通知名写法


其实Swift的早期,基本上还是沿用着OC的这一套写法来写通知名,不过在Swift4.2之后就迎来比较大的改变,让我们来看看调用的API与源码:

open func post(name aName: NSNotification.Name, object anObject: Any?)

open func post(name aName: NSNotification.Name, object anObject: Any?, userInfo aUserInfo: [AnyHashable : Any]? = nil)

发通知的时候,通知名被一个NSNotification.Name类型代替了,我们进去追着NSNotification.Name看:




大家一定要记住这种编码的书写方式,先送上结论:

  • 可以在一个类型里面再定义一个类型,大家可以自己尝试。
  • 什么时候嵌套?为何要这么写?当嵌套的类型与外层定义的类型有着较强关联的时候可以这么写。

说完了这些,我们可以看到在Swift中,发通知,通知名不再是一个字符串了,而是一个NSNotification.Name类型了。


那么在开发过程中,我们如何使用呢?我们不妨还是从系统提供的API开始找:




因为Swift可以随处编写一个类的分类,于是在一个类的分类中定义好该类的通知名这种书写方式随处可见,这样的好处就是通知名与类紧紧联系在一起,一来便于查找,二来便于绑定业务类型。

NotificationCenter.default.post(name: UIApplication.didFinishLaunchingNotification, object: nil)

上面这个通知一发出,通过通知名我就知道是涉及UIApplication的操作行为。


说完了系统提供的API,我们再来看看一些知名第三方库是怎么定义吧,这里以Alamofire为例:




Alamofire保持了和系统API一样的风格来定义通知名。


我们再来看看Kingfisher




Kingfisher是在NSNotification.Name分类中扩展了通知名。


顺带说一下,我自己管理与编写通知名是这样的:

extension Notification.Name {
    enum LoginService {
        /// 退出
        static let logoutNotification = NSNotification.Name("logoutNotification")
    }
}
NotificationCenter.default.post(name: .LoginService.logoutNotification, object: nil)

通过在NSNotification.Name分类中进行二级业务扩展,细化通知名。


至于大家更喜欢哪一种写法,那就是仁者见仁智者见智的事情了。


总结


本篇文章从NotificationCenter发通知的通知名开始,对OC到Swift的写法演进进行梳理与说明,举了系统API和著名第三方库的例子,给大家讲解如何写好并管理好NSNotification.Name


吐槽


掘金的这个编辑器,我直接从Xcode里面复制粘贴代码的体验真的很不友好,导致我比较长的代码都是截图,只有较少的代码使用的代码块。


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


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


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


作者:season_zhu
链接:https://juejin.cn/post/7249624466149376058
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Swift 中async/await 简单使用

iOS
在 Swift 5.5 中,终于加入了语言级别的异步处理 async/await,这应该会让用回调闭包写异步调用方法时代彻底结束了! 这篇文章就简单总结一下这个功能使用吧。 异步函数 所谓异步,是相对于同步而言,这是一种执行任务的方式,同步的执行任务,任务需...
继续阅读 »

在 Swift 5.5 中,终于加入了语言级别的异步处理 async/await,这应该会让用回调闭包写异步调用方法时代彻底结束了!



这篇文章就简单总结一下这个功能使用吧。


异步函数


所谓异步,是相对于同步而言,这是一种执行任务的方式,同步的执行任务,任务需要一个一个的顺序执行,前边的好了,后边的才能运行。而异步就不是这样,它不需要等待当前任务执行完成,其他任务就可以执行。


在 Swift 5.5 中,添加了 async 关键字,标记这个函数是一个异步函数。

func getSomeInfo() async -> String { ... }
/// 可以抛出错误的异步函数
func getSomeInfoWithError() async throws -> String { ... }


这里需要注意的是,如果我们想调用异步函数,就必须在其他异步函数或者闭包里面使用 await关键字。

func runAsyncFunc() async {
let info = await getSomeInfo()
...
}

func runAsyncErrorFunc() async throws {
let info = try await getSomeInfoWithError()
...
}

实际使用异步函数的时候,我们是无法在同步函数里使用的,这时Swift会报错。要使用的话,就需要我们要提供了一个异步执行的环境 Task

func someFunc() {
Task {
runAsyncFunc()
}
}


异步序列


如果一个序列中的每个信息都是通过异步获取的,那么就可以使用异步序列的方式遍历获取。前提是序列是遵守AsyncSequence协议,只要在for in 中添加 await关键字。

let asyncItems = [asyncItem1, asyncItem2, asyncItem3]
for await item in asyncItems { ... }

多个异步同时运行


这个可以使用叫做异步绑定的方式,就是在每个存储异步返回信息的变量前边添加async

async let a = getSomeInfo()
async let b = getSomeInfo()
async let c = getSomeInfo()
let d = await [a, b, c]
...

这时运行的情况就是 a b c 是同时执行的,也就是所说的并行执行异步任务,即并发。


结构化并发


上边在提到在同步函数中使用异步函数,我们需要添加一个Task,来提供异步运行的环境。 每个 Task 都是一个单独任务,里面执行一些操作,这操作可以是同步也可以是异步。多个任务执行时,可以把它们添加到一个任务组TaskGroup中,那么这些任务就有了相同的父级任务,而这些任务Task又可以添加子任务,这样下来,任务之间就有了明确的层级关系,这也就是所谓的结构化并发


任务和任务组


任务组可以更为细节的处理结构化并发,使用方式如下,就在任务组中添加单个任务即可。

func someTasksFunc() {
Task {
await withTaskGroup(of: String.self) { group in
group.addTask {
let a = await getSomeInfo()
...
}
group.addTask {
let b = await getSomeInfo()
...
}
}
}
}

从运行的方式来说,这种使用任务组的情况和异步绑定的效果一样,简单的异步任务,完全可以使用异步绑定的方式。而任务和任务组是为更为复杂的并发情况提供支持,比如任务的优先级,执行和取消等。


如果异步函数是可抛出错误的,使用withThrowingTaskGroup就行。


解决数据竞争的Actor


在并发过程中,对于同一属性数据的读取或者写入,有时会有奇怪的结果,这些由于在不同的线程,同时进行了操作。消除这种问题的方式,就是使用 Swift 提供的 Actor类型。 一个和类差不多的类型,但是对自身可变内容进行了隔离。

actor SomeInfo {
var info: String
}

外部在访问其info属性时,actor 做了隔离,每次只能一个线程访问,直接访问就会报错。而且外部不能进行修改,只有内部才能修改。


外部访问方式就是需要异步执行,在异步环境中,添await

let content = SomeInfo(info: "abc")
let info = await content.info)
...

总结


以上就是Swift 5.5 async/await的简单使用了。了解了这些,就可以日常开发中替代闭包回调,告别回调地狱和处理不完的 completionHandler了。😎
目前官方已经在已有闭包回调处理异步的地方,都增加async异步版本,自行查看文档了解吧。。


另外附上一些很有帮助的文章地址,这些地方都有更为详尽的说明,参考学习起来!


Swift 5.5 有哪些新功能?


http://www.hackingwithswift.com/articles/23…


Swift 并发初步


onevcat.com/2021/07/swi…


并发


swiftgg.gitbook.io/swift/swift…


作者:WeiYou
链接:https://juejin.cn/post/7250375035634090043
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

什么?同一张图在Flutter和iOS原生渲染出来效果不一样?

iOS
最近遇到了一个很奇怪的现象,同样的一张图在iOS原生能正常显示,但是在Flutter就显得很暗 具体往下看 现象 可以很清晰的看到iOS对于原图的还原度要比Flutter好太多 思考 看到这种场景顿时懵逼,明明是一张图,不至于Flutter这么拉胯吧,但是这...
继续阅读 »

最近遇到了一个很奇怪的现象,同样的一张图在iOS原生能正常显示,但是在Flutter就显得很暗
具体往下看


现象


截屏2023-08-18 16.49.37.png


可以很清晰的看到iOS对于原图的还原度要比Flutter好太多


思考


看到这种场景顿时懵逼,明明是一张图,不至于Flutter这么拉胯吧,但是这个现象是做了这么久的Flutter第一次遇见(或者是之前没太仔细看小的icon,这次大图渲染更加直观。。),所以觉得这个事情或许没那么复杂。于是开始怀疑是Impeller的问题,但是我尝试了@恋猫de小郭的文章里的
在iOS工程中修改info.plist


<key>FLTEnableImpeller</key>
<true />

尝试关闭Impeller,但是并没有什么效果,所以初步怀疑Impeller的思路或许不太对,所以就开始看之前的在Flutter显示正常的图,最后发现了一个微妙之处,在图片的简介里有一个我不懂的地方不一样,就是颜色描述文件


WechatIMG394.jpg


这里询问chatgpt颜色描述文件:sRGB IEC61966-2.1Display P3有什么区别,得到的回答是



sRGB IEC61966-2.1和Display P3是两种不同的颜色描述文件,它们主要用于不同的显示设备和应用场景。它们的区别主要在于颜色范围和应用领域。



  1. sRGB IEC61966-2.1:sRGB是一种标准的RGB颜色空间,广泛用于互联网、数字摄影和普通的显示设备上。sRGB IEC61966-2.1的颜色范围相对较窄,适合一般图像显示,它的色域较小,能够在大多数常见显示设备上提供相对一致的颜色显示。sRGB通常是图像文件的默认颜色描述文件。

  2. Display P3:Display P3是一种更宽广的颜色空间,偏向于更鲜明和逼真的色彩展示。它主要用于高级的显示设备,如苹果公司的Retina显示器。相对于sRGB,Display P3能够呈现更宽广的颜色范围,对于鲜艳和饱和度较高的颜色效果更为明显。 总结来说,sRGB IEC61966-2.1适合一般的互联网应用和常见显示设备,而Display P3则适用于高级显示设备,如高分辨率显示器和专业图形处理工作。在选择使用哪一种颜色描述文件时,需要考虑图像的应用场景和目标设备的能力来做出合适的选择。



呃。。好像看不出来咋回事,后来看到一篇文章说到Flutter对于Display P3的支持问题,具体意思就是原因就是 Flutter 直接把 Display P3 色域当做 sRGB 色域的图像处理了,而没有做色域转换,这一下就真相大白了~。


解决办法


文章中提到可以让原生来处理图片


CGImageSourceRef src = CGImageSourceCreateWithData((__bridge CFDataRef) imageData, NULL);
NSUInteger frameCount = CGImageSourceGetCount(src);
if (frameCount > 0) {
NSDictionary *options = @{(__bridge NSString *)kCGImageSourceShouldCache : @YES,
(__bridge NSString *)kCGImageSourceShouldCacheImmediately : @NO
};
NSDictionary *props = (NSDictionary *) CFBridgingRelease(CGImageSourceCopyPropertiesAtIndex(src, (size_t) 0, (__bridge CFDictionaryRef)options));
NSString *profileName = [props objectForKey:(NSString *) kCGImagePropertyProfileName];
if ([profileName isEqualToString:@"Display P3"]) {

NSMutableData *data = [NSMutableData data];
CGImageDestinationRef destRef = CGImageDestinationCreateWithData((__bridge CFMutableDataRef)data, kUTTypePNG, 1, NULL);

NSMutableDictionary *properties = [NSMutableDictionary dictionary];
properties[(__bridge NSString *)kCGImageDestinationLossyCompressionQuality] = @(1);
properties[(__bridge NSString *)kCGImageDestinationEmbedThumbnail] = @(0);

properties[(__bridge NSString *)kCGImagePropertyNamedColorSpace] = (__bridge id _Nullable)(kCGColorSpaceSRGB);
properties[(__bridge NSString *)kCGImageDestinationOptimizeColorForSharing] = @(YES);

CGImageDestinationAddImageFromSource(destRef, src, 0, (__bridge CFDictionaryRef)properties);

CGImageDestinationFinalize(destRef);
CFRelease(destRef);
return data;

}
}

return imageData;

这里偷懒了,因为找UI小姐姐让她切图的时候调整一下就可以了~,最后的解决方案是UI根据设计稿导出sRGB IEC61966-2.1类型的图片,同时这个图片的色值是向Display P3

作者:Jerry815
来源:juejin.cn/post/7268539503907307520
code>靠拢的,至此问题解决。

收起阅读 »

Stack Overflow 2023 开发者调查报告

iOS
众所周知,Stack Overflow 是全球最大的程序员问答社区,本篇带来它的 2023 开发者调查报告解析! 闲话少说,冲冲冲~ 2023 一共收集了 9 万份开发者的报告,他们反馈了自己正在使用的编程工具以及编程语言。完整的报告在:survey.stac...
继续阅读 »

众所周知,Stack Overflow 是全球最大的程序员问答社区,本篇带来它的 2023 开发者调查报告解析!


闲话少说,冲冲冲~


2023 一共收集了 9 万份开发者的报告,他们反馈了自己正在使用的编程工具以及编程语言。完整的报告在:survey.stackoverflow.co/2023




另外,今年与以往不一样的是对人工智能领域做了更加深入的调查,调查目的是想知道如今以 ChatGPT 为代表的 AIGC工具到底是否改变了开发人员的工作方式、还是只是一场炒作??详细报告在:hype-or-not-developers-have-something-to-say-about-ai 以及给出了一些见解 《developer-sentiment-ai-ml》(挖坑有空翻译~)


悄然变化


一些老程序员都习惯在 Stack Overflow 进行问答,从今年统计看,各个国家的回答率占比有所变化:美国仍然排第一、德国(增长30%)超越印度(下降50%)位列第二。


本次调查中,来自印度的开发人员平均年龄更加年轻,89% 低于 34 岁;而整体样本中,低于 34 岁的占比是 62%。


从整体角度来看,开发者年龄分布略有增长,有 37% 的程序员年龄大于 35 岁,而去年只有 31%;


今年的十大编程语言中,有三门语言的地位提高了,它们分别是:Python、Bash/Shell、C


《comparing-tag-trends-with-our-most-loved-programming-languages/》 了解到:过去三年,大家对 Python 的关注又在不断提升;




尤其是对于非职业的编程人员来说,Python 是一门相当不错的入手编程语言:




另外,C 语言重回台面,这个就很有意思了:尽管 C 是一门很古老的编程语言(始于1970),但在之前它从未进入开发者调查报告受欢迎语言的前十名,


C 语言作为一门基础语言,是嵌入式编程语言所需,从这个角度看,是不是意味着:设备的嵌入式编程开发近年也在急速发展?物联网正在发力。学习 C :Codecademy




薪资情况,调查显示 2023 年程序员整体收入将比去年增长约 10%;


其中最受欢迎的三种编程语言:JavaScript、HTML/CSS 和 Python,薪资中位数却出现了下降;


而一些小众语言,比如 APL 和 Crystal ,薪资增幅较大。由此推断,在 2023 年一些小众语言的程序员薪资上涨会更多!


期待使用


今年,在调查报告中还新增了一个概念区分,即“期望使用的编程语言”,在之前,我们只关注“受欢迎的语言”,这次还综合统计了大家的预期。




如图所示,Rust 是所有语言中最受欢迎且被期望继续使用的语言!有 80% 的 Rust 使用者选择将来会继续使用~(Rust 你用上了吗??)


而比如 JavaScript 使用者大约只有 60% 选择会继续使用它~~


薪资水平


另外,技术受欢迎,但是也肯定同样要考虑工资水平。其中,Rust、Elixir 和 Zig 语言的开发者薪资中位数比其它语言普遍高出 20%,年薪约 50+ 万人民币~ 薪资完整情况




(可惜没统计咱们的。。。)


欲知更多报告,观点在:《dev-survey-results-2023》


作者:掘金安东尼
链接:https://juejin.cn/post/7251199685128814649
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

🐻优化GIF的内存加载

iOS
一、内存OOM问题 使用 UIImage.animatedImage(with:duration:) 方法:UIImage 类提供了一个便利的方法来加载并处理 GIF 图像,该方法可以将 GIF 图像转换为 UIImage 的动画表示。这种方法可以有效地管理内...
继续阅读 »

一、内存OOM问题


使用 UIImage.animatedImage(with:duration:) 方法:UIImage 类提供了一个便利的方法来加载并处理 GIF 图像,该方法可以将 GIF 图像转换为 UIImage 的动画表示。这种方法可以有效地管理内存,并且不需要手动处理每一帧的图像。但会存在内存问题,UIImage(contentsOfFile:) 虽然不会立即放入内存中,但显示时还是会加载到内存中。

if let gifURL = Bundle.main.url(forResource: "example", withExtension: "gif") {
let gifData = try? Data(contentsOf: gifURL)
let gifImage = UIImage.animatedImage(with: gifData)
imageView.image = gifImage
}

大量的GIF会导致OOM问题,一旦使用超过系统的阈值,就会崩溃。


二、使用FLAnimatedImageView可以有效的解决GIF内存暴涨的问题

  • FLAnimatedImageView 使用渐进式解码:FLAnimatedImageView 使用渐进式解码来加载 GIF 图片。渐进式解码允许在图片尚未完全加载时就开始显示并逐步增加清晰度。这意味着 FLAnimatedImageView 可以在加载 GIF 图片的同时,逐帧渲染和显示动画,而不需要等待整个 GIF 图片加载完成。这对于大型 GIF 图片特别有利,因为可以显著降低首次加载的延迟,并提高用户体验。
  • 内存优化:FLAnimatedImageView 在加载和显示大型 GIF 图片时进行了内存优化。它只会将当前帧所需的数据加载到内存中,并在显示下一帧时释放之前的帧数据,从而避免占用过多的内存。这有助于在加载大型 GIF 图片时降低内存使用,减少内存压力和 OOM 问题。

三、让FLAnimatedImageView支持网络GIF


FLAnimatedImageView 是用于显示 GIF 动画的 FLAnimatedImage 库中的特殊控件,它并不直接用于加载网络图片,但我们可以扩展方法为其增加加载网络图片的功能。

import FLAnimatedImage
import Kingfisher

extension FLAnimatedImageView {
func setGifImage(withURL url: URL) {
// 使用 Kingfisher 加载网络图片
self.kf.setImage(with: url, completionHandler: { result in
switch result {
case .success(let value):
// 成功加载图片,value.image 是 UIImage 类型
// 将加载的图片转换为 FLAnimatedImage 类型
let animatedImage = FLAnimatedImage(animatedGIFData: value.image.kf.gifRepresentation())
// 在 FLAnimatedImageView 中显示 GIF 动画
self.animatedImage = animatedImage
case .failure(let error):
// 加载图片失败,处理错误
print("Error loading image: (error)")
}
})
}
}

上述方法利用Kingfisher不仅添加了缓存,还能后直接显示来自网络的GIF图片。


四、测试效果


总体内存可以降低70%,CPU在迅速滑动时波动较大,大概为原来的1-2倍,但是停止滑动时降低为原来的50%左右。由于目前iPhone手机的CPU普遍较好,而内存较低;所以这种用CPU缓解内存压力的方法是可行的。


作者:熊大与iOS
链接:https://juejin.cn/post/7262151580898983994
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

移动端页面加载耗时监控方案

iOS
本文阐述了个人对移动端页面加载耗时监控的一些理解,主要从:节点划分及对应的实现方案,线上监控注意点,后续还能做的事 三个方面来和大家分享。 前言 移动端的页面加载速度,作为最为影响用户体验的因素之一,是我们做移动端性能优化的重点方向之一。 而优化的效果体现,...
继续阅读 »

本文阐述了个人对移动端页面加载耗时监控的一些理解,主要从:节点划分及对应的实现方案,线上监控注意点,后续还能做的事 三个方面来和大家分享。



前言


移动端的页面加载速度,作为最为影响用户体验的因素之一,是我们做移动端性能优化的重点方向之一。


而优化的效果体现,需要置信的指标进行衡量(常见方法论:寻找方向->确定指标->实践->量化收益),而本文想要分享的就是:如何真实、完整、方便的获得页面加载时间,并会向线上监控环节,有一定延伸。


本文的示例代码都是OC(因为Java和kotlin我也不会😅),但相关思路和方案也适用于Android(Android端已实现并上线)。


页面加载耗时


常见方案


页面加载时长是一直以来大家都在攻坚的方向,所以市面上也有非常非常多的度量方案,从节点划分角度看:


较为基础的:ViewController 的 init -> viewDidLoad -> viewDidAppear


更进一步的:ViewController 的 init -> viewDidLoad -> viewDidAppear -> user Interactable


主流方案:ViewController 的 init -> viewDidLoad -> viewDidAppear -> view render completed -> user Interactable


还有什么地方可以改进的吗?


对于这些成熟方案,我还有什么可以更进一步的吗?主要总结为以下几个方面吧:

  • 完整反映用户体感

我们做性能优化,归根结底,更是用户体验优化,在满足功能需要的同时,不影响用户的使用体验。
所以,我个人认为,大多数的性能指标,都要考虑到用户体验这个方向;页面启动速度这一块,更是如此;而传统的方案,能够完整的反应用户体感吗?
我觉得还是有一部分的缺失的:用户主动发起交互到ViewController这个阶段。这一部分有什么呢,不就是直接tap触发的action里vc就初始化了吗?
实际在一些较为复杂、大型的项目中,并不然,中间可能会有很多其他处理,例如:方法hook、路由调度、参数解析、containerVC的初始化、动态库加载等等。这一部分的耗时,实际上也是用户体感的一部分,而这一部分的耗时,如果不加监控的话,也会对整体耗时产生劣化。(这里可能会有小伙伴问了,这些东西,不应该由各自负责的同学,例如负责路由的同学,自行监控吗?这里我想阐述的一个观点时,时长类的监控,如果由几个时间段拼接,相比于endTime - startTime,难免会产生gap,即,加入endTime = 10,startTime = 0,那么中间分成两段,很有可能endTime2 = 10,startTime2 = 6;endTime1 = 4,startTime1 = 0,造成总时长不准。总而言之,还是希望得到一个能够完整反映用户体感的时长。)

  • 数据采集与业务解耦

这一点其实市面上的很多方案已经做得很好了。解耦,一方面是为了,提效:避免后续有新的页面需要监控时,需要进行新的开发;另一方面,也是避免业务迭代对于监控数据的影响:如果是手动侵入性埋点,很难保证后续新增的耗时任务对监控数据不产生影响。
而本文方案,不需要在业务代码中插入任何代码,大都是通过方法hook来实现数据采集的;而对范围、以及匹配关系等的控制,也都是通过配置来完成的。


具体实现


节点确定&数据采集方式



根据一个页面(ViewController)的加载过程中,开发主要进行的处理,以及可能对用户体感产生影响的因素,将页面加载过程划分为如上图所示的11个节点,具体解释及实现方案如下:


1. 用户行为触发页面跳转

由于页面的跳转一般是通过用户点击、滑动等行为触发的,因此这里监听用户触摸屏幕的时间点;但有效节点仅为VC在初始化前的最后一次点击/交互。


具体实现
hook UIWidow 的 sendEvent:方法,在swizzle方法内记录信息;为了性能考虑,目前仅记录一个uint64_t的时间戳,且仅内存写;
注意这里需要记录手指抬起的时间,即 touch.phase == UITouchPhaseEnded,因为一般action被调用的时机就是此时;
同时,为了适配各种行为触发的新页面出现,还增加了一个手动添加该节点的方法,使一些较复杂且不通用,业务特性较强的初始化场景,也能够有该节点数据,且不依赖hook;但注意该手动方法为侵入式数据采集方式。


2. ViewController的初始化

具体实现:hook UIViewController或你的VC基类 的 - (instancetype)init 的方法;


3. 本地UI初始化

不依赖于网络数据的UI开始初始化。


这个节点,我实际上并没有在本次实现,这里的一个理想态是:将这部分行为(即UI初始化的代码),通过协议的方式,约束到指定方法中;例如,架构层面约束一个setupSubviews的接口,回调给各业务VC,供其进行基础UI绘制(目前这种方式再一些更复杂的业务场景下实现并运行较好);有这个基础约束的前提下,才能准确的采集我理想中该节点的耗时。而我目前所负责的模块,并没有这种强约束,而又不能简单的去认为所有基础UI都是在viewDidLoad中去完成的。因此需要 对原有架构的一定修改 或 能够保证所有基础UI行为都在viewDidLoad中实现,才能够实现该节点数据的准确采集。
因此2 ~ 3和3 ~ 4间的耗时,被融合为了一段2 ~ 4的耗时。


4. 本地UI初始化完成

不依赖于网络数据的UI初始化完成。


具体实现:监听主线程的闲时状态,VC初始化 节点后的首个闲时状态表示 本地UI初始化完成;(闲时状态即runloop进入kCFRunLoopBeforeWaiting


5. 发起网络请求

调用网络SDK的时间点。


这里描述的就是上面的节点划分图的第二条线,因为两条线的节点间没有强制的线性关系,虽然图中当前节点是放在了VC初始化平行的位置,但实际上,有些实现会在VC初始化之前就发起网络请求,进行预加载,这种情况在实现的时候也是需要兼容的。


具体实现:hook 业务调用网络SDK发起请求方法的api;这里的网络库各家实现方案就可能有较大差异了,根据自身情况实现即可。


6. 网络SDK回调

网络SDK的回调触发的时间点。


具体实现:hook 网络SDK向业务层回调的api;差异性同5。


7. send request

8. receive response

真正 发出网络请求 和 收到response 的时间点,用于计算真正的网络层耗时。
这俩和5、6是不是重复了啊?并不然,因为,网络库在接收到发起网络请求的请求后,实际上在端阶段,还会进行很多处理,例如公参的处理、签名、验签、json2Model等,都会产生耗时;而真正离开了端,在网上逛荡那一段,更是几乎“完全不可控”的状态。所以,分开来统计:端部分 和 网络阶段,才能够为后续的优化提供数据基础,这也是数据监控的意义所在


具体实现
实际上系统网络api中就有对网络层详细性能数据的收集

- (void)URLSession:(NSURLSession *)session 
task:(NSURLSessionTask *)task
didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics;

根据官方文档中的描述

 

可以发现,我们实际上需要的时长就是从 fetchStartDateresponseEndDate 间的时间。
因此可以该delegate,获取这两个时间点。


9. 详细UI初始化

详细UI指,依赖于网络接口数据的UI,这部分UI渲染完成才是页面达到对用户可见的状态。


具体实现:这里我们认为从网络SDK触发回调时,即开始进行详细UI的渲染,因此该节点和节点6是同一个节点。


10. 详细UI渲染完成

页面对用户来说,真正达到可见状态的节点。


具体实现
对于一个常规的App页面来说,如何定义一个页面是否真正渲染完成了呢?


被有效的视图铺满


什么是有效视图呢?视频,图片,文字,按钮,cell,能向用户传递信息,或者产生交互的view;
铺满,并不是指完全铺满,而是这些有效视图填充到一定比例即可,因为按照正常的视觉设计和交互体验,都不会让整个屏幕的每一个像素点都充满信息或具备交互能力;而这个比例,则是根据业务的不同而不同的。
下面则是上述逻辑的实现思路:


确定有效视图的具体类
UITextView 
UITextField
UIButton
UILabel
UIImageView
UITableViewCell
UICollectionViewCell

主流方案中比较常见的,是前几种类,并不包括最后的两个cell;而这里为什么将cell也作为有效视图类呢?
首先,出于业务特征考虑,目前应用该套监控方案的页面,主要是以卡片列表样式呈现的;而且个人认为,市面上很多App的页面也都是列表形式来呈现内容的;当然,如果业务特征并不相符,例如全屏的视频播放页,就可以不这样处理。
其次,将cell作为有效视图,确实能够极大的降低每次计算覆盖率的耗时的。性能监控本身产生的性能消耗,是性能方向一直以来需要着重关注的点,毕竟你一个为了性能优化服务的工具,反而带来了不小的劣化,怎样也说不太过去啊😂~
我也测试了是否包含cell对计算耗时的影响:
下表中为,在一个层级较为复杂的业务页面,页面完全渲染完成之后,完成一次覆盖率达到阈值的扫描所需的时长。






















有效视图包含 cell不包含 cell
检测一次覆盖率耗时(ms)1~515~18
耗时减少15ms/次(83%)

而且,有效视图的类,建议支持在线配置,也可以是一些自定义类。


将cell作为有效视图,大家可能会产生一个新的顾虑:占位cell的情况,再具体点,就是常见的骨架图怎么办?骨架图是什么,就是在网络请求未返回的时候,用缓存的data或者模拟样式,渲染出一个包含大致结构,但不包含具体内容的页面状态,例如这种:





这种情况下,cell已经铺满了屏幕,但实际上并未完成渲染。这里就要依赖于节点的前后顺序了,详细UI是依赖于网络数据的,而骨架图是在网络返回之前绘制完成的,所以真正的覆盖率计算,是从网络数据返回开始的,因此骨架图的填充完成节点,并不会被错误统计未详细UI渲染完成的节点。
覆盖率的计算方式



如上图所示,开辟两个数组a、b,数组空间分别为屏幕长宽的像素数,并以0填充,分别代表横纵坐标;
从ViewController的view开始递归遍历他的subView,遇见有效视图时,将其frame的width和height,对应在数组a、b中的range的内存空间,都填充为1,每次遍历结束后,计算数组a、b中内容为1的比例,当达到阈值比例时,则视为可见状态。
示例代码如下:
- (void)checkPageRenderStatus:(UIView *)rootView {
if (kPhoneDeviceScreenSize.width <= 0 || kPhoneDeviceScreenSize.height <= 0) {
return;
}

memset(_screenWidthBitMap, 0, kPhoneDeviceScreenSize.width);
memset(_screenHeightBitMap, 0, kPhoneDeviceScreenSize.height);

[self recursiveCheckUIView:rootView];
}

- (void)recursiveCheckUIView:(UIView *)view {
if (_isCurrentPageLoaded) {
return;
}

if (view.hidden) {
return;
}

// 检查view是否是白名单中的实例,直接用于填充bitmap
for (Class viewClass in _whiteListViewClass) {
if ([view isKindOfClass:viewClass]) {
[self fillAndCheckScreenBitMap:view isValidView:YES];
return;
}
}

// 最后递归检查subviews
if ([[view subviews] count] > 0) {
for (UIView *subview in [view subviews]) {
[self recursiveCheckUIView:subview];
}
}
}

- (BOOL)fillAndCheckScreenBitMap:(UIView *)view isValidView:(BOOL)isValidView {

CGRect rectInWindow = [view convertRect:view.bounds toView:nil];

NSInteger widthOffsetStart = rectInWindow.origin.x;
NSInteger widthOffsetEnd = rectInWindow.origin.x + rectInWindow.size.width;
if (widthOffsetEnd <= 0 || widthOffsetStart >= _screenWidth) {
return NO;
}
if (widthOffsetStart < 0) {
widthOffsetStart = 0;
}
if (widthOffsetEnd > _screenWidth) {
widthOffsetEnd = _screenWidth;
}
if (widthOffsetEnd > widthOffsetStart) {
memset(_screenWidthBitMap + widthOffsetStart, isValidView ? 1 : 0, widthOffsetEnd - widthOffsetStart);
}

NSInteger heightOffsetStart = rectInWindow.origin.y;
NSInteger heightOffsetEnd = rectInWindow.origin.y + rectInWindow.size.height;
if (heightOffsetEnd <= 0 || heightOffsetStart >= _screenHeight) {
return NO;
}
if (heightOffsetStart < 0) {
heightOffsetStart = 0;
}
if (heightOffsetEnd > _screenHeight) {
heightOffsetEnd = _screenHeight;
}
if (heightOffsetEnd > heightOffsetStart) {
memset(_screenHeightBitMap + heightOffsetStart, isValidView ? 1 : 0, heightOffsetEnd - heightOffsetStart);
}

NSUInteger widthP = 0;
NSUInteger heightP = 0;
for (int i=0; i< _screenWidth; i++) {
widthP += _screenWidthBitMap[i];
}
for (int i=0; i< _screenHeight; i++) {
heightP += _screenHeightBitMap[i];
}

if (widthP > _screenWidth * kPageLoadWidthRatio && heightP > _screenHeight * kPageLoadHeightRatio) {
_isCurrentPageLoaded = YES;
return YES;
}

return NO;
}

但是也会有极端情况(类似下图) 


无法正确反应有效视图的覆盖情况。但是出于性能考虑,并不会采用二维数组,因为w*h的量太大,遍历和计算的耗时,会有指数级的激增;而且,正常业务形态,应该不太会有类似的极端形态。


即使真的会较高频的出现类似情况,也有一套备选方案:计算有效视图的面积 占 总面积 的比例;该种方式会涉及到UI坐标系的频繁转换,耗时也会略差于当前的方式。


在某些业务场景下,例如 无/少结果情况,关于页面等,完全渲染后,也无法达到铺满阈值。
这种情况,会以用户发生交互(同 1、用户行为触发页面跳转 的获取方式)和 主线程闲时状态超过5s (可配)来做兜底,看是否属于这种状态,如果是,则相关性能数据不上报,因为此种页面对性能的消耗较正常铺满的情况要低,并不能真实的反应性能消耗、瓶颈,因此,仅正常铺满的业务场景进行监控并优化,即可。


扫描的触发时机

以帧刷新为准,因为只有每次帧刷新后,UI才会真正产生变化;出于性能考虑,不会每帧都进行扫描,每间隔x帧(x可配,默认为1),扫描一次;同时,考虑高刷屏 和 大量UI绘制时会丢帧 的情况,设置 扫描时间间隔 的上下限,即:满足 隔x帧 的前提下,如果和上次扫描的时间差小于 下限,仍不扫描;如果 某次扫描时,和上次扫描的时间间隔 大于 上限,则无论中间隔几帧,都开启一次扫描。


11. 用户可交互

用户可见之后的下一个对用户来说至关重要的节点。如果只是可见,然后就疯狂占用主线程或其他资源,造成用户的点击等交互行为,还是会被卡主,用户只能看,不能动,这个体感也是很差的;


具体实现:详细UI渲染完成 后的 首次主线程闲时状态。


监控方案


这里由于各家的基建并不相同,因此只是总结一些小的建议,可能会比较零散,大家见谅。

  1. 建议采样收集
  2. 首先,数据的采集或者其他的新增行为/方法,一定是会产生耗时的,虽然可能不多,但还是秉着尽善尽美的原则,还是能少点就少点的,所以数据的采集,包括前面的hook等等一切行为,都只是随机的面向一部分用户开放,降低影响范围; 而且,如果数据量极大,全量的数据上报,其实对数据链路本身也会产生压力、增加成本。 当前,采样的前提是基本数据量足够,不然的话,采样样本量过小,容易对统计结果产生较大波动,造成不置信的结果。

    1. 可配置

    除了基本的是否开启的开关之外,还有其他的很多的点 需要/可以/建议 使用线上配置控制。个人认为,线上配置,除了实现对逻辑的控制,更重要的一个作用,就是出现问题时及时止损。 举一些我目前使用的配置中的例子: - 有效视图类 - 渲染完成状态,横纵坐标的填充百分比阈值 - 终态的兜底阈值 - VC的类名、对应的网络请求 等等。

    1. 本地异常数据过滤

    由于我们的样本数据量会非常大,所以对于异常数据我们不需要“手软”,我们需要有一套本地异常数据过滤的机制,来保证上报的数据都是符合要求的;不然我们后续统计处理的时候,也会因此出现新的问题需要解决。


后续还能做的事


这一部分,是对后续可实现方案的一个美好畅想~


1)页面可见态的终点,不只是覆盖率

其实,实际业务场景中,很多cell,即使绘制完,并渲染到屏幕上,此时,用户可见的也没有达到我们真正希望用户可见的状态,很多内容,都还是一个placeholder的状态。例如,通过url加载的image,我们一般都是先把他的size算好,把他的位置留好,cell渲染完就直接展示了;再进一步,如果是一个视频的播放卡片,即使网络图片加载好了,还要等待视频帧的返回,才能真正达到这张卡片的业务终态\color{red}{业务终态}(求教这里标红后如何能够让字体大小一致)。


这个非常后置,而且我们端上可能也影响不了什么的节点,采集起来有意义吗?


我觉得这是一个非常有价值的节点。一直都在说“技术反哺业务”,那么业务想要用户真正看到的那个终态,就是很重要的一环;因此,用户能在什么时间点看到,从业务角度说,能够影响其后续的方案设计(表现形式),完善用户体感对业务指标的影响;从技术角度说,可以感知真实的全链路的表现(不只是端),从而有针对性的进行优化。


如何获取到所有的业务终态呢?


这里一定是和业务有所耦合的,因为每个业务的终态,只有业务自身才知道;但是我们还是要尽量降低耦合度。
这里可以用协议的方式,为各个业务增加一个达到终态的标识,那么在某个业务达到终态之后,设置该标识即可,这里就是唯一对业务的侵入了;然后和计算覆盖率类似,这里的遍历,是业务维度(这里想象为卡片更好理解一点),只有全部业务的标识都ready之后,才是真正达到业务上的终态。


2)性能指标 关联 业务行为

其实,现在性能监控,各类平台,各个团队,或多或少的都在做,我相信,性能数据采集的代码,在工程中,也不仅仅只有一份;这个现状,在很多成一定规模的互联网公司中都可能存在。


而如果您和我一样,作为一个业务团队,如何在不重复造轮子的情况下,夹缝中求生存呢?


我个人目前的理解:将 性能表现 与 业务场景 相关联。


帧率、启动耗时、CPU、内存等等,这些性能指标数据的获取,在业界都有非常成熟的方案,而且我们的工程里,一定也有相关的代码;而我们能做的,仅仅是,调一下人家的api,然后把数据再自己上传一份(甚至有的连上传都包含了),就完事了吗?


这样我觉得并不能体现出我们自建监控的价值。个人理解,监控的意义在于:暴露问题 + 辅助定位问题 + 验证问题的解决效果


所以我们作为业务团队,将 性能数据 和 我们的业务做了什么 bind 到一起了,是不是就能一定程度上完成了上面的目的呢?


我们可以明确,我们什么样的业务行为,会影响我们的性能数据,也就是影响我们的用户基础体验。这样,不仅会帮助我们定位问题的原因,甚至会影响产品侧的一些产品能力设计方案。


完成这些建设之后,可能我们的监控就可以变成这样,甚至更好的状态: 



3)完善全链路对性能表现的关注

性能数据的关注、监控,不应该仅仅在线上阶段,开发期 → 测试期 → 线上,全链路各个环节都应该具有。

  • 目前各家都比较关注线上监控,相信都已经较为完善;

  • 测试期的业务流程性能脚本;对于测试的性能测试方案,开放应该参与共建或者有一定程度的参与,这样才能从一定程度上保证数据的准确性,以及双方性能数据的相互认可;

  • 开发期,目前能够提供展示实时CPU、FPS、内存数据的基础能力的工具很常见,也比较容易实现;但实际上,在日常开发的过程中,很难让RD同时关注需求情况与性能数据表现。因此,还是需要一些工具来辅助:例如,我们可以对某些性能指标,设置一些阈值,当日常开发中,超过阈值时,则弹窗提醒RD确认是否原因、是否需要优化,例如,详细UI绘制阶段的耗时阈值是800ms,如果某位同学在进行变更后,实际绘制耗时多次超越该值,则弹窗提醒。


作者:XTShow
链接:https://juejin.cn/post/7184033051289059384
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

设计模式-01.简单工厂方法

iOS
这是我尝试写的第一篇文章,以软件开发的设计模式开始,记录一下自己的理解与心得,方便以后回过头来查看。以简单工厂开始: 什么是简单工厂? 简单工厂模式(Simple Factory Pattern)是一种创建型设计模式,它提供了一种简单的方法来创建对象,而不需...
继续阅读 »

这是我尝试写的第一篇文章,以软件开发的设计模式开始,记录一下自己的理解与心得,方便以后回过头来查看。以简单工厂开始:


什么是简单工厂?



简单工厂模式(Simple Factory Pattern)是一种创建型设计模式,它提供了一种简单的方法来创建对象,而不需要直接暴露对象的创建逻辑给客户端。



UML 类图


以计算器为例子,拥有加减乘除功能,画出类图:



具体示例

// 运算符接口
protocol Operation {
    var numberA: Double { set get }
    var numberB: Double { setget }
    func calculate() -> Double
}

// 加法运算类
struct OperationAdd: Operation {
    var numberA: Double = 0.0
    var numberB: Double = 0.0
    func calculate() -> Double {
        return numberA + numberB
    }
}

// 减法运算类
struct OperationSub: Operation {
    var numberA: Double = 0.0
    var numberB: Double = 0.0
    func calculate() -> Double {
        return numberA - numberB
    }
}

// 乘法运算类
struct OperationMul: Operation {
    var numberA: Double = 0.0
    var numberB: Double = 0.0
    func calculate() -> Double {
        return numberA * numberB
    }
}

// 除法运算类
struct OperationDiv: Operation {
    var numberA: Double = 0.0
    var numberB: Double = 0.0
    func calculate() -> Double {
        if numberB != 0 {
            return numberA / numberB
        }
        return 0
    }
}

// 简单工厂类
class OperationFactory {
    static func createOperate(_ operate: String) -> Operation? {
        switch operate {
        case "+":
            return OperationAdd()
        case "-":
            return OperationSub()
        case** "*":
            return OperationMul()
        case "/":
            return OperationDiv()
        default: return nil
        }
    }
}

// 客户端调用
// 加法运算
var addOperation = OperationFactory.createOperate("+")
addOperation?.numberA = 1
addOperation?.numberB = 2
addOperation?.calculate()

// 减法运算
var subOperation = OperationFactory.createOperate("-")
subOperation?.numberA = 1
subOperation?.numberB = 2
subOperation?.calculate()

// 乘法运算
var mulOperation = OperationFactory.createOperate("*")
mulOperation?.numberA = 1
mulOperation?.numberB = 2
mulOperation?.calculate()

// 除法运算
var divOperation = OperationFactory.createOperate("/")
divOperation?.numberA = 1
divOperation?.numberB = 2
divOperation?.calculate()

简单工厂方法总结


优点:

  • 将对象的创建逻辑集中在工厂类中,降低了客户端的复杂度。
  • 隐藏了创建对象的细节,客户端只需要关心需要创建何种对象,无需关心对象是如何创建的。
  • 可以通过修改工厂类来轻松添加新的产品类

缺点:

  • 如果产品的类太多,会导致工厂类中的代码变得很复杂,难以维护。
  • 添加新产品时,需要修改工厂类,也就是会在OperationFactory类中新增case语句,这违背了开闭原则。

总体而言,简单工厂模式适用于创建对象的逻辑相对简单,且产品类的数量较少的场景。对于更复杂的对象创建和对象之间的依赖关系,可以考虑使用其他创建型设计模式,如工厂方法模式或抽象工厂模式。


作者:SamuelWu
链接:https://juejin.cn/post/7267091509953855543
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

SwiftUI精讲:Tabs 标签页组件的实现

iOS
大家好,我们又见面了~今天给大家带来 Tabs标签页组件在SwiftUI中的实现方式。在本文中,我依然会采用一种循序渐进的方式来进行讲解,这其实也是我的实现思路,希望能帮到需要的朋友。 在看本文之前,我强烈建议你先阅读我的上一篇文章 SwiftUI精讲:自定...
继续阅读 »

大家好,我们又见面了~今天给大家带来 Tabs标签页组件在SwiftUI中的实现方式。在本文中,我依然会采用一种循序渐进的方式来进行讲解,这其实也是我的实现思路,希望能帮到需要的朋友。



在看本文之前,我强烈建议你先阅读我的上一篇文章 SwiftUI精讲:自定义 Tabbar 组件 (包含过渡效果),因为有一些重复的知识点在上篇中已经讲过了,本文再讲的话难免会有些乏味,我希望每次写下的文章都有一些新的知识点~


1.Tabs组件的实现


我们先创建 Componets 文件夹,并在其中创建 tabs 文件,我们先简单地创建一个list,并将内容遍历渲染出来,如下所示:


1-1:大致UI的实现

import SwiftUI

struct TabItem: Identifiable {
var id:Int
var text:String
}

struct tabs: View {
let list:[TabItem]
@State var currentSelect:Int = 0
var body: some View {
ScrollView(.horizontal,showsIndicators: false) {
HStack {
ForEach(list) { tabItem in
Button{
currentSelect = tabItem.id
} label: {
HStack{
Spacer()
Text(tabItem.text)
.padding(.horizontal,12)
.fixedSize()
Spacer()
}
}
}
}
.frame(minWidth: UIScreen.main.bounds.width)
}
}
}

struct tabs_Previews: PreviewProvider {
// 创建一些测试数据
static let list = [
TabItem(id:1,text:"关注"),
TabItem(id:2,text:"推荐"),
TabItem(id:3,text:"热榜"),
TabItem(id:4,text:"头条精选"),
TabItem(id:5,text:"后端"),
TabItem(id:6,text:"前端")
]
static var previews: some View {
tabs(list: list)
}
}

这里加上 .frame(minWidth: UIScreen.main.bounds.width) 是为了保证在标签只有两三个的时候,我依然希望它们处于一个均匀布局的状态。代码运行后如图所示:



接着我们加上下划线样式,代码如下所示:

import SwiftUI

struct TabItem: Identifiable {
var id:Int
var text:String
}


struct tabs: View {
let list:[TabItem]
@State var currentSelect:Int = 1
var body: some View {
ScrollView(.horizontal,showsIndicators: false) {
HStack {
ForEach(list) { tabItem in
Button{
currentSelect = tabItem.id
} label: {
HStack{
Spacer()
Text(tabItem.text)
.padding(EdgeInsets(top: 8, leading: 12, bottom: 10, trailing: 12))
.fixedSize()
Spacer()
}
.background(
VStack{
if(currentSelect == tabItem.id){
Spacer()
Rectangle()
.fill(Color(hex: "#1677ff"))
.frame(height: 2)
.padding(.horizontal,12)
.cornerRadius(2)
}
}

)

}
}
}
.frame(minWidth: UIScreen.main.bounds.width)
}
}
}

struct tabs_Previews: PreviewProvider {
// 创建一些测试数据
static let list = [
TabItem(id:1,text:"关注"),
TabItem(id:2,text:"推荐"),
TabItem(id:3,text:"热榜"),
TabItem(id:4,text:"头条精选"),
TabItem(id:5,text:"后端"),
TabItem(id:6,text:"前端")
]
static var previews: some View {
tabs(list: list)
}
}

细心的朋友可能会发现,我的代码里面出现了 Color(hex: "#1677ff"),这是因为我们对Color结构进行了拓展,让它支持16进制颜色的传递,如下所示:

extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (1, 1, 1, 0)
}

self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}

代码运行后的效果如图所示:



我们再对字体方面进行优化,我们希望点击后的字体颜色和大小和点击前保持不一致,我们对代码做出修改,如下所示:

HStack{
Spacer()
Text(tabItem.text)
.padding(EdgeInsets(top: 8, leading: 12, bottom: 10, trailing: 12))
.fixedSize()
.foregroundColor(currentSelect == tabItem.id ? Color(hex: "#1677ff") : Color(hex: "#333"))
// 新增
.font(.system(size: currentSelect == tabItem.id ? 20 : 17))
// 新增
.fontWeight(currentSelect == tabItem.id ? .bold : .regular)
Spacer()
}

更改后的效果如图所示:



好了,我们一个普通的tab组件就写完了,完结撒花。


接下来我们需要给下划线添加相应的过渡效果,类似于掘金的下划线移动过渡。如果有从事web端开发的朋友们,我们可以想一下,在web端我们是怎么实现类似的效果的?是不是要通过一些计算,然后赋值给下划线 css的 left 值,或者是 translateX 值。在SwiftUI中,我们压根不用这么麻烦,我们可以使用 matchedGeometryEffect 来轻易的做到相应的效果!


1-2:下划线过渡效果实现


我们对代码稍微修改下,详细的步骤我会在图中进行标注,如下图所示:




接着我们按下 command + R ,运行 Simulator 来查看对应的效果:




可以发现,我们其实已经取得了我们想要的效果。但是由于 tab 在激活的时候,文字对应的动画看着十分晃眼,很讨人厌。如果希望只保留下划线的过渡效果,而不要文字的过渡效果,该怎么做呢?


很简单,我们只需要添加 .animation(nil,value:UUID()) 即可,如下所示:

Text(tabItem.text)
.padding(EdgeInsets(top: 8, leading: 12, bottom: 10, trailing: 12))
.fixedSize()
.foregroundColor(currentSelect == tabItem.id ? Color(hex: "#1677ff") : Color(hex: "#333"))
.font(.system(size: currentSelect == tabItem.id ? 20 : 17))
.fontWeight(currentSelect == tabItem.id ? .bold : .regular)
// 新增
.animation(nil,value:UUID())

现在看起来是不是正常多了? 



1-3:自动滚动到对应位置


大致UI画得差不多了,接下来我们需要在点击比较靠后的tab时,我们希望 ScrollView 能帮我们滚动到对应的位置,我们该怎么做呢?
答案是引入 ScrollViewReader, 使用 ScrollViewProxy中的scrollTo方法,代码如下所示:

struct tabs: View {
let list:[TabItem]
@State var currentSelect:Int = 1
@Namespace var animationNamespace

var body: some View {
ScrollViewReader { scrollProxy in
ScrollView(.horizontal,showsIndicators: false) {
HStack {
ForEach(list) { tabItem in
Button{
withAnimation{
currentSelect = tabItem.id
}
} label: {
HStack{
Spacer()
Text(tabItem.text)
.padding(EdgeInsets(top: 8, leading: 12, bottom: 10, trailing: 12))
.fixedSize()
.foregroundColor(currentSelect == tabItem.id ? Color(hex: "#1677ff") : Color(hex: "#333"))
.font(.system(size: currentSelect == tabItem.id ? 20 : 17))
.fontWeight(currentSelect == tabItem.id ? .bold : .regular)
.animation(nil,value:UUID())

Spacer()
}
.background(
VStack{
if(currentSelect == tabItem.id){
Spacer()
Rectangle()
.fill(Color(hex: "#1677ff"))
.frame(height: 2)
.padding(.horizontal,12)
.cornerRadius(2)
.matchedGeometryEffect(id: "tab_line", in: animationNamespace)
}
}

)

}
}
}
.frame(minWidth: UIScreen.main.bounds.width)
}
.onChange(of: currentSelect) { newSelect in
withAnimation(.easeInOut) {
scrollProxy.scrollTo(currentSelect,anchor: .center)
}
}
}
}
}

在代码中,我们利用 scrollProxy.scrollTo 方法,轻易地实现了滚动到对应tab的位置。效果如下所示:




呜呼,目前为止,我们已经完成了一个不错的tabs组件。接下来在ContentView中,我们引入该组件。由于我们需要在父视图中知道tabs中currentSelect的变化,我们需要把子组件的 @State 改成 @Binding,同时为了避免 preview报错,我们也要做出对应的修改,如图所示:




1-4:结合TabView完成手势滑动切换


日常我们在使用tabs标签页的时候,如果需要支持用户通过手势进行切换标签页的操作,我们可以结合TabView一起使用,代码如下所示:

import SwiftUI

struct ContentView: View {
let list = [
TabItem(id:1,text:"关注"),
TabItem(id:2,text:"推荐"),
TabItem(id:3,text:"热榜"),
TabItem(id:4,text:"头条精选"),
TabItem(id:5,text:"后端"),
TabItem(id:6,text:"前端"),
]


@State var currentSelect:Int = 1
var body: some View {
VStack(spacing: 0){
tabs(list:list,currentSelect:$currentSelect)
TabView(selection:$currentSelect){
ForEach(list){tabItem in
Text(tabItem.text).tag(tabItem.id)
}
}.tabViewStyle(.page(indexDisplayMode: .never))
}
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

效果如下所示:



至此,我们总算是完成了一个能满足大部分需求的Tabs组件啦~


2. Tabs组件的拓展


2-1:Tabs组件的吸顶


仅仅实现一个简单的效果怎么够,这不符合笔者精讲技术的精神,我们还要结合日常的业务进行思考。比如,我现在想要在页面滚动的时候,我希望tabs组件能够自动吸顶,应该怎么去实现呢?


首先我们新建View文件夹,在其中放置一些视图组件,并在组件中,添加一些文本,如图所示:



接着我们先思考一下,如何在SwiftUI中做出一个吸顶的效果。这里我使用了 LazyVStack + Section的方式来做。但是有个问题,TabView被包裹在Section里面时,TabView的高度会丢失。我将会在 ScrollView 的外层套上 GeometryReader 来解决这个问题,以下为代码展示:

import SwiftUI

struct ContentView: View {
let list = [
TabItem(id:1,text:"关注"),
TabItem(id:2,text:"推荐"),
TabItem(id:3,text:"热榜")
]

@State var currentSelect:Int = 1
var body: some View {
NavigationView{
GeometryReader { proxy in
ScrollView{
LazyVStack(spacing: 0, pinnedViews:.sectionHeaders) {
Section(
header:tabs(list:list,currentSelect:$currentSelect)
.background(.white)
){
TabView(selection:$currentSelect){
ForEach(list){tabItem in
VStack{
switch currentSelect{
case 1:
Attention()
case 2:
Recommend()
case 3:
Hot()
default:
Text("")
}
}
.tag(tabItem.id)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.frame(minHeight:proxy.size.height)
}
}
}
}
.navigationTitle("Tabs组件实现")
}
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

效果如图所示:



2-2:下拉刷新的实现


要实现下拉刷新的功能,我们可以使用ScrollView并结合.refreshable 来实现这个效果,代码如下所示:

import SwiftUI

struct ContentView: View {
let list = [
TabItem(id:1,text:"关注"),
TabItem(id:2,text:"推荐"),
TabItem(id:3,text:"热榜")
]

@State var currentSelect:Int = 1
var body: some View {
NavigationView{
GeometryReader { proxy in
ScrollView{
LazyVStack(spacing: 0, pinnedViews:.sectionHeaders) {
Section(
header:tabs(list:list,currentSelect:$currentSelect)
.background(.white)
){
TabView(selection:$currentSelect){
ForEach(list){tabItem in
ScrollView{
switch currentSelect{
case 1:
Attention()
case 2:
Recommend()
case 3:
Hot()
default:
Text("")
}
}
.tag(tabItem.id)
.refreshable {
print("触发刷新")
}
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.frame(minHeight:proxy.size.height)
}
}
}
}
.navigationTitle("Tabs组件实现")
}
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

在这里要注意 .refreshable 是 ios15 才能使用的,使用时要考虑API的兼容性。效果如图所示:



至此,我们已经完成了一个很不错的Tabs标签页组件啦。感谢你的阅读,如有问题欢迎在评论区中进行交流~


作者:KSHMR的小粉丝
链接:https://juejin.cn/post/7208016902039207993
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Swift Enum 关联值嵌套的一些实践

iOS
前言 Swift 中的枚举很强大,算是一等公民。可以定义函数,也可以遵守协议、实现 extension 等等。 关联值也是 Swift 枚举的一大特性。基本用法如下:enum RequestResult { case success case ...
继续阅读 »

前言


Swift 中的枚举很强大,算是一等公民。可以定义函数,也可以遵守协议、实现 extension 等等。


关联值也是 Swift 枚举的一大特性。基本用法如下:

enum RequestResult {
case success
case failure(Error)
}

let result = RequestResult.failure(URLError(URLError.timedOut))
switch result {
case .success:
print("请求成功")
case .failure(let error):
print(error)
}

1、在需要关联值的 case 中声明关联值的类型。


2、在 switch 的 case 中声明一个常量或者变量来接收。


遇到的问题


一般情况下,上述的代码是清晰明了的。但在实际开发的过程中,遇到了以下的情况:关联值的类型也是枚举,而且嵌套不止一层。


比如下面的代码:

enum EnumT1 {
case test1(EnumT2)
case other
}

enum EnumT2 {
case test2(EnumT3)
case other2
}

enum EnumT3 {
case test3(EnumT4)
case test4
}

根据我们的需求,需要进行多次嵌套来进行类型细化。当进行枚举的声明时,代码还是正常的,简单明了。但当进行 case 判断时,代码就变得丑陋难写了。


比如,我只想处理 EnumT3 中的 test4 的情况,在 switch 中我需要进行 switch 的嵌套来处理:

let t1: EnumT1? = .test1(.test2(.test4))
switch t1 {
case .test1(let t2):
switch t2 {
case .test2(let t3):
switch t3 {
case .test4:
print("test4")
case default:
print("default")
}
default:
print("default")
}
default:
print("default")
}

这种写法,对于一个程序员来说是无法忍受的。它存在两个问题:一是代码臃肿,我的本意是只处理某一种情况,但我需要显式的嵌套多层 switch;二是枚举本身是不推荐使用 default 的,官方推荐是显式的写出所有的 case,以防出现难以预料的问题。


废话不多说,下面开始简化之路。


实践一


首先能想到的是,因为是对某一种情况进行处理,考虑使用 if + == 的判断来进行处理,比如下面这种写法:

if t1 == .test1(.test2(.test4)) { }

这样处理有两个不足之处。首先,如果对枚举用 == 操作符的话,需要对每一个枚举都遵守 Equatable 协议,这为我们带来了工作量。其次最重要的是,这种处理方式无法应对 test3 这种带有关联值的情况。

if t1 == .test1(.test2(.test3) { } 

如果这样写的话,编译器会报错,因为 test3 是需要传进去一个 Int 值的。

if t1 == .test1(.test2(.test3(20))) { }

如果这样写的话也不行,因为我们的需求是处理 test3 的统一情况(所有的关联值),而不是某一个具体的关联值。


实践二


经过在网上的一番搜查,发现可以用 if-case 关键字来简化写法:

if case .test1(.test2(.test3)) = t1 { }

这样就能统一处理 test3 这个 case 的所有情况了。如果想获取关联值,可以用下面的写法:

if case .test1(.test2(.test3(let i))) = t1 {
print(i)
}

对比上面的 switch 写法,可以看到,下面的这种写法既易懂又好写😁。


总结来说,当我们遇到关联值多层枚举嵌套的时候,又需要对某一种情况进行处理。那么可以采用实践二的做法来进行代码简化。


参考链接


作者:冯志浩
链接:https://juejin.cn/post/7267919801780994109
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

做点微小的工作,实现 iOS 日历和提醒事项双向同步

iOS
前言 作为一名资深谷粉和十年的 Android 用户,在 2020 年看着各家厂商在笔记本、手机、手表、耳机甚至是智能家居上不断推成出新,补齐数字生活的每一块拼图,辅以“生态化反”的概念牢牢绑住每一个入坑的用户,此时再看看自己手里孤身寡人的 Pixel 手机,...
继续阅读 »

前言


作为一名资深谷粉和十年的 Android 用户,在 2020 年看着各家厂商在笔记本、手机、手表、耳机甚至是智能家居上不断推成出新,补齐数字生活的每一块拼图,辅以“生态化反”的概念牢牢绑住每一个入坑的用户,此时再看看自己手里孤身寡人的 Pixel 手机,以及不知何时就被砍掉的 Pixelbook 系列,默默留下了悔恨的泪水。久苦于谷歌令人失望的硬件生态,我终于还是放弃了 Android 生态,转身拥抱苹果全家桶。苹果硬件生态品类齐全,多年深耕的软件生态和云服务也赋予了这些硬件无缝的使用体验。但有一点一直令我不解,那就是 iOS 的自带应用:日历和提醒事项,它们的事件竟不是相互联动的。而在谷歌套件中,只要一个任务在 Google Tasks 中被新增或是被勾选完成,就会自动同步到 Google Calendar 中,以方便用户进行日程安排或是日程回顾。虽然第三方应用如滴答清单、Sunsama 也提供了类似的功能,但为了原生(免费)体验,只能自己动手折腾了。


前提条件


为了在 iOS 上实现日历和提醒事项双向同步的效果,需要借助快捷指令,搭配 JSBox 写一个脚本,创建数据库来绑定和管理日历和提醒事项中各自的事件。

  1. iOS 14+;
  2. 愿意花 40 RMB 开通 JSBox 高级版;
  3. 不满足第2点,则需要设备已越狱,或者装有 TrollStore;

*破解 JSBox


步骤:

  1. 在 App Store 安装 JSBox;
  2. 通过越狱的包管理工具或者 TrollStore 安装 Apps Manager;
  3. 下载 JSBox 备份文件,在文件管理中长按该文件,选择分享,使用 Apps Manager 打开,在弹出的菜单中点取消;
  4. 在 Apps Manager 中的 Applications 选项卡中,选择 JSBox,点击 Restore 进行还原,即可使用 JSBox 高级版功能(在 JSBox 中的设置选项卡中不要点击“JSBox 高级版”选项,否则需要再次还原);



加载脚本


步骤:

  1. 下载 Reminders ↔️ Calendar 项目文件,在文件管理中长按该文件,选择分享,使用 JSBox 打开;
  2. 在日历和提醒事项中各自新建一个“test”列表,在提醒事项的“test”列表中新建一个定时事件;
  3. 返回 JSBox 中的 Reminders ↔️ Calendar 项目,点击界面下的“Sync now”按钮;
  4. 回到日历中查看事件是否同步成功;



设置项说明:

  1. 同步周期 —— 周期内的事件才会被同步;
  2. 同步备注 —— 是否同步日历和提醒事项的备注;
  3. 同步删除 —— 删除一方事件时,是否自动删除另一方对应的事件;
  4. 单边提醒 —— 日历和提醒事项的事件,谁创建谁通知,关闭则日历和提醒事项都会通知;
  5. 历史待办默认超期完成 —— 补录历史待办,是否默认为已完成;
  6. 提醒事项:默认优先级 —— 在日历创建的事件,同步到提醒事项时候默认的优先级;
  7. 日历:默认用时 —— 在提醒事项创建的事件,同步到日历时默认的时间间隔;
  8. 日历:快速跳转 —— 日历的事件是否在链接项中添加跳转到对应提醒事项的快速链接;
  9. 日历:显示剩余时间 —— 日历的事件是否在地点项中添加时间信息;
  10. 日历:完成变全天 —— 日历的事件是否在完成时,自动变成全天事件(这样日历视图就会将该项目置顶,方便查看未完成项目);



设置快捷指令


步骤:

  1. 打开快捷指令应用,选择自动化选项卡,点击右上角 + 号新增一个任务;
  2. 选择新建个人自动化,设置触发条件为打开应用,指定应用为日历和提醒事项,点击下一步;
  3. 点击按钮新增一个行动,选择执行 JSBox 脚本,在脚本名上填入“Reminders ↔️ Calendar”,点击右下角的 ▶️ 测试,如果输出成功则点击下一步;(注意区分执行 JSBox 脚本和执行 JSBox 界面);
  4. 关闭执行前询问的选项,点击右上角的完成保存任务;



总结


JSBox 是一款运行在 iOS 设备上的轻量级脚本编辑器和开发环境。它内置了大量的 API,允许用户使用 JavaScript 访问原生的 iOS API。另一款相似的应用 Scriptable 在语法的书写上更亲和,但其暴露的事件对象中缺少 last modified 字段,当信息不对称时,没有办法判断日历和提醒事项中事件的新旧。期待 Scriptable 的后续更新,毕竟它是免费的🤡。


作者:Shouduo
链接:https://juejin.cn/post/7258222037319172154
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Widget开发流程

iOS
本文所用到的 Demo 可以在这里下载: github.com/zhenyunzhan… 一、创建Widget Extension 1、创建Widget Target 点击 Project —> 添加新的Target —> 搜索Widget Ext...
继续阅读 »

本文所用到的 Demo 可以在这里下载: github.com/zhenyunzhan…


一、创建Widget Extension


1、创建Widget Target


点击 Project —> 添加新的Target —> 搜索Widget Extension —> 点击Widget Extension —> 点击 Next




2、添加配置信息


Include Configuration Intent 是创建 intentdefinition 文件用的,可以让用户动态配置组件,可以先不勾选,后期可以手动创建




3、添加Widget


创建好之后就可以运行程序,程序运行完成之后,长按主屏幕进入编辑状态,点击主屏幕右上方添加按钮,找到程序,就可以添加Widget,简单体验下了


二、大致了解 Widget 文件


查看创建完 Widget Extension 后默认生成的 xxxx Widget.swift 文件,Xcode 为我们生成了 Widget 所需的模版代码,如下这个文件




widget的入口 @main标识的部分




view EntryView 是我们实际展示的UI




数据 Entry 是用来携带widget展示需要的数据




Provider Timeline的提供对象,包含TimelineEntry & ReloadPolicy,用来后续刷新 Widget 内容




三、开发


以Demo为例,做一个展示古诗内容的Widget,间隔一段时间后自动更新widget内容,并且可以自动选择古诗内容来跟新Widget,例子如下:


展示古诗内容 -> 长按后可编辑小组件 -> 进入选择界面 -> 选择并更新




四、静态配置 StaticConfiguration


创建完 Widget Extension Target之后,系统会给我们创建好一个Widget的开发模板


1、TimelineEntry


自己创建的模型作为参数,模型 (itemModel) 用 swift 或者 OC创建均可




2、界面样式


界面有三种尺寸的类型,每种尺寸还可以准备不同的布局,另外界面填充的数据就来源于 TimelineEntry




3、Timeline时间线


实现 TimelineProvider 协议 getTimeline 方法,主要是构建 Entry 和 reloadPolicy,用这两个参数初始化 Timeline ,之后再调用completion回调,回调会走到 @main ,去更新 Widget 内容。


demo中是每次刷新 Timeline ,创建一个 Entry, 则更新一次主屏幕的 Widget 内容, 刷新间隔为 60 分钟,注意:

  • atEnd 所有的entry执行完之后,再次调用 getTimeline 构造数据

  • after(date) 不管这次构造的entry 是否执行完,等系统时间到达date之后,就会在调用getTimeline

  • never 最后一个 entry 展示完毕之后 Widget 就会一直保持那个 entry 的显示内容




开发完成后,可以运行代码,试一下效果,此时的更新时间有点长,可以改为 5 秒后再试。


五、动态配置 IntentConfiguration


程序运行到这里,有的会想,怎么实现编辑小组件功能,动态配置 widget 的显示内容呢?




1、创建 intentdefinition 文件


command + N 组合键创建新 File —> 搜索 intent




选择xxx.intentdefinition文件 —>点击下方 + ,选择intent创建 —> 对intent命名






这个 intent 文件包含了你所有的(intents),通过这个文件编译到你的app中,系统将能够读取你的 intents ,一旦你定义了一个intent文件,Xcode也会为你生成一个intent类别


2、可以添加到 intent 中的参数类型


参数类型有多种,下方为一些示例
参数类型分别为:String、Boolean、Integer时的展示




你也可以用自己定义的类型去设置,参数也支持多个值




3、如何为小组件添加丰富的配置


a、确定配置对象


以这个demo为例,小组件只能显示一首古诗,但是app中有很多首古诗,这就可以创建多个 古诗 组件,然后通过动态配置,每个小组件想要显示不同的古诗。这样的例子还有很多,比如某个人有多张银行卡,每个组件显示不同银行卡余额




b、配置intent文件


category选项设置为View,然后勾选下图中的选项,现在我们可以只关注小组件选项,将快捷指令的勾选也取消,如下图




c、intent添加参数


使用参数列表中的 + 按钮,添加一个参数




Type类型可以选择自定义的type




参数添加完后,系统会在ClickBtnIntent类中生成相应的属性




随后ClickBtnIntent 的实例将在运行时传递到 小组件扩展中,让你的小组件知道用户配置了什么,以及要显示什么




d、代码中的更改


StaticConfiguration 切换为 IntentConfiguration,相应的provider也改为IntentTimelineProvider,provider就不上截图了,可以去demo中的ClickBtn.swift文件查看




现在运行APP,然后长按古诗小组件,选择编辑小组件,会弹出带有Btn Type的参数,点击Btn Type一栏弹出带有搜索的列表页面。 效果如下:




显示的Btn Type就是下图中框选Display Name,自己可以随便起名字,中英文均可




目前,带有搜索的列表页面是一个空白页面,如果想要使其有数据,则要都选Dynamic Options复选框,为其添加动态数据




e、如何为列表添加动态数据?


勾选了Dynamic Options复选框,系统会自动生成一个ClickBtnIntentHandling协议,可以点开ClickIntent类去查看,现在有了intent文件,有了新的可遵守协议,就需要有一个Extension去遵守协议,实现协议里边的方法,为搜索列表提供数据



  • 点击Project —> 新建target —> 搜索intent —> 选择 Intents Extentsion







  • 贴上类的方法,以及方法对应的效果图




f、注意点


实现IntentHandler时,Xcode会报找不到ClickBtnIntentHandling这个协议的错误,

  • 引入头文件 Intents
  • 需要将下图所标的地方做下修改



六、APP创建多个Widget


这个比较简单,按照demo中的例子处理一下就可以,如下图:




目前测试,最多可以同时创建五个不同的Widget


作者:zhenyun
链接:https://juejin.cn/post/7189897593965510711
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

PAG动效框架源码笔记 (四)渲染框架

iOS
前言 PAG采用自研TGFX特效渲染引擎,抽象分离了接口及平台实现类,可以扩展支持多种图形渲染库,比如OpenGL、Metal等 TGFX引擎是如何实现纹理绘制?本文基于OpenGL图形库分析讲解TGFX渲染框架分层及详细架构设计。开始之前,先提一个问题: 绘...
继续阅读 »

前言


PAG采用自研TGFX特效渲染引擎,抽象分离了接口及平台实现类,可以扩展支持多种图形渲染库,比如OpenGL、Metal等


TGFX引擎是如何实现纹理绘制?本文基于OpenGL图形库分析讲解TGFX渲染框架分层及详细架构设计。开始之前,先提一个问题:


绘制一个Texture纹理对象,一般需要经历哪些过程?


渲染流程


通常情况下,绘制一个Texture纹理对象到目标Layer上,可以抽象为以下几个阶段:


1. 获取上下文: 通过EGL获取Context绘制上下文,提供与渲染设备交互的能力,比如缓冲区交换、Canvas及Paint交互等


2. 定义着色器: 基于OpenGL的着色器语言(GLSL)编写着色器代码,编写自定义顶点着色器和片段着色器代码,编译、链接加载和使用它们


3. 绑定数据源: 基于渲染坐标系几何计算绑定顶点数据,加载并绑定纹理对象给GPU,设置渲染目标、混合模式等


4. 渲染执行: 提交渲染命令给渲染线程,转化为底层图形API调用、并执行实际的渲染操作




关于OpenGL完整的渲染流程,网上有比较多的资料介绍,在此不再赘述,有兴趣的同学可以参考 OpenGL ES Pipeline


框架层级


TGFX框架大致可分为三大块:


1. Drawable上下文: 基于EGL创建OpenGL上下文,提供与渲染设备交互的能力


2. Canvas接口: 定义画布Canvas及画笔Paint,对外提供渲染接口、记录渲染状态以及创建绘制任务等


3. DrawOp执行: 定义并装载着色器函数,绑定数据源,执行实际渲染操作


为了支持多平台,TGFX定义了一套完整的框架基类,实现框架与平台的物理隔离,比如矩阵对象Matrix、坐标Rect等,应用上层负责平台对象与TFGX对象的映射转化

- (void)setMatrix:(CGAffineTransform)value {
pag::Matrix matrix = {};
matrix.setAffine(value.a, value.b, value.c, value.d, value.tx, value.ty);
_pagLayer->setMatrix(matrix);
}

Drawable上下文


PAG通过抽象Drawable对象,封装了绘制所需的上下文,其主要包括以下几个对象


1. Device(设备): 作为硬件设备层,负责与渲染设备交互,比如创建维护EAGLContext等


2. Window(窗口): 拥有一个Surface,负责图形库与绘制目标的绑定,比如将的opengl的renderBuffer绑定到CAEAGLLayer上;


3. Surface(表面): 创建canvas画布提供可绘制区域,对外提供flush绘制接口;当窗口尺寸发生变化时,surface会创建新的canvas


4. Canvas(画布): 作为实际可绘制区域,提供绘制api,进行实际的绘图操作,比如绘制一个image或者shape等



详细代码如下:


1、Device创建Context
std::shared_ptr<GLDevice> GLDevice::Make(void* sharedContext) {
if (eaglShareContext != nil) {
eaglContext = [[EAGLContext alloc] initWithAPI:[eaglShareContext API]
sharegroup:[eaglShareContext sharegroup]];
} else {
// 创建Context
eaglContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3];
if (eaglContext == nil) {
eaglContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
}
}
auto device = EAGLDevice::Wrap(eaglContext, false);
return device;
}

std::shared_ptr<EAGLDevice> EAGLDevice::Wrap(EAGLContext* eaglContext, bool isAdopted) {
auto oldEAGLContext = [[EAGLContext currentContext] retain];
if (oldEAGLContext != eaglContext) {
auto result = [EAGLContext setCurrentContext:eaglContext];
if (!result) {
return nullptr;
}
}
auto device = std::shared_ptr<EAGLDevice>(new EAGLDevice(eaglContext),
EAGLDevice::NotifyReferenceReachedZero);
if (oldEAGLContext != eaglContext) {
[EAGLContext setCurrentContext:oldEAGLContext];
}
return device;
}

// 获取Context
bool EAGLDevice::makeCurrent(bool force) {
oldContext = [[EAGLContext currentContext] retain];
if (oldContext == _eaglContext) {
return true;
}
if (![EAGLContext setCurrentContext:_eaglContext]) {
oldContext = nil;
return false;
}
return true;
}

2、Window创建Surface,绑定RenderBuffer
std::shared_ptr<Surface> EAGLWindow::onCreateSurface(Context* context) {
auto gl = GLFunctions::Get(context);
...
gl->genFramebuffers(1, &frameBufferID);
gl->bindFramebuffer(GL_FRAMEBUFFER, frameBufferID);
gl->genRenderbuffers(1, &colorBuffer);
gl->bindRenderbuffer(GL_RENDERBUFFER, colorBuffer);
gl->framebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, colorBuffer);
auto eaglContext = static_cast<EAGLDevice*>(context->device())->eaglContext();
// 绑定到CAEAGLLayer上
[eaglContext renderbufferStorage:GL_RENDERBUFFER fromDrawable:layer];
...
GLFrameBufferInfo glInfo = {};
glInfo.id = frameBufferID;
glInfo.format = GL_RGBA8;
BackendRenderTarget renderTarget = {glInfo, static_cast<int>(width), static_cast<int>(height)};
// 创建Surface
return Surface::MakeFrom(context, renderTarget, ImageOrigin::BottomLeft);
}

// 通过renderTarget持有context、frameBufferID及Size
std::shared_ptr<Surface> Surface::MakeFrom(Context* context,
const BackendRenderTarget& renderTarget,
ImageOrigin origin, const SurfaceOptions* options) {
auto rt = RenderTarget::MakeFrom(context, renderTarget, origin);
return MakeFrom(std::move(rt), options);
}

3、Surface创建Canvas及flush绘制
Canvas* Surface::getCanvas() {
// 尺寸变化时会清空并重新创建canvas
if (canvas == nullptr) {
canvas = new Canvas(this);
}
return canvas;
}

bool Surface::flush(BackendSemaphore* signalSemaphore) {
auto semaphore = Semaphore::Wrap(signalSemaphore);
// drawingManager创建tasks,装载绘制pipiline
renderTarget->getContext()->drawingManager()->newTextureResolveRenderTask(this);
auto result = renderTarget->getContext()->drawingManager()->flush(semaphore.get());
return result;
}

4、渲染流程
bool PAGSurface::draw(RenderCache* cache, std::shared_ptr<Graphic> graphic,
BackendSemaphore* signalSemaphore, bool autoClear) {
// 获取context上下文
auto context = lockContext(true);
// 获取surface
auto surface = drawable->getSurface(context);
// 通过canvas画布
auto canvas = surface->getCanvas();
// 执行实际绘制
onDraw(graphic, surface, cache);
// 调用flush
surface->flush();
// glfinish
context->submit();
// 绑定GL_RENDERBUFFER
drawable->present(context);
// 释放context上下文
unlockContext();
return true;
}

Canvas接口


Canvas API主要包括画布操作及对象绘制两大类:


画布操作包括Matrix矩阵变化、Blend融合模式、画布裁切等设置,通过对canvasState画布状态的操作实现绘制上下文的切换


对象绘制包括Path、Shape、Image以及Glyph等对象的绘制,结合Paint画笔实现纹理、文本、图形、蒙版等多种形式的绘制及渲染

class Canvas {
// 画布操作
void setMatrix(const Matrix& matrix);
void setAlpha(float newAlpha);
void setBlendMode(BlendMode blendMode);

// 绘制API
void drawRect(const Rect& rect, const Paint& paint);
void drawPath(const Path& path, const Paint& paint);
void drawShape(std::shared_ptr<Shape> shape, const Paint& paint);
void drawImage(std::shared_ptr<Image> image, const Matrix& matrix, const Paint* paint = nullptr);
void drawGlyphs(const GlyphID glyphIDs[], const Point positions[], size_t glyphCount,
const Font& font, const Paint& paint);
};
// CanvasState记录当前画布的状态,包括Alph、blend模式、变化矩阵等
struct CanvasState {
float alpha = 1.0f;
BlendMode blendMode = BlendMode::SrcOver;
Matrix matrix = Matrix::I();
Path clip = {};
uint32_t clipID = kDefaultClipID;
};

// 通过save及restore实现绘制状态的切换
void Canvas::save() {
auto canvasState = std::make_shared<CanvasState>();
*canvasState = *state;
savedStateList.push_back(canvasState);
}

void Canvas::restore() {
if (savedStateList.empty()) {
return;
}
state = savedStateList.back();
savedStateList.pop_back();
}

DrawOp执行


DrawOp负责实际的绘制逻辑,比如OpenGL着色器函数的创建装配、顶点及纹理数据的创建及绑定等


TGFX抽象了FillRectOp矩形绘制Op,可以覆盖绝大多数场景的绘制需求


当然,其还支持其它类型的绘制Op,比如ClearOp清屏、TriangulatingPathOp三角图形绘制Op等

class DrawOp : public Op {
// DrawOp通过Pipiline实现多个_colors纹理对象及_masks蒙版的绘制
std::vector<std::unique_ptr<FragmentProcessor>> _colors;
std::vector<std::unique_ptr<FragmentProcessor>> _masks;
};

// 矩形实际绘制执行者
class FillRectOp : public DrawOp {
FillRectOp(std::optional<Color> color, const Rect& rect, const Matrix& viewMatrix,
const Matrix& localMatrix);
void onPrepare(Gpu* gpu) override;
void onExecute(OpsRenderPass* opsRenderPass) override;
};

总结


本文结合OpenGL讲解了TGFX渲染引擎的大概框架结构,让各位有了一个初步认知


接下来将结合image纹理绘制介绍TGFX渲染引擎详细的绘制渲染流程,欢迎大家关注点赞!


作者:olinone
链接:https://juejin.cn/post/7239178749153312824
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

SwiftUI关于菜单 iOS 的长按 & macOS 右键的实现

iOS
长按 按钮或者图片出现菜单是个很平常的操作。 从app的icon 到app 内部的按钮 可以将内部的一些操作整合到这个特点内 SwiftUI 自带的菜单选择 ContextMenu 代码 iOS 效果 macOS 在mac上不是长按了,是右键的菜单操作 文案...
继续阅读 »

长按 按钮或者图片出现菜单是个很平常的操作。


从app的icon 到app 内部的按钮 可以将内部的一些操作整合到这个特点内


SwiftUI 自带的菜单选择 ContextMenu


代码




iOS 效果



macOS


在mac上不是长按了,是右键的菜单操作



文案可能要修改一下,应该叫 右键


这里有一个有趣的点,mac 版本的样式是没有图标。必须加一句

Button(action: { fileData.selectedFilesToOperate = [item] //单个  
fileWindow.isShowMoveFileView = true })
{ Label("移动", systemImage: "folder")
.labelStyle(.titleAndIcon)
}

但是现实的情况往往没有如此的简单,至少产品和老板的需求,都不是那么简单。下面几个我自己遇到的情况
可能不太全面,但是按图索骥应该可以给看遇到相似问题的人一点启发的感觉


问题1 菜单 不能太单调,分别来显示

Section {
Button1
Button2 ....
}

用section 包裹 可以让菜单有明显的分区



问题2 菜单里面放点别的


那再放开一点,,contextMenu 内部 放点别的

      contextMenu {
// picker
// list
// toggle
// image...
}



放入单选记得选什么的 Picker



放入子菜单


这里用到了 Menu 这个标签


这个表情 也是个菜单,点击就有,不用长按。


菜单里面放菜单的效果


Menu {

                            Picker(selection: $sort, label: Text("Sorting options")) {

                                Text("Size").tag(0)

                                Text("Date").tag(1)

                                Text("Location").tag(2)

                            }

                        } label: {

                            Label("Sort", systemImage: "arrow.up.arrow.down")

                        }

这个效果挺有意思,和mac 的右键的子菜单一个效果。



这个放一切UI的效果,确实比较有趣。有兴趣可以尝试放入更丰富的控件。


SwiftUI 的控件我个人感觉的套路

  1. 一切view 都是声明的方式,靠@State 或者@Publish 一些的Modify来控制控件的显示数据
  2. 因为没有了生命周期,对于onAppair 和DisAppair的控制放在了每一个控件上的@ViewBuilder上,这个可以自定义,开始的时候都用自带的 @ViewBuilder
  3. View 都是Struct,class用的不多。
  4. View 里面包View,尽量做到了控件复用。而且是挑明了就是,比如之前的Text里面label,Button里面的Label,NavigationLink里面的View(也可以一切不同类型的View)

个人感觉这些都是在表面SwiftUI 打破以前Swift UIKit或者是OC中的UIKit的思维逻辑。


既: UI廉价 刷新廉价


让程序员 特别是iOS 开发过程中,不同状态的刷新UI ,回调刷新UI的开发复杂度


总结


对于一个控件的开始编写,到不停叠加复杂的情况,还有许多场景还没遇到和想到。目前SwiftUI的源码和网上的资料,还不如OC 如此内核的解析资料丰富。但是未来的iOS开发 一定是SwiftUI的时代,特别是对于个人开发者相比OC 友好程度明显。


作者:我不是豆豆
链接:https://juejin.cn/post/7240347693461569591
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

超出范围自动滚动、支持彩色流动特效的UILabel封装

iOS
JKRShimmeringLabel 特征 支持炫彩字支持炫彩流动字支持超出显示范围自动滚动文本支持RTL下的对称显示和滚动支持Frame布局支持Xib和StoryBoard内使用支持AutoLayout布局 使用 源码连接 和原生UILabel一样用,只需...
继续阅读 »

JKRShimmeringLabel


特征





  1. 支持炫彩字

  2. 支持炫彩流动字

  3. 支持超出显示范围自动滚动文本

  4. 支持RTL下的对称显示和滚动

  5. 支持Frame布局

  6. 支持Xib和StoryBoard内使用

  7. 支持AutoLayout布局


使用


源码连接


和原生UILabel一样用,只需要设置mask属性(一张彩色的图片遮罩)即可。


原有项目的UILabel替换


因为JKRAutoScrollLabel和JKRShimmeringLabel本身就是继承UILabel,可以直接把原有项目的UILabel类,替换成JKRAutoScrollLabel或JKRShimmeringLabel即可。


JKRAutoScrollLabel


超出范围自动滚动的Lable,需要设置attributedText,不能设置text。要同时支持流动彩字,设置mask即可。不需要彩色可以不设置mask,只有自动滚动的特性。


// Frame布局,字体支持炫彩闪动,同时超出显示范围自动滚动

NSMutableAttributedString *textForFrameAttr = [[NSMutableAttributedString alloc] initWithString:@"我是滚动测试文本Frame布局,看看我的效果" attributes:@{NSForegroundColorAttributeName: UIColorHex(FFFFFF), NSFontAttributeName: [UIFont systemFontOfSize:19 weight:UIFontWeightBold]}];

self.autoScrollLabelForFrame = [[JKRAutoScrollLabel alloc] initWithFrame:CGRectMake(isRTL ? kScreenWidth - 10 - 300 : 10, CGRectGetMaxY(title0.frame) + 10, 300, 24)];

// 滚动文本需要设置 attributedText 才能生效

self.autoScrollLabelForFrame.attributedText = textForFrameAttr;

// 设置文字颜色的mask图片遮罩,如果不需要字体炫彩,不设置即可

self.autoScrollLabelForFrame.mask = [self maskImage];

[self.view addSubview:self.autoScrollLabelForFrame];


JKRShimmeringLabel


支持流动彩字,设置mask即可,如果还需要超出范围自动滚动,需要使用JKRAutoScrollLabel。


// Frame布局,字体支持炫彩闪动

self.shimmerLabelForFrame = [[JKRShimmeringLabel alloc] initWithFrame:CGRectMake(isRTL ? kScreenWidth - 10 - 300 : 10, CGRectGetMaxY(title1.frame) + 10, 300, 24)];

self.shimmerLabelForFrame.text = @"我是彩色不滚动文本Frame布局,看看我的效果";

self.shimmerLabelForFrame.font = [UIFont systemFontOfSize:19];

// 设置文字颜色的mask图片遮罩,如果不需要字体炫彩,不设置即可

self.shimmerLabelForFrame.mask = [self maskImage];

[self.view addSubview:self.shimmerLabelForFrame];


Xib使用


控件支持xib和autolayout的场景,和UILabel一样设置约束即可,自动滚动和彩色动画,会自动支持。只需要正常配置约束,然后设置mask彩色遮罩即可。


同时,因为JKRShimmeringLabel和JKRAutoScrollLabel本身就是继承UILabel的,所以UILabel在Xib中的文本自动填充宽度、约束优先级等等特性,也都可以正常使用。


作者:Whip78215
链接:https://juejin.cn/post/7220690079701434405
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

iOS 线程安全和锁机制

iOS
一、线程安全场景 多个线程中同时访问同一块资源,也就是资源共享。多牵扯到对同一块数据的读写操作,可能引发数据错乱问题。 比较经典的线程安全问题有购票和存钱取钱问题,为了说明读写操作引发的数据混乱问题,以存钱取钱问题来做个说明。 1. 购票案例 用代码示例如下...
继续阅读 »

一、线程安全场景


多个线程中同时访问同一块资源,也就是资源共享。多牵扯到对同一块数据的读写操作,可能引发数据错乱问题。


比较经典的线程安全问题有购票和存钱取钱问题,为了说明读写操作引发的数据混乱问题,以存钱取钱问题来做个说明。


1. 购票案例




用代码示例如下:

@IBAction func ticketSale() {

        tickets = 30

        let queue = DispatchQueue.global()

        queue.async {

            for _ in 0..<10 {

                self.sellTicket()

            }

        }

        queue.async {

            for _ in 0..<10 {

                self.sellTicket()

            }

        }

        queue.async {

            for _ in 0..<10 {

                self.sellTicket()

            }

        }

    }

    //卖票

    func sellTicket() {

        var oldTicket = tickets

        sleep(UInt32(0.2))

         oldTicket -= 1

         tickets = oldTicket

        print("还剩\(tickets)张票 ---- \(Thread.current)")

    }

同时有三条线程进行卖票,对余票进行减操作,三条线程每条卖10次,原定票数30,应该剩余票数为0,下面看下打印结果:




可以看到打印票数不为0


2. 存钱取钱案例


先用个图说明




上图可以看出,存钱和取钱之后的余额理论上应该是500,但由于存钱取钱同时访问并修改了余额,导致数据错乱,最终余额可能变成了400,下面用代码做一下验证说明:

//存钱取钱

    @IBAction func remainTest() {

        remain = 500

        let queue = DispatchQueue.global()

        queue.async {

            for _ in 0..<5 {

                self.saveMoney()

            }

        }

        queue.async {

            for _ in 0..<5 {

                self.drawMoney()

            }

        }

    }

    //存钱

    func saveMoney() {

       var oldRemain = remain

        sleep(2)

        oldRemain += 100

        remain = oldRemain

        print("存款100元,账户余额还剩\(remain)元 ----\(Thread.current)")

    }

    

    //取钱

    func drawMoney() {

        var oldRemain = remain

         sleep(2)

         oldRemain -= 50

         remain = oldRemain

        print("取款50元,账户余额还剩\(remain)元 ---- \(Thread.current)")

    }

上述代码存款5次100,取款5次50,最终的余额应该是 500 + 5 * 100 - 5 * 50 = 750




如图所示,可以看到在存款取款之间已经出现错乱了



上述两个案例之所以出现数据错乱问题,就是因为有多个线程同时操作了同一资源,导致数据不安全而出现的。



那么遇到这个问题该怎么解决呢?自然而然的,我们想到了对资源进行加锁处理,以此来保证线程安全,在同一时间,只允许一条线程访问资源。


加锁的方式大概有以下几种:

  • OSSpinLock
  • os_unfair_lock
  • pthread_mutex
  • dispatch_semaphore
  • dispatch_queue(DISPATCH_QUEUE_SERIAL)
  • NSLock
  • NSRecursiveLock
  • NSCondition
  • NSConditionLock

1. OSSpinLock 自旋锁




OSSpinLock 是自旋锁,在系统框架 libkern/OSAtomic




如图,系统提供了以下几个API

  • 定义lock let osspinlock = OSSpinLock()
  • OSSpinLockTry

官方给定的解释如下

Locks a spinlock if it would not block
return false, if the lock was already held by another thread,
return true, if it took the lock successfully.


尝试加锁,加锁成功则继续,加锁失败则直接返回,不会阻塞线程

  • OSSpinLockLock
Although the lock operation spins, it employs various strategies to back

off if the lock is held.

加锁成功则继续,加锁失败,则会阻塞线程,处于忙等状态

  • OSSpinLockUnlock: 解锁

使用
@IBAction func ticketSale() {

        osspinlock = OSSpinLock()

        tickets = 30

        let queue = DispatchQueue.global()

        queue.async {

            for _ in 0..<10 {

                self.sellTicket()

            }

        }

        queue.async {

            for _ in 0..<10 {

                self.sellTicket()

            }

        }

        queue.async {

            for _ in 0..<10 {

                self.sellTicket()

            }

        }

    }

    //卖票

    func sellTicket() {

        OSSpinLockLock(&osspinlock)

        var oldTicket = tickets

        sleep(UInt32(0.2))

         oldTicket -= 1

         tickets = oldTicket

        print("还剩\(tickets)张票 ---- \(Thread.current)")

        OSSpinLockUnlock(&osspinlock)

    }



可以看到,最终的余票数量已经是正确的了,这里要注意的是osspinlock需要做成全局变量或者属性,多个线程要用这同一把锁去加锁和解锁,如果每个线程各自生成锁,则达不到要加锁的目的了


那么自旋锁是怎么样做到加锁保证线程安全的呢?
先来介绍下让线程阻塞的两种方法:

  • 忙等:也就是自旋锁的原理,它本质上就是个while循环,不停地去判断加锁条件,自旋锁没有让线程真正的阻塞,只是将线程处在while循环中,系统CPU还是会不停地分配资源来处理while循环指令。
  • 真正阻塞线程: 这是让线程休眠,类似于Runloop里的match_msg() 实现的效果,它借助系统内核指令,让线程真正停下来处于休眠状态,系统的CPU不再分配资源给线程,也不会再执行任何指令。系统内核用的是symcall指令来让线程进入休眠

它的原理就是,自旋锁在加锁失败时,让线程处于忙等状态,让线程停留在临界区之外,一旦加锁成功,就可以进入临界区对资源进行操作。




通过这个可以看到,苹果在iOS10之后就弃用了OSSpinLock,官方建议用 os_unfair_lock来代替,那么为什么要弃用呢?因为在iOS10之后线程可以设置优先级,在优先级配置下,可以产生优先级反转,使自旋锁卡住,自旋锁本身已经不再安全。


2. os_unfair_lock


os_unfair_lock 是苹果官方推荐的,自iOS10之后用来替代 OSSpinLock 的一种锁

  • os_unfair_lock_trylock: 尝试加锁,加锁成功返回true,继续执行。加锁失败,则返回false,不会阻塞线程。
  • os_unfair_lock_lock: 加锁,加锁失败,阻塞线程继续等待。加锁成功,继续执行。
  • os_unfair_lock_unlock : 解锁

使用:

//卖票

    func sellTicket() {

        os_unfair_lock_lock(&unfairlock)

        var oldTicket = tickets

        sleep(UInt32(0.2))

         oldTicket -= 1

         tickets = oldTicket

        print("还剩\(tickets)张票 ---- \(Thread.current)")

        os_unfair_lock_unlock(&unfairlock)

    }

打印结果和OSSpinLock一样,os_unfair_lock摒弃了OSSpinLock的while循环实现的忙等状态,而是采用了真正让线程休眠,从而避免了优先级反转问题。


3. pthread_mutex


pthread_mutexpthread跨平台的一种解决方案,mutex 为互斥锁,等待锁的线程会处于休眠状态。
互斥锁的初始化比较麻烦,主要为以下方式:

  1. var ticketMutexLock = pthread_mutex_t()
  2. 初始化属性:
var attr = pthread_mutexattr_t()
pthread_mutexattr_init(&attr)
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL)

3. 初始化锁:pthread_mutex_init(&ticketMutexLock, &attr)

关于互斥锁的使用,主要提供了以下方法:

  1. 尝试加锁:pthread_mutex_trylock(&ticketMutexLock)
  2. 加锁:pthread_mutex_lock(&ticketMutexLock)
  3. 解锁:pthread_mutex_unlock(&ticketMutexLock)
  4. 销毁相关资源:pthread_mutexattr_destory(&attr)pthread_mutex_destory(&ticketMutexLock)

使用方式如下:




要注意,在析构函数中要将锁进行销毁释放掉
在初始化属性中,第二个参数有以下几种方式:




PTHREAD_MUTEX_DEFAULT = PTHREAD_MUTEX_NORMAL,代表普通的互斥锁
PTHREAD_MUTEX_ERRORCHECK 代表检查错误锁
PTHREAD_MUTEX_RECURSIVE 代表递归互斥锁


互斥锁的底层原理实现也是通过阻塞线程,等待锁的线程处于休眠状态,CPU不再给等待的线程分配资源,和上面讲到的os_unfair_lock原理类似,都是通过内核调用symcall方法来休眠线程,通过这个对比也能推测出,os_unfair_lock实际上也可以归属于互斥锁


3.1 递归互斥锁



如图所示,如果是上述场景,方法1里面嵌套方法2,正常调用时,输出应该为:




若要对上述场景保证线程安全,先用普通互斥锁添加锁试下




结果打印如下:




和预想中的不一样,如果懂得锁机制便会明白,图中所示的rsmText2中加锁失败,需要等待rsmText1中的锁释放后才可加锁,所以rsmText2方法开始等待并阻塞线程,程序无法再执行下去,那么rsmText1中锁释放的逻辑就无法执行,就这样造成了死锁,所以只能打印rsmText1中的输出内容。
解决这个问题,只需要给两个方法用两个不同的锁对象进行加锁就可以了,但是如果是针对于同一个方法递归调用,那么就无法通过不同的对象去加锁,这时候应该怎么办呢?递归互斥锁就该用上了。








如上,已经可以正常调用并加锁
那么递归锁是如何避免死锁的呢?简而言之就是允许对同一个对象进行重复加锁,重复解锁,加锁和解锁的次数相等,调用结束时所有的锁都会被解开


3.2 互斥锁条件 pthread_cond_t

互斥锁条件所用到的常见方法如下:

  1. 定义一个锁: var condMutexLock = pthread_mutex_t()
  2. 初始化锁对象:pthread_mutex_init(&condMutexLock)
  3. 定义条件对象:var condMutex = pthread_cond_t()
  4. 初始化条件对象:pthread_cond_init(&condMutex, nil)
  5. 等待条件:pthread_cond_wait(&condMutex, &condMutexLock) 等待过程中会阻塞线程,知道有激活条件的信号发出,才会继续执行
  6. 激活一个等待该条件的线程:pthread_cond_signal(&condMutex)
  7. 激活所有等待该条件的线程pthread_cond_broadcast(&condMutex)
  8. 解锁:pthread_mutex_unlock(&condMutexLock)
  9. 销毁锁对象和销毁条件对象:pthread_mutex_destroy(&condMutexLock) pthread_cond_destroy(&condMutex)

下面设计一个场景:

  • 在一个线程里对dataArr做remove操作,另一个线程里做add操作
  • dataArr为0时,不能进行删除操作
@IBAction func mutexCondTest(_ sender: Any) {

        initMutextCond()

    }

    func initMutextCond() {

        //初始化属性

        var attr = pthread_mutexattr_t()

        pthread_mutexattr_init(&attr)

        pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL)

        //初始化锁

        pthread_mutex_init(&condMutexLock, &attr)

        //释放属性

        pthread_mutexattr_destroy(&attr)

        //初始化cond

        pthread_cond_init(&condMutex, nil)

        _testDataArr()

        

    }

    func _testDataArr() {

        let threadRemove = Thread(target: self, selector: #selector(_remove), object: nil)

        threadRemove.name = "remove 线程"

        threadRemove.start()

        

        sleep(UInt32(1))

        let threadAdd = Thread(target: self, selector: #selector(_add), object: nil)

        threadAdd.name = "add 线程"

        threadAdd.start()

        

    }

    @objc func _add() {

        //加锁

        pthread_mutex_lock(&condMutexLock)

        print("add 加锁成功---->\(Thread.current.name!)开始")

        sleep(UInt32(2))

        dataArr.append("test")

        print("add成功,发送条件信号 ------ 数组元素个数为\(dataArr.count)")

        pthread_cond_signal(&condMutex)

        //解锁

        pthread_mutex_unlock(&condMutexLock)

        print("解锁成功,\(Thread.current.name!)线程结束")

    }

    @objc func _remove() {

        //加锁

        pthread_mutex_lock(&condMutexLock)

        print("remove 加锁成功,\(Thread.current.name!)线程开启")

        if(dataArr.count == 0) {

            print("数组内没有元素,开始等待,数组元素为\(dataArr.count)")

            pthread_cond_wait(&condMutex, &condMutexLock)

            print("接收到条件更新信号,dataArr元素个数为\(dataArr.count),继续向下执行")

        }

        dataArr.removeLast()

        print("remove成功,dataArr数组元素个数为\(dataArr.count)")

        

        //解锁

        pthread_mutex_unlock(&condMutexLock)

        print("remove解锁成功,\(Thread.current.name!)线程结束")

    }

    

    deinit {

//        pthread_mutex_destroy(&ticketMutexLock)

        pthread_mutex_destroy(&condMutexLock)

        pthread_cond_destroy(&condMutex)

    }

输出结果为:




从打印结果来看,如果不满足条件时进行条件等待 pthread_cond_wati,remove 线程是解锁,此时线程是休眠状态,然后等待的add 线程进行加锁成功,处理add的逻辑。


当add 操作完毕时,通过 pthread_cond_signal发出信号,remove线程收到信号后被唤醒,然后remove线程会等待add线程解锁后,再进行加锁处理后续的逻辑.


整个过程中一共用到了三次加锁,三次解锁,这种锁可以处理线程依赖的场景.


4. NSLock, NSRecursiveLock, NSCondition


上文中提到了mutex普通互斥锁 mutex递归互斥锁mutext条件互斥锁,这几种锁都是基于C语言的API,苹果在此基础上做了面向对象的封装,分别对应如下:

  • NSLock 封装了 pthread_mutex_t的 attr类型为 PTHREAD_MUTEX_DEFAULT 或者 PTHREAD_MUTEX_NORMAL 普通锁
  • NSRecursiveLock 封装了 pthread_mutex 的 attr类型为PTHREAD_MUTEX_RECURSIVE递归锁
  • NSCondition 封装了 pthread_mutex_t 和 pthread_cond_t

底层实现和 pthread_mutex_t一样,这里只看下使用方式即可:


4.1 NSLock
//普通锁 
let lock = NSLock()
lock.lock()
lock.unlock()

4.2 NSRecursiveLock
let lock = NSRecursiveLock()
lock.lock()
lock.unlock()

4.3 NSCondition
let condition = NSCondition()
condition.lock()
condition.wait()
condition.signal()//condition.broadcast()
condition.unlock()

4.4 NSConditionLock

这个是NSCondition 的进一步封装,该锁允许我们在锁中设定条件具体条件值,有了这个功能,我们可以更加方便的多条线程的依赖关系和前后执行顺序


下面用一个场景来模拟下顺序控制的功能,有三条线程执行A,B,C三个方法,要求按A,C,B的顺序执行

@IBAction func conditionLockTest(_ sender: Any) {

       let threadA = Thread(target: self, selector: #selector(A), object: nil)

        threadA.name = "ThreadA"

        threadA.start()

       let threadB = Thread(target: self, selector: #selector(B), object: nil)

        threadB.name = "ThreadB"

        threadB.start()

       let threadC = Thread(target: self, selector: #selector(C), object: nil)

        threadC.name = "ThreadC"

        threadC.start()

    }

    @objc func A() {

        conditionLock.lock()

        print("A")

        sleep(UInt32(1))

        conditionLock.unlock(withCondition: 3)

    }

    @objc func B() {

        conditionLock.lock(whenCondition: 2)

        print("B")

        sleep(UInt32(1))

        conditionLock.unlock()

    }

    @objc func C() {

        conditionLock.lock(whenCondition: 3)

        print("C")

        conditionLock.unlock(withCondition: 2)

    }

输出结果为:

A

C

B

5. dispatch_semaphore


信号量 的初始值可以用来控制线程并发访问的最大数量,初始值为1,表示同时允许一条线程访问资源,这样可以达到线程同步的目的

  • 创建信号量:dispatch_semaphore_create(value)

  • 等待:dispatch_semaphore_wait(semaphore, 等待时间) 信号量的值 <= 0,线程就休眠等待,直到信号量 > 0,如果信号量的值 > 0,则就将信号量的值递减1,继续执行下面的程序

  • 信号量值+1: dispatch_semaphore_signal(semaphore)


作者:君陌笑
链接:https://juejin.cn/post/7263350491610726460
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

【iOS】高效调试 iOS APP 的 UI

iOS
调试是程序是开发过程中必不可少的环节,每当我们完成一段代码或者发现一些问题都需要对程序进行调试。高效的调试能帮我们节省大量的开发时间。这篇文章我将分享一些提高调试效率的工具和它们的使用方法。 在开发iOS APP的时候我们最频繁进行调试的莫过于UI了。 一、U...
继续阅读 »

调试是程序是开发过程中必不可少的环节,每当我们完成一段代码或者发现一些问题都需要对程序进行调试。高效的调试能帮我们节省大量的开发时间。这篇文章我将分享一些提高调试效率的工具和它们的使用方法。


在开发iOS APP的时候我们最频繁进行调试的莫过于UI了。


一、UI的调试


开发中我们经常需要多次修改UI元素的样式进行微调,查看效果并确定正确的数值。

Xcode

如下图所示,Xcode 提供了完备的UI调试工具。




在左边,我们可以看到完整对视图树,中间有各个视图对3D拆分展示,右边,可以看到当前选中的视图的一些信息。


Xcode在进行UI调试的时候,会暂停APP,视图的信息也只能查看不能方便的修改。在UI调试的时候需要修改代码然后重新编译运行才能看到最终的效果。


在频繁调试UI样式的时候是很耗费时间的(如果电脑性能非常好可能会耗费的时间可能会短一些)所以这不是最佳的选择。

LookIn

在这里向大家介绍一款视图调试工具Lookin,它是由腾讯的QMUI团队开发并开源的一款免费的UI调试工具。


有了它,我们就能进行高效的UI调试。


使用方法也非常简单,具体可以查看官方的集成指导


接下来我将分几点简单的介绍一下这个工具的强大功能。

查看与修改UI

Lookin 可以查看与修改 iOS App 里的 UI 对象,类似于 Xcode 自带的 UI Inspector 工具,不需要重新编译运行。而且借助于“控制台”和“方法监听”功能,Lookin 还可以进行 UI 之外的调试。



独立运行
此外,虽然 Lookin 主体是一款 macOS 程序,它亦可嵌入你的 iOS App 而单独运行在 iPhone 或 iPad 上。



显示变量名
Lookin 会显示变量名,以及 indexPath 等各种提示。



显示手势
添加了手势的 UIView,或添加了 Target-Action 的 UIControl,左侧都会被加上一个小蓝条,点击即可查看信息或调试手势



测距
按住 Option 键,即可测量任意两个 view 之间的距离



导出文件

通过手机或电脑将当前 iOS App 的 UI 结构导出为 Lookin 文件以备之后查看,或直接转发给别人。
当测试发现BUG时可以完美对固定现场,并可以将文件发送给开发者查看当时的视图结构。


二、热重载


💉Injection III


Lookin已经帮我们解决了很多问题,但当我们修改了代码的业务逻辑,或者修改了UI的加载逻辑,或者对代码进行了比较大的改动,此时还是需要重新编译运行才能使新的代码生效。同样会耗费许多时间编译、重新运行、点击屏幕到达刚才修改的页面的时间。


这个时候就是我们的第二款高效开发的得力助手登场的时候了。


它就是 💉 Injection III,一款开源免费的热重载工具。


Injection III 是一款能在iOS开发时实现类似Web前端那样热重载的工具。他会监听代码文件的变化,当代码发生改变,他会将改变的部分自动编译成一个动态链接库,然后动态的加载到程序中,达到不重启APP直接热重载的目的。


下面我简单介绍一下如何使用它。


我们可以在 Mac App Store 上下载InjectionIII。打开后会在状态栏有一个蓝色的注射器图标,选择Open Project 打开工程所在目录开始监听我们的文件更改。




接下来在工程中进行一些配置,


Xcodebuild settingOther Linker Flags 中添加-Xlinker -interposable


AppDelegateapplicationDidFinishLaunching方法中加入如下代码:

#if DEBUG
//for iOS:
Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/iOSInjection.bundle")?.load()
//for tvOS:
Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/tvOSInjection.bundle")?.load()
//Or for macOS:
Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/macOSInjection.bundle")?.load()
#endif

接下来,编译运行你的APP,此时控制台会打印Injection的相关信息




同时状态栏的图标也会变为红色。此时说明Injection启动成功。


接下来你就可以修改一下代码,并保存,Injection会自动编译并自动将其注入到模拟器中运行的APP。控制台也会打印相关的信息。




同时,它会为被注入的类提供一个回调@objc func injected() ,当某个类被注入时,会调用该方法。


我们可以在这里刷新UI,就能做到实时更新UI了。


注意事项


虽然Injection很强大,但它也有很多限制:

  • 你可以修改class、enum、struct 的成员方法的实现,但如果是inline函数则不行,如果有这种情况需要重新编译运行。

  • 它只支持模拟器,不支持真机。

  • 你不能修改class、enum、struct的布局(即成员变量和方法的顺序),如果有这种情况需要重新编译运行。

  • 你不能增加或删除存储类型的属性和方法,如果有这种情况需要重新编译运行。


更多详情可以参见官方的说明:InjectionIII/README.md at main · johnno1962/InjectionIII (github.com)


虽然 Injection III 有很多限制,但它依然能为我们带来非常大的效率提升。


另一个热重载神器: krzysztofzablocki/Inject


krzysztofzablocki/Inject: Hot Reloading for Swift applications! (github.com)


它配合 Injection III 可以更方便的实现热重载和界面自动刷新,实现类似Swift UI的自动刷新效果,但是,它只支持Swift,并且通过Swift Package Manager进行安装。


三、写在最后


实用的工具很多,找到一款既强大又好用的工具,并且把它用好能够很大的提升我们开发的效率。


希望大家能喜欢我分享的这两款工具,希望它们能为大家带来效率的提升。


作者:BlueSky335
链接:https://juejin.cn/post/7150869046190145544
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

iOS UITableView 图片刷新闪烁问题记录

iOS
一. 问题背景 项目中遇到一个问题,就是当App不在首页的时候,切换到其他App比如微信,然后返回App当前页面,然后从当前页面返回首页,会在首页viewWillAppear这里去拉取是否有未完成订单的接口,刷新UITableView,这时会出现广告位闪烁问题...
继续阅读 »

一. 问题背景


项目中遇到一个问题,就是当App不在首页的时候,切换到其他App比如微信,然后返回App当前页面,然后从当前页面返回首页,会在首页viewWillAppear这里去拉取是否有未完成订单的接口,刷新UITableView,这时会出现广告位闪烁问题。




二. 问题排查


1.原因分析


这个问题经过断点调试和排除法,发现只要当App进入后台后,回来刷新首页的UITableView都有可能出现闪烁现象。


因此首先我们对图片的加载做延迟操作,并在Cell生成方法调用里面添加相关打印:






可以看到如下打印日志:




从打印日志我们可以看出来,调用reloadData方法后,原来UITableViewcell位置会调整。


但是如果我们App没有进入后台,而是直接调用UITableViewreloadData方法,并不会出现闪烁现象。


因此可以这里可以推测应该是进入后台做了什么操作导致,回到App刷新才会导致闪烁。


因为使用的是SDWebImage加载框架加载,我们合理的怀疑是加载图片的SDWebImage框架,进入后台的处理逻辑导致的,因此我们先使用imageCacheDict字典写下图片加载和缓存逻辑:




经测试,进入后台,再返回App刷新不会出现闪烁现象。


因此可以肯定UITableView调用reloadData方法闪烁原因是SDWebImage,在进入后台的时候对内存缓存做了相关操作导致。


我们都知道SDWebImage,默认是使用NSCache来做内存缓存,而NSCache在进入后台的时候,默认会清空缓存操作,导致返回App调用UITableView调用reloadData方法时候,SDWebImage需要根据图片地址重新去磁盘获取图像数据,然后解压解码渲染,因为是从缓存磁盘直接获取图像数据,没有渲染流程,因此会造成闪烁。


为了验证这个猜想,我们使用YYWebImage加载框架来做对比实验:

首先注释掉YYWebImage进入后台清空内存缓存的逻辑: 


然后进入后台,返回App调用UITableView调用reloadData刷新,发现一切正常。

原因总结:

  • 第一个原因是UITableView调用reloadData方法,由于UITableViewCell的复用,会出现Cell位置调整现象

  • 由于SDWebImage使用了NSCache做内存缓存,当App进入后台,NSCache会清空内存缓存,导致返回App后调用UITableView调用reloadData,刷新去加载图片的时候,需要从SDWebImage的磁盘中重新获取图片数据,然后重新解压解码渲染,因为从磁盘中读取速度快,两者原因导致了闪烁。


三. 解决方案


因为该现象是由如上两个原因导致,因此针对这两个原因,有如下两种解决方案:

1. 解决UITableViewCell复用问题


可以通过设置ReusableCellWithIdentifier不同,保证广告cell不进行复用。

 NSString *cellId = [NSString stringWithFormat:@"%ld-%ld-FJFAdTableViewCell", indexPath.section, indexPath.row];

2. 从后台返回后,提早进行刷新操作

当从后台返回App前台的时候或者视图添加到父视图的时候,先执行下UITableView调用reloadData方法,提前通过SDWebImage去从磁盘中加载图片。


从后台返回前台:

[NSNotificationCenter.defaultCenter addObserver:self selector:@selector(willEnterForeground) name:UIApplicationWillEnterForegroundNotification object:nil];
- (void)willEnterForeground {
[self.tableView reloadData];
NSLog(@"--------------------------willEnterForeground");
}

视图添加到父视图:

- (void)willMoveToParentViewController:(UIViewController *)parent {
[self.tableView reloadData];
}

作者:果哥爸
链接:https://juejin.cn/post/7267091810367111224
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

RunLoop:iOS开发中的神器,你真的了解它吗?

iOS
在iOS开发中,RunLoop是一个非常重要的概念,它提供了一个事件循环机制,用于处理各种事件,例如用户交互、网络请求、定时器等等。RunLoop不仅是iOS开发中的核心之一,而且在其他平台的开发中也有广泛的应用。本文将为您介绍Swift中RunLoop的基本...
继续阅读 »

iOS开发中,RunLoop是一个非常重要的概念,它提供了一个事件循环机制,用于处理各种事件,例如用户交互、网络请求、定时器等等。RunLoop不仅是iOS开发中的核心之一,而且在其他平台的开发中也有广泛的应用。本文将为您介绍SwiftRunLoop的基本概念和使用方法。


什么是RunLoop?


RunLoop是一个事件循环机制,它用于在iOS应用程序中处理各种事件。RunLoop在应用程序的主线程中运行,它负责管理该线程中的事件,并确保UI更新等重要任务能够顺利执行。RunLoop还负责处理其他线程发送的事件,例如网络请求等等。


RunLoop的基本思想是循环地处理事件。当RunLoop启动时,它会进入一个无限循环,等待事件的发生。当有事件发生时,RunLoop会调用相应的处理方法来处理该事件,并继续等待下一个事件的发生。RunLoop会一直运行,直到被手动停止或应用程序退出。


RunLoop与线程


iOS中,每个线程都有一个RunLoop,但默认情况下,RunLoop是被禁用的。要使用RunLoop,必须手动启动它,并将其添加到线程的运行循环中。


例如,要在主线程中使用RunLoop,可以使用如下代码:

RunLoop.current.run()

这将启动主线程的RunLoop,并进入一个无限循环,等待事件的发生。


RunLoop模式


RunLoop模式是RunLoop的一个重要概念,它定义了RunLoop在运行过程中需要处理的事件类型。一个RunLoop可以有多个模式,但在任何时刻只能处理一个模式。每个模式都可以包含多个输入源(input source)和定时器(timer)RunLoop会根据当前模式中的输入源和定时器来决定下一个事件的处理方式。


RunLoop提供了几个内置模式,例如:

  1. NSDefaultRunLoopMode:默认模式,处理所有UI事件、定时器和PerformSelector方法。
  2. UITrackingRunLoopMode:跟踪模式,只处理与界面跟踪相关的事件,例如UIScrollView的滚动事件。
  3. NSRunLoopCommonModes:公共模式,同时包含NSDefaultRunLoopModeUITrackingRunLoopMode。 RunLoop还允许开发者自定义模式,以满足特定需求。

定时器


iOS开发中,定时器是一种常见的事件,例如每隔一段时间刷新UI、执行后台任务等等。RunLoop提供了定时器(timer)机制,用于在指定时间间隔内执行某个操作。


例如,要在主线程中创建一个定时器并启动它,可以使用如下代码:

let timer = Timer(timeInterval: 1.0, repeats: true) { timer in // 定时器触发时执行的操作 } RunLoop.current.add(timer, forMode: .common)

这将创建一个每隔1秒钟触发一次的定时器,并在公共模式下添加到主线程的RunLoop中。


在添加定时器时,需要指定它所属的RunLoop模式。如果不指定模式,则默认为NSDefaultRunLoopMode。如果需要在多个模式下都能响应定时器事件,可以使用NSRunLoopCommonModes


输入源


输入源(input source)是一种与RunLoop一起使用的机制,用于处理异步事件,例如网络请求、文件读写等等。RunLoop在运行过程中,会检查当前模式下是否有输入源需要处理,如果有则会立即处理。


输入源可以是一个Port、Socket、CFFileDescriptor等等。要使用输入源,必须将其添加到RunLoop中,并设置回调函数来处理输入事件。


例如,要在主线程中使用输入源,可以使用如下代码:

let inputSource = InputSource()
inputSource.setEventHandler {
// 输入源触发时执行的操作
}
RunLoop.current.add(inputSource, forMode: .common)

这将创建一个输入源,并在公共模式下添加到主线程的RunLoop中。


Perform Selector


Perform Selector是一种调用方法的方式,可以在RunLoop中异步执行某个方法。在调用方法时,可以设置延迟执行时间和RunLoop模式。该方法会在指定的时间间隔内执行,直到被取消。


例如,要在主线程中使用Perform Selector,可以使用如下代码:

RunLoop.current.perform(#selector(doSomething), target: self, argument: nil, order: 0, modes: [.default])

这将在默认模式下异步执行doSomething方法。


RunLoop的常用操作


除了上述基本操作之外,RunLoop还提供了其他常用操作,例如:

  1. stop:停止RunLoop的运行。
  2. runUntilDate:运行RunLoop直到指定日期。
  3. runMode:运行RunLoop指定模式下的事件处理循环。
  4. currentMode:获取当前RunLoop的运行模式。

RunLoop与线程安全


iOS开发中,多线程是一个常见的问题。RunLoop在处理异步事件时,可能会导致线程不安全的问题。为了保证RunLoop的线程安全,可以使用以下方法:

  1. 使用RunLoopQueue,在队列中使用RunLoop来执行异步操作。
  2. 在主线程中使用RunLoop来处理异步事件,避免跨线程操作。

结论


RunLoopiOS开发中非常重要的一个概念,它提供了一种事件循环机制,用于处理各种事件。RunLoop的基本思想是循环地处理事件,当有事件发生时,RunLoop会调用相应的处理函数来处理事件。RunLoop还提供了定时器、输入源、Perform Selector等机制来处理异步事件。了解RunLoop的工作原理,可以帮助我们更好地理解iOS应用的运行机制,避免出现一些奇怪的问题。


最后,我们再来看一下RunLoop的一些注意事项:

  1. 不要在主线程中阻塞RunLoop,否则会导致UI卡顿。
  2. 避免使用RunLoopNSDefaultRunLoopMode模式,因为这个模式下会处理大量UI事件,可能会导致其他事件无法及时处理。
  3. 在使用RunLoop的过程中,需要注意线程安全问题。

RunLoop是一种事件循环机制,通过它,我们可以很方便地处理各种事件,避免出现一些奇怪的问题。在日常开发中,我们需要经常使用RunLoop,所以建议大家多多练习,掌握RunLoop的各种用法。


作者:远方662
链接:https://juejin.cn/post/7202843472676044855
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

iOS时钟翻转动画

iOS
最近项目有个时间倒计时的功能,网上也有很多优秀的第三方。这个功能应用还是比较广泛的,就稍微研究了一下。有好几种方法实现,笔者选取较简单一种。 原理其实很简单,就是一个的绕X轴的翻转动画,只是在翻转过程中针对特殊情况做 特殊处理就行,下面也会讲到。 效果图 思...
继续阅读 »

最近项目有个时间倒计时的功能,网上也有很多优秀的第三方。这个功能应用还是比较广泛的,就稍微研究了一下。有好几种方法实现,笔者选取较简单一种。


原理其实很简单,就是一个的绕X轴的翻转动画,只是在翻转过程中针对特殊情况做 特殊处理就行,下面也会讲到。


效果图




思路


以一次完整动画为例,分步骤解析:


第一步:


新建3个UILable,分别是正在显示(currentLabel)、下一个显示(nextLabel)、做动画的(animationLabel)。


第二步:


首先在每次动画前给nextLabel设置默认的X轴起始角度翻转,这样处理是为了能够只显示上半部分,下半部分被隐藏(zPosition不改动的情况下),如下图,红色的是nextLabel,绿色的是currentLabel,灰色的是animationLabel




代码:

// 设置默认的X轴起始角度翻转,为了能够只显示上半部分,下半部分被隐藏(zPosition不改动的情况下)
func setupStartRotate() -> CATransform3D {
var transform = CATransform3DIdentity
transform.m34 = CGFLOAT_MIN
transform = CATransform3DRotate(transform, .pi*kStartRotate, -1, 0, 0)
return transform
}

第三步:


使用CADisplayLink做动画,笔者这里设置固定的刷新帧率为60(因为存在不同的刷新帧率设备),且动画执行时间0.5s,即每次刷新帧率时动画执行了2/60进度。


接下来使用CATransform3DRotateanimationLabel沿着X轴进行翻转动画,这时候我们会发现动画的进度超过一半时,会存在如下问题:




上图这个是倒计时 2 变为 1 的过程,且动画进度超过一半时的显示画面。我们换个角度看看:




可知在当前情况下,灰色的标签显示的是 2 的上部分的背面,但是应该显示的是 1 的下部分,这显示是有问题的。这么说有点拗口,简单来说就是一个物体在3D空间中沿X轴翻转大于90度时,我们看到的实际是物体的上下和前后均颠倒的二维平面,所以才会出现如此的不和谐。


所以解决这个问题,使动画更和谐流畅,我们需要物体翻转的动画在临界点翻转到90度时,即与屏幕垂直的时候,为了正确显示,即需要将动画的animationLabel同时沿着Y和Z轴翻转,并切换文字,将2切换成1。即:

if animateProgress >= 0.5 {
t = CATransform3DRotate(t, .pi, 0, 0, 1);
t = CATransform3DRotate(t, .pi, 0, 1, 0);
animationLabel.text = nextLabel.text
}else{
animationLabel.text = currentLabel.text
}

此时的过程就是 2 在翻转超过90时,将之沿着Y和Z轴翻转,并切换为1,看到的就是动图显示的过程了。


到这里一个完整的翻转动画就结束了,后面使用CADisplayLink定时重复上述动画就可以了。


后续也使用这个动画写了一个时间显示的和倒计时的demo,具体的代码在下面的链接,感兴趣的可以查阅指导下。


RCFoldAnimation


若存在什么不对的地方,欢迎指正!


作者:云层之上
链接:https://juejin.cn/post/7243973283372335164
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

从 Mac 远程登录到 iPhone

iOS
简介 平时在使用 Mac 的过程中,经常会使用终端输入命令来执行一些操作。在越狱开发的过程中,同样需要在 iOS 系统上输入一些命令来执行一些任务。那么如何才能在 iOS 系统上输入命令呢,在 iOS 上安装一个终端命令行工具,然后在 iPhone 那小小的屏...
继续阅读 »

简介


平时在使用 Mac 的过程中,经常会使用终端输入命令来执行一些操作。在越狱开发的过程中,同样需要在 iOS 系统上输入一些命令来执行一些任务。那么如何才能在 iOS 系统上输入命令呢,在 iOS 上安装一个终端命令行工具,然后在 iPhone 那小小的屏幕上用触摸屏输入命令吗?虽然说理论上和实际上都是可行的,但是通过手指触摸屏幕来输入命令的方式效率比较低,也不是很方便。这里还是推荐在 Mac 上远程登录到 iOS 系统,这样就可以使用 Mac 的键盘输入命令到 iOS 上去执行,更加方便,快捷。


SSL、openSSL、SSH、openSSH


SSL(Secure Sockets Layer)是一种用于在计算机网络上进行安全通信的协议。SSL 最初由 Netscape 开发,后来发展为 TLS(Transport Layer Security)。SSL/TLS 用于在客户端和服务器之间建立安全的加密连接,以保护敏感数据的传输,例如在网页浏览器和服务器之间的数据传输。


OpenSSL 是一个强大的、商业级的、功能齐全的开源工具包,它提供了一组库和命令行工具,用于处理 SSL/TLS 协议和加密算法,是 SSL 协议的一款开源实现工具。OpenSSL 可以用于创建和管理数字证书、实现安全传输和通信,以及进行加密和解密等操作。它不仅支持 SSL/TLS 协议,还支持多种加密算法和密码学功能。


SSH(Secure Shell)是一种用于安全远程登录和数据传输的网络协议。它为计算机之间的通信提供了加密和身份验证,以确保通信的机密性和完整性。SSH 使用公钥密码体制进行身份验证,并使用加密算法来保护数据的传输。


OpenSSH 是一个开源的 SSH 实现,它提供了 SSH 客户端和服务器的功能,用于安全远程登录、命令执行和文件传输。它包括客户端 ssh 和服务器 sshd、文件传输实用程序 scp 和 sftp 以及密钥生成工具 (ssh-keygen)、运行时密钥存储 (ssh-agent) 和许多支持程序。它是 Linux 和其他类 Unix 系统中最常见的 SSH 实现,也支持其他操作系统。


SSL 最早出现于 1994 年,用于 Web 浏览器和服务器之间的安全通信。OpenSSL 和 SSH 都起源于 1995 年,OpenSSL 是一个加密工具包,而 SSH 是用于安全远程登录和数据传输的协议。OpenSSH 是 SSH 协议的开源实现,起源于 1999 年,为 SSH 提供了广泛使用的实现。


OpenSSH 通常依赖于 OpenSSL。OpenSSH 使用 OpenSSL 库来实现加密和安全功能,包括加密通信、密钥生成、数字证书处理等。OpenSSL 提供了各种加密算法和密码学功能,使 OpenSSH 能够建立安全的 SSH 连接,并保护通信数据的机密性和完整性。在大多数情况下,安装 OpenSSH 时,系统会自动安装或链接到已经安装的 OpenSSL 库。这样,OpenSSH 就能够使用 OpenSSL 的功能来实现加密和安全性,而不必重新实现这些复杂的加密算法和协议。


因此,可以说 OpenSSH 依赖于 OpenSSL,OpenSSL 提供了 OpenSSH 所需的加密和安全功能,使得 OpenSSH 成为一种安全、可靠的远程登录和数据传输工具。这些安全协议和工具对于保护通信和数据安全至关重要。


实践


对以上名词概念有了基本的了解之后,我们可以进行实践操作。如果感觉还是迷迷糊糊也不要紧,实践起来就会感觉简单多了。主要是对 OpenSSH 这个开源库提供的常用命令的使用。Mac 系统自带了这个工具所以不需要进行配置,而 iOS 系统上默认是没有安装这个工具的,包括越狱之后的 iOS 也没有,所以需要先下载安装这个工具。


安装过程很简单,如下图所示,在 Cydia 上搜索 OpenSSH 下载并按照提示进行安装就好了。



安装好之后,就可以在 Mac 上远程登录到越狱 iOS 了。iOS 系统默认提供了两个用户,一个是 root 用户,是 iOS 中最高权限的用户,我们在逆向开发过程中基本都是使用这个用户。还有一个是 mobile 用户,是普通权限用户,iOS 平时进行 APP 安装,卸载基本都是使用这个用户,但是我们在逆向开发中很少或者基本不会使用到这个用户,这里有个了解就够了。


Cydia 首页有 OpenSSH 访问教程,这个文档详细的记载了如何从 Mac 远程登录到 iOS 设备上,并且也提供了修改默认密码的方法。建议英文不错的同学直接阅读这篇文档,不想看的就看我后面的介绍也可以。文档位置如下图所示



通过默认账号密码登录到 iPhone


ssh 提供了两种登录到服务器的方式,第一种是使用账号和密码。第二种是免密码登录。下面先介绍第一种

  1. 越狱 iPhone 在 Cydia 上安装 OpenSSH
  2. 确认 iPhone 和 Mac 电脑在同一个局域网下,在 Mac 打开终端,输入以下命令
    ssh root@iPhone的IP地址
    第一次连接会出现 Are you sure you want to continue connecting (yes/no/[fingerprint])? 提示,输入 yes 确认进行连接
  3. 输入默认的初始密码 alpine ,这里终端为了安全并不会显示密码的明文
  4. 之后就会看到终端切换到了 iPhone:~ root# 用户,代表成功登录到远程 iPhone 手机的 root 用户上了。这个时候,你在 Mac 终端输入的指令都会被发送到 iPhone 上,如下图 

     如果你觉得还不过瘾,可以输入 reboot 命令,体会一下远程操纵手机的快乐(重启之后,你可能需要重新越狱一下 iPhone 了😶)
  5. 输入 exit 退出登录

刚刚我们登录的是 root 用户。在 iOS 中,除了 root 用户,还有一个 mobile 用户。其中 root 用户是 iOS 中最高权限的用户。mobile 是普通权限用户,其实平时越狱调试过程中,很少会使用这个 mobile 用户,这里只是介绍一下。


能够成功登录 iPhone 之后,建议修改一下用户的默认密码,既然做逆向开发了,当然对安全也要注意一点。在登录 root 用户之后,输入:passwd 可以修改 root 用户的密码,输入 passwd mobile 可以修改 mobile 用户的密码。


通过免密码方式登录到 iPhone


OpenSSH 除了默认的账号密码登录的方式,还提供了免密码登录的方式。需要进一步完成一些配置才可以实现。服务器(在当前情况下,iPhone是服务器,Mac是客户端)的 ~/.ssh 目录下需要添加一个 authorized_keys 文件,里面记录可以免密登录的设备的公钥信息。当有客户端(Mac)登录的时候,服务器会查看 ~/.ssh/authorized_keys 文件中是否记录了当前登录的客户端的公钥信息,如果有就直接登录成功,没有就要求输入密码。所以我们要做的就是将 Mac 设备的公钥信息追加到 iPhone 的 authorized_keys 文件内容的最后面。追加是为了不影响其他的设备。完成这个操作需要先确保我们的 Mac 设备上已经有 ssh 生成的公钥文件。


打开 Mac 终端,输入 ls ~/.ssh 查看是否已经存在 id_rsa.pub 公钥文件,.pub就是公钥文件的后缀




如果没有看到公钥文件,需要使用 ssh-keygen 命令生成该文件。按回车键接受默认选项,或者根据需要输入新的文件名和密码。这将生成一个公钥文件(id_rsa.pub)和一个私钥文件(id_rsa)。


使用 SSH 复制公钥到远程服务器。使用以下命令将本地计算机(Mac)上的公钥复制到远程服务器(iPhone)。请将user替换为您的远程服务器用户名,以及remote_server替换为服务器的域名或IP地址。

ssh-copy-id user@remote_server



在远程服务器(iPhone)上设置正确的权限。确保远程服务器上的~/.ssh文件夹权限设置为 700,并将~/.ssh/authorized_keys文件的权限设置为 600。这样可以确保SSH可以正确识别公钥并允许免密码登录。如下图所示:




.ssh 文件夹前面的 drwx------ 是 Linux 和类 Unix 系统中表示文件或目录权限的一种格式。在这个格式中,每一组由10个字符组成,代表文件或目录的不同权限。让我们逐个解释这些字符的含义:




所以,drwx------ 表示这是一个目录,并且具有以下权限:

  • 文件所有者具有读、写和执行权限。
  • 文件所有者所在组没有任何权限。
  • 其他用户没有任何权限。

后面 9 个字符分为三组,每组从左至右如果有对应的权限就是421相加起来就是 7 后面都是0。所以 .ssh 文件夹的权限是正确的值 700,如果不是 700 的使用 chmod 700 .ssh 进行提权。authorized_keys 文件的权限是 rw 就是 420 相加起来就是 6 。后面都是 0,所以 authorized_keys 的权限也是正确的值 600。同样如果不是 600,使用 chmod 600 authorized_keys 命令修改权限。


配置完成后,您现在可以使用 SSH 免密码登录到远程服务器(iPhone)。在 Mac 上,使用以下命令连接到远程服务器:

ssh root@10.10.20.155

这将直接连接到远程服务器,而无需输入密码。




通过 USB 有线的方式登录到 iPhone


配置为免密码登录之后,还可以进一步使用 USB 有线连接的方式登录到手机。如果你经常使用 WiFi 这种方式远程登录调试就会发现偶尔会碰到指令输入,响应卡顿,反应慢的情况,这样的体验显然让人感到不爽。所以,在大部分情况下,更推荐使用 USB 有线连接登录到 iPhone 上,这样使用的过程中,就像在本地输入命令操作一样流畅。


iproxy 是一个用于端口转发的命令行工具。它通常用于在 iOS 设备和计算机之间建立端口映射,从而将 iOS 设备上运行的服务暴露到计算机上。这对于开发者来说非常有用,因为可以通过本地计算机访问 iOS 设备上运行的服务,而无需将服务部署到公共网络上。


iproxyusbmuxd 的一部分,后者是一个用于连接和管理 iOS 设备的 USB 通信的守护进程。usbmuxd 允许通过 USB 连接与 iOS 设备进行通信,并且iproxy 则负责在本地计算机和iOS设备之间建立端口转发。


通常,您可以在命令行中使用 iproxy 命令来建立端口转发,例如:

iproxy local_port device_port

其中,local_port 是本地计算机上的端口号,device_port 是 iOS 设备上的端口号。执行此命令后,iOS 设备上的服务将通过 device_port 映射到本地计算机上的 local_port


请注意,使用 iproxy 需要先安装 libusbmuxd 包。在 macOS 上,您可以使用 Homebrew 来安装 libusbmuxd

brew install libusbmuxd

安装好之后,就可以使用 iproxy 命令了,使用 iproxy 将本机 10010 端口和 USB 设备的 22 端口进行映射的命令如下:

iproxy 10010 22



这里本机的端口 10010 可以设置为你想要的其他端口,但是不能是系统保留的端口(系统保留的端口有哪些,可以看百度的介绍)。端口转发设置完成之后,这个终端就不要关闭,也不要管它了,新建另一个端口进行 ssh 登录。此时,需要给 ssh 加上指定端口参数,命令如下:

ssh -p 10010 root@localhost

同样第一次使用这种方式建立连接会给出提示,输入 yes 确认




之后,在 iPhone 设备上输入命令调试时,再也不会遇到卡顿,慢,延迟的现象啦。玩得开心~


作者:franky
链接:https://juejin.cn/post/7262982452768145465
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

iOS - 人脸识别

iOS
前言 最近公司提出了一个有趣的新需求,需要开发一个功能来自动识别用户前置摄像头中的人脸,并且能够对其进行截图。 话不多说,直接开整...技术点:AVCaptureSession:访问和控制设备的摄像头,并捕获实时的视频流。Vision:提供了强大的人脸识别和分...
继续阅读 »

前言


最近公司提出了一个有趣的新需求,需要开发一个功能来自动识别用户前置摄像头中的人脸,并且能够对其进行截图。


话不多说,直接开整...

  • 技术点:
  • AVCaptureSession:访问和控制设备的摄像头,并捕获实时的视频流。
  • Vision:提供了强大的人脸识别和分析功能,能够快速准确地检测和识别人脸。

效果




开始


首先,工程中引入两个框架

import Vision
import AVFoundation

接下来,我们需要确保应用程序具有访问设备摄像头的权限。先判断是否拥有权限,如果没有权限我们通知用户去获取

let videoStatus = AVCaptureDevice.authorizationStatus(for: .video)
switch videoStatus {
case .authorized, .notDetermined:
print("有权限、开始我们的业务")
case .denied, .restricted:
print("没有权限、提醒用户去开启权限")
default:
break
}

然后,我们需要对摄像头进行配置,包括确认前后置摄像头、处理视频分辨率、设置视频稳定模式、输出图像方向以及设置视频数据输出


配置


确认前后置摄像头:


使用AVCaptureDevice类可以获取设备上的所有摄像头,并判断它们是前置摄像头还是后置摄像头

// 获取所有视频设备
let videoDevices = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .unspecified).devices

// 筛选前置摄像头和后置摄像头
var frontCamera: AVCaptureDevice?
var backCamera: AVCaptureDevice?

for device in videoDevices {
if device.position == .front {
frontCamera = device
} else if device.position == .back {
backCamera = device
}
}

// 根据需要选择前置或后置摄像头
let cameraDevice = frontCamera ?? backCamera

处理视频分辨率:


可以通过设置AVCaptureSession的sessionPreset属性来选择适合的视频分辨率。常见的分辨率选项包括.high、.medium、.low等。

let captureSession = AVCaptureSession()
captureSession.sessionPreset = .high


输出图像方向:


可以通过设置AVCaptureVideoOrientation来指定输出图像的方向。通常,我们需要根据设备方向和界面方向进行调整。

if let videoConnection = videoOutput.connection(with: .video) {
if videoConnection.isVideoOrientationSupported {
let currentDeviceOrientation = UIDevice.current.orientation
var videoOrientation: AVCaptureVideoOrientation

switch currentDeviceOrientation {
case .portrait:
videoOrientation = .portrait
case .landscapeRight:
videoOrientation = .landscapeLeft
case .landscapeLeft:
videoOrientation = .landscapeRight
case .portraitUpsideDown:
videoOrientation = .portraitUpsideDown
default:
videoOrientation = .portrait
}

videoConnection.videoOrientation = videoOrientation
}
}

视频数据输出:


可以使用AVCaptureVideoDataOutput来获取摄像头捕捉到的实时视频数据。首先,创建一个AVCaptureVideoDataOutput对象,并将其添加到AVCaptureSession中。然后,设置代理对象来接收视频数据回调。

let videoOutput = AVCaptureVideoDataOutput()
captureSession.addOutput(videoOutput)

let videoOutputQueue = DispatchQueue(label: "VideoOutputQueue")
videoOutput.setSampleBufferDelegate(self, queue: videoOutputQueue)

视频处理、人脸验证


接下来,我们将对视频进行处理,包括人脸验证和圈出人脸区域。我们将在AVCaptureVideoDataOutputSampleBufferDelegate 的代理方法中来实现这些功能

func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {

guard let bufferRef = CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}

let detectFaceRequest = VNDetectFaceRectanglesRequest()
let detectFaceRequestHandler = VNImageRequestHandler(cvPixelBuffer: bufferRef, options: [:])

do {
try detectFaceRequestHandler.perform([detectFaceRequest])
guard let results = detectFaceRequest.results else {
return
}

DispatchQueue.main.async { [weak self] in
guard let self = self else {
return
}

// 移除先前的人脸矩形
for layer in self.layers {
layer.removeFromSuperlayer()
}
self.layers.removeAll()

for observation in results {
let oldRect = observation.boundingBox
let w = oldRect.size.width * self.view.frame.size.width
let h = oldRect.size.height * self.view.frame.size.height
let x = oldRect.origin.x * self.view.bounds.size.width
let y = self.view.frame.size.height - (oldRect.origin.y * self.view.frame.size.height) - h

// 添加矩形图层
let layer = CALayer()
layer.borderWidth = 2
layer.cornerRadius = 3
layer.borderColor = UIColor.orange.cgColor
layer.frame = CGRect(x: x, y: y, width: w, height: h)

self.layers.append(layer)
}

// 将矩形图层添加到视图的图层上
for layer in self.layers {
self.view.layer.addSublayer(layer)
}
}
} catch {
print("错误: \(error)")
}
}

结尾


识别单个人脸的时候没有太大问题,但是多个人脸位置不是很准确,有知道原因的小伙伴告知一下


作者:土豆崽xxx
链接:https://juejin.cn/post/7250008208159997989
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

所有开发者注意,苹果审核策略有变

iOS
访问敏感数据的 App 新规 苹果最近在 Apple Developer 上发了篇新闻公告,对需要访问用户敏感数据的 App 增加了审核要求。 这件事的缘由是苹果发现有一小部分 API 可能会被开发者滥用,通过信息指纹收集有关用户设备的信息。 早在今年 6 月...
继续阅读 »


访问敏感数据的 App 新规


苹果最近在 Apple Developer 上发了篇新闻公告,对需要访问用户敏感数据的 App 增加了审核要求。


这件事的缘由是苹果发现有一小部分 API 可能会被开发者滥用,通过信息指纹收集有关用户设备的信息。


早在今年 6 月的 WWDC23 上苹果就宣布,开发人员需要在其应用程序的隐私清单中声明使用某些 API 的原因,目前正式放出了这份需要声明的 API 列表。


新规详情


从今年(2023年)秋天开始,大概是 9 月中旬左右,如果你将你的 App 上传到 App Store Connect,你的应用程序使用到了需要声明原因的 API(也包括你引入的第三方 SDK),但是你没有在隐私清单文件中添加原因,那么 Apple 会给你发送一封警告性的邮件。


从 2024 年春季开始,大概是 3 月左右,没有在隐私清单文件中说明使用原因的 App 将会被拒审核。


需要声明原因的 API 有哪些?


1、NSUserdefaults 相关 API


这个 API 是被讨论最多争议最大的,因为几乎每个 App 都会用到,而且因为有沙盒保护,每个 app 的存储空间是隔离的,这都要申报理由,的确十分荒谬。


2、获取文件时间戳相关的 API

  • creationDate
  • modificationDate
  • fileModificationDate
  • contentModificationDateKey
  • creationDateKey
  • getattrlist(::::_:)
  • getattrlistbulk(::::_:)
  • fgetattrlist(::::_:)
  • stat
  • fstat(::)
  • fstatat(::::)
  • lstat(::)
  • getattrlistat(::::::)

3、获取系统启动时间的 API


大多数衡量 App 启动时间的 APM 库会用到这个 API。

  • systemUptime
  • mach_absolute_time()

4、磁盘空间 API

  • volumeAvailableCapacityKey
  • volumeAvailableCapacityForImportantUsageKey
  • volumeAvailableCapacityForOpportunisticUsageKey
  • volumeTotalCapacityKey
  • systemFreeSize
  • systemSize
  • statfs(::)
  • statvfs(::)
  • fstatfs(::)
  • fstatvfs(::)
  • getattrlist(::::_:)
  • fgetattrlist(::::_:)
  • getattrlistat(::::::)

5、活动键盘 API


这个 API 可以来确定当前用户文本输入的主要语言,有些 App 可能会用来标记用户。

  • activeInputModes


如何在 Xcode 中配置


由于目前 Xcode 15 正式版还没有发布,下边的操作是在 Beta 版本进行的。


在 Xcode 15 中隐私部分全部归类到了一个后缀为 .xcprivacy 的文件中,创建项目时默认没有生成这个文件,我们先来创建一下。


打开项目后,按快捷键 Command + N 新建文件,在面板中搜索 privacy,选择 App Pirvacy 点击下一步创建这个文件。



这个文件是个 plist 格式的面板,默认情况下长这样:



然后点击加号,创建一个 Privacy Accessed API TypesKey,这是一个数组,用来包含所有你 App 使用到需要申明原因的 API。



在这个数组下继续点击加号,创建一个 Item,会看到两个选项:

  • Privacy Accessed API Type:用到的 API 类型
  • Privacy Accessed API Reasons:使用这个 API 的原因(也是个数组,因为可能包含多个原因)



这两个 Key 都创建出来,然后在 Privacy Accessed API Type 一栏点击右侧的菜单,菜单中会列出上边提到的所有 API,选择你需要申报的 API,我这里就拿 UserDefault 来举例:



然后在 Privacy Accessed API Reasons 一览中点击加号,在右侧的选项框中选择对应的原因,每个 API 对应的原因都会列出来,可以到苹果的官方文档上查看这个 API 的原因对应的是哪个,比如 UserDefault 对应的是 CA92.1,我这里就选择这个:



到此,申报原因就完成了,原因不需要自己填写,直接使用苹果给出的选项就可以了,还是蛮简单的。


参考资料


[1]公告原文: developer.apple.com/news/?id=z6…


[2]需要在 App 内声明的 API 列表: developer.apple.com/documentati…


[3]API 列表对应的原因: developer.apple.com/documentati…


作者:杂雾无尘
来源:juejin.cn/post/7267091810379759676
收起阅读 »

Mac开发环境配置看这一篇就够了

iOS
前言 从 macOS Catalina 开始,Mac 使用 zsh 作为默认登录 Shell 和交互式 Shell。当然你也可以修改默认Shell,但一般没这个必要。而实际开发中经常会遇到一些环境问题导致的报错,下面我们就讲一下一些常用库的环境配置以及原理。 ...
继续阅读 »

前言


macOS Catalina 开始,Mac 使用 zsh 作为默认登录 Shell 和交互式 Shell。当然你也可以修改默认Shell,但一般没这个必要。而实际开发中经常会遇到一些环境问题导致的报错,下面我们就讲一下一些常用库的环境配置以及原理。


一、Homebrew


作为Mac上最常用的包管理器,Homebrew可以称为神器,用它来管理Mac上的依赖环境便捷又省心。


1. 安装


这里我们直接在终端执行国人写的一键安装脚本,换源(官方源的速度你懂的)啥的都直接安排上了。

/bin/zsh -c "$(curl -fsSL https://gitee.com/cunkai/HomebrewCN/raw/master/Homebrew.sh)"



这里我们选择1、中科大下载源就好了,按照提示输入并耐心等待安装完成。






最后一步重载配置文件我们执行source ~/.zshrc,重载用户目录下的.zshrc


到这里我们可以执行brew -v测试一下Homebrew的安装结果:

~:~$brew -v
Homebrew 3.6.21-26-gb0a74e5
Homebrew/homebrew-core (git revision 4fbf6930104; last commit 2023-02-08)
Homebrew/homebrew-cask (git revision cbce859534; last commit 2023-02-09)

有版本号输出说明已经安装完成了。


2. 卸载


直接在终端执行一键脚本即可

复制代码
/bin/zsh -c "$(curl -fsSL https://gitee.com/cunkai/HomebrewCN/raw/master/HomebrewUninstall.sh)"

3. 常用命令

/// 安装
brew install FORMULA|CASK...

/// 搜索
brew search TEXT|/REGEX/

/// 卸载包
brew uninstall FORMULA|CASK...

/// 查看安装列表
brew list [FORMULA|CASK...]

/// 查看包信息
brew info [FORMULA|CASK...]

/// 查看哪些包可以更新
brew outdated

/// 更新指定包(安装新包,但旧包依旧保留)
brew upgrade [FORMULA|CASK...]

/// 更新Homebrew
brew update

/// 清理旧版本和缓存
brew cleanup # 清理所有包的旧版本
brew cleanup [FORMULA ...] # 清理指定包的旧版本
brew cleanup -n # 查看可清理的旧版本包,不执行实际操作

/// 锁定不想更新的包(因为update会一次更新所有的包的,当我们想忽略的时候可以使用这个命令)
brew pin [FORMULA ...] # 锁定某个包
brew unpin [FORMULA ...] # 取消锁定

/// 软件服务管理
brew services list # 查看使用brew安装的服务列表
brew services run formula|--all # 启动服务(仅启动不注册)
brew services start formula|--all # 启动服务,并注册
brew services stop formula|--all # 停止服务,并取消注册
brew services restart formula|--all # 重启服务,并注册

二、Ruby

1. 安装



其实Mac系统默认已经有Ruby的环境了,在终端中执行ruby -v查看版本号。

~:~$ruby -v
ruby 2.6.3p62 (2019-04-16 revision 67580) [universal.x86_64-darwin20]

本地ruby版本有点低了,这里我们使用Homebrew来更新,

brew install ruby

执行结束后默认会将最新版本的ruby安装到/usr/local/Cellar/目录下。


我们查看一下当前的ruby版本:

~:~$ruby -v
ruby 2.6.3p62 (2019-04-16 revision 67580) [universal.x86_64-darwin20]

好像版本并未发生变化,why? 这里主要是因为Shell环境中并没有读到最新的ruby路径,我们可以再编辑一下用户目录下的环境配置文件~/.zshrc,新增ruby的路径并写入环境变量:

# 环境变量配置
export RUBY=/usr/local/Cellar/ruby/3.2.0/bin
export GEMS=/usr/local/lib/ruby/gems/3.2.0/bin

# 写入环境变量
export PATH=$RUBY:$GEMS:$PATH

这里先添加上面的内容然后执行source ~/.zshrc,后面会讲到Shell环境配置相关的内容。


再次查看ruby版本:

~:~$ruby -v
ruby 3.2.0 (2022-12-25 revision a528908271) [x86_64-darwin20]

此时可以看到ruby已经升级到最新的3.2.0版本。


当然我们还可以执行which ruby查看当前的ruby的具体路径:

~:~$which ruby
/usr/local/Cellar/ruby/3.2.0/bin/ruby

从结果可以看出当前使用的ruby正是我们在.zshrc中配置的路径。


2. Gem换源


Gemruby的包管理器,一些ruby库我们需要使用Gem来安装,但Gem官方源速度拉胯,这里我们需要替换为国内源。

/// 添加国内源并删除官方源
gem sources --add https://gems.ruby-china.com/ --remove https://rubygems.org/

/// 查看当前源地址
gem sources -l

查看当前源,确认已替换为国内源即可。

~:~$gem sources -l
*** CURRENT SOURCES ***

https://gems.ruby-china.com/

3. 常用包安装

/// cocoapods安装
gem install cocoapods

/// fastlane安装
gem install fastlane

耐心等待安装完成后我们可以测试一下:

~:~$pod --version
1.11.3

~:~$fastlane --version
fastlane installation at path:
/usr/local/lib/ruby/gems/3.2.0/gems/fastlane-2.211.0/bin/fastlane
-----------------------------
[✔] 🚀
fastlane 2.211.0

从结果可以看出cocoapodsfastlane都安装完成了。


三、Python

1. 使用Xcode自带Python库(推荐)



其实Xcode命令行工具自带了python库,项目中需要执行python脚本的优先使用这个会更合适,因为Xcode编译项目时会优先使用这个python库,Mac中仅使用这一个版本可以避免一些多python版本环境问题导致的报错。


根据当前Xcode命令行工具中的python版本,这里我们需要在~/.zshrc中添加相关配置并执行source ~/.zshrc重载配置:

# 环境变量配置
export PYTHON=/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.8/Python3/bin

# 写入环境变量
export PATH=$PYTHON:$PATH

# 别名
alias python=python3
alias pip=pip3

这里使用别名以便于执行python命令时使用的是python3, 查看一下版本,结果也符合预期。

~:~$python --version
Python 3.8.9

2. 使用Homebrew安装


这里我们直接执行:

brew install python

耐心等待安装完成,其实Homebrew会将Python安装到/usr/local/Cellar/目录下,并在/usr/local/bin目录创建了链接文件。这里我们需要在~/.zshrc中添加相关配置并执行source ~/.zshrc重载配置:

# 环境变量配置
export SBIN=/usr/local/bin:/usr/local/sbin

# 写入环境变量
export PATH=$SBIN:$PATH

# 别名
alias python=python3
alias pip=pip3

查看一下版本,已经升级到最新版:

~:~$python --version
Python 3.10.10

3. pip换源


pippython的包管理器,我们可以使用它来安装一些python库。我们可以更换一个国内源来提升下载速度:

/// 查看当前源
pip config list

/// 替换为清华大学源
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple

/// 还原为默认源
pip config unset global.index-url


4. 常用包安装

/// openpyxl安装
pip install openpyxl

安装速度非常快:

~:~$pip install openpyxl
Collecting openpyxl
Using cached openpyxl-3.1.0-py2.py3-none-any.whl (250 kB)
Requirement already satisfied: et-xmlfile in /usr/local/lib/python3.10/site-packages (from openpyxl) (1.1.0)
Installing collected packages: openpyxl
Successfully installed openpyxl-3.1.0

四、Shell环境配置

1. zsh的配置文件.zshrc



macOS Catalina 开始,Mac 使用 zsh 作为默认shell,而它的配置文件是用户目录下的.zshrc文件,所以我们之前在定义环境变量时都会编辑这个文件。每次打开终端时都会读取这个配置文件,如果需要在当前的shell窗口读取最新的环境配置则需要执行source ~/.zshrc,这也是之前我们编辑该文件后重载配置的原因(为了让最新的配置生效😁)。


2. 定义环境变量(全局变量)

export RUBY=/usr/local/Cellar/ruby/3.2.0/bin

其实我们之前在讲Ruby的安装时已经在~/.zshrc文件中定义过全局变量,语法就是在一个变量名前面加上export关键字。这里我们可以在终端输出一下这个变量:

~:~$echo $RUBY
/usr/local/Cellar/ruby/3.2.0/bin

变量的值可以正常输出,这也意味着这样的变量在当前shell程序中全局可读。


3. 写入环境变量


常见的环境变量:

  • CDPATH:冒号分隔的目录列表,作为cd命令的搜索路径
  • HOME:当前用户的主目录
  • PATHshell查找命令的目录列表,由冒号分隔
  • BASH:当前shell实例的全路径名
  • PWD:当前工作目录

这里重点关注一下PATH变量,当我们在shell命令行界面中输入一个外部命令时,shell必须搜索系统来找到对应的程序。PATH环境变量定义了用于进行命令和程序查找的目录:

echo $PATH

某些时候我们执行命令会遇到command not found这样的报错,比如:

~:~$hi
zsh: command not found: hi

这是因为PATH中的目录并没有包含hi命令,所以我们执行hi就报错。同理,当我们在配置环境时,某些库的目录需要被写入到PATH中,比如:

# 环境变量配置
export SBIN=/usr/local/bin:/usr/local/sbin
export HOMEBREW=/usr/local/Homebrew/bin
export RUBY=/usr/local/Cellar/ruby/3.2.0/bin
export GEMS=/usr/local/lib/ruby/gems/3.2.0/bin

# 写入环境变量
export PATH=$SBIN:$HOMEBREW:$RUBY:$GEMS:$PATH

这样当我们执行具体的命令时,shell才能够正确的访问。




  • 附.zshrc常见配置

    # 环境变量配置
    export SBIN=/usr/local/bin:/usr/local/sbin
    export HOMEBREW=/usr/local/Homebrew/bin
    export RUBY=/usr/local/Cellar/ruby/3.2.0/bin
    export GEMS=/usr/local/lib/ruby/gems/3.2.0/bin

    # 写入环境变量
    export PATH=$SBIN:$HOMEBREW:$RUBY:$GEMS:$PATH

    # 别名
    alias python=python3
    alias pip=pip3

    # 编码
    export LC_ALL=en_US.UTF-8
    export LANG=en_US.UTF-8

    # 控制PS1信息
    PROMPT='%U%F{51}%1~%f%u:~$'

    # 镜像源
    export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.ustc.edu.cn/homebrew-bottles



五、参考文档


作者:HiMi
链接:https://juejin.cn/post/7198081187955802171
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

App 备案的复杂情绪:某些海外的独立 app 要和我们告别了

最近国内 App 备案的消息引发了大家热烈的讨论,其实对于国内的正规开发者而言其实影响没那么大,只是多了一道行政程序。有媒体报道说这样做是为了打击网络欺诈。对于作为独立开发者的我而言,app 要求备案我是完全理解的。只是希望能让备案维持一个低门槛,不要出现地方...
继续阅读 »

最近国内 App 备案的消息引发了大家热烈的讨论,其实对于国内的正规开发者而言其实影响没那么大,只是多了一道行政程序。有媒体报道说这样做是为了打击网络欺诈。对于作为独立开发者的我而言,app 要求备案我是完全理解的。只是希望能让备案维持一个低门槛,不要出现地方部门在执行中为了图省事增加额外的制度成本。


关于备案一个焦点问题是海外的 app 怎么办。参照历史经验,海外的一些独立 app 可能要跟国区告别了。备案对于海外的独立开发者而言还是不太好操作的,除非 AppStore 可以提供足够的帮助。但是我个人的观点,AppStore 只是一个发行商。替开发者备案是一个重运营的体力活,从商业角度 apple 实在是没动力做这个事情。何况如果海外 app 能赚钱,他们自然有能力和动力去完成备案。总的来说对海外的独立开发者而言,增加了不少门槛。




还有一个坏消息:如果一个 app 提供的是订阅服务,在订阅期间停止服务苹果会给用户退款。所以海外的 app 在国区下架以后,如果提供的是订阅服务,就不只是损失国内市场。苹果给用户退款,开发者可能还要贴钱给苹果。不过我个人觉得一个 app 如果能在中国赚到钱,似乎完全有动力找一个本地代理解决一些备案的事情。也有不少海外 app 接入的是支付集成方案,也许支付的解决方案提供商会有兴趣提供国内的备案服务(stripe?)。


但是也许这个对国内的一些开发者而言有一个小小的利好。如果国区大量海外 app 下架,国内的 app 市场就空出了不少市场。虽然这个并不是我所期待的,但是在商言商这个就是事实。也许 AppStore 会再现 copy to china 的情况,做一个高仿的海外 app 上架国区。道德上这样当然是要被人谴责的,但是国内现状就有不少安卓的开发者 copy 优质的 iOS 独立 app 到安卓市场。真很难评。


再说监管的执行问题。Apple 因为对自己的 app 分发一直有严格的管理,前几年就开始收紧了企业证书,很容易满足监管要求。加上 apple 又是一家守法的外企,相信在要求 app 在信息里填上备案号就可以了。但是安卓因为可以比较自由的安装 apk 的包,主流安卓手机厂商又都是中国的企业,我觉得未来如果要收紧监管,要求国内安卓手机接入 apk 安装认证,只有有备案号的 app 才能安装在手机上也不是不可能。到时候如果海外 app 不仅不能从应用商店下,自己下载的 apk 也不能安装恐怕会是一个沉重的打击。


更加严格的监管,对于会被电信诈骗骗到的小白用户是有好处的。但是我想对于另外一头对 app 有自主辨别能力的自由派用户而言就相当不友好了。我觉得简单的抱怨有点肤浅,而且伤身体。还是找到一个和现实世界妥协的方式吧。


作者:独立开花卓富贵
链接:https://juejin.cn/post/7266802662049267772
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

iOS 组件间通信,另一种与众不同的实现方式

iOS
本文已参与「新人创作礼」活动,一起开启掘金创作之路。 组件间通信,但凡大一点的项目都会做模块化开发,必然会遇到兄弟组件解耦、通信问题。 那如何不互相依赖模块,又可以相互传输消息呢?网上的方案是有很多了,比如:URL 路由target-actionprotoco...
继续阅读 »

本文已参与「新人创作礼」活动,一起开启掘金创作之路。


组件间通信,但凡大一点的项目都会做模块化开发,必然会遇到兄弟组件解耦、通信问题。


那如何不互相依赖模块,又可以相互传输消息呢?网上的方案是有很多了,比如:

  1. URL 路由
  2. target-action
  3. protocol


iOS:组件化的三种通讯方案 这篇写的挺不错,没了解的同学可以看一下



也有很多第三方组件代表,MGJRouterCTMediatorBeeHiveZIKRouter 等(排名不分前后[手动狗头])。


但他们或多或少都有各自的优缺点,这里也不展开说,但基本上的有这么几种问题:

  1. 使用起来比较繁琐,需要理解成本,开发起来也需要写很多冗余代码。
  2. 基本都需要先注册,再实现。那就无法保证代码一定存在实现,也无法保证实现是否跟注册出现不一致(当然你可以增加一些校验手段,比如静态检测之类的)。这一点在比较大型的项目里都是很痛的,要不就不敢删除历史代码来积债,要不就是莽过去,测试或者线上出现问题[手动狗头]。
  3. 如果存在 Model 需要传递,要不下沉到公共模块,要不就是转 NSDictionary。还是公共层积债或者模型变更导致运行时出问题。

那有没有银弹呢?这就是本次要讲的实现方式,换个角度解决问题。


与众不同的方案


通过上述的问题,想一下我们想要的实现是什么样:

  1. 不需要增加开发成本,也不需要理解整体的实现原理。
  2. 由组件提供方提供,先有实现再有定义,保证 API 是完全可用的,如果实现发生变更,调用方会编译时报错(问题暴露前置)。且其他模块不依赖但又可以准确调用到这个方法。
  3. 各类模型在模块内是正常使用的,且对外暴露也是可以正常使用的,但又不用去下沉在公共模块。

是不是感觉要求很过分?就像一个渣男既不想跟你结婚,又想跟你生孩子[手动狗头] 。


但能不能实现呢,确实是可以的。但解决办法不在 iOS 本身,而在 codegen。铺垫到这里,我们来看看具体实现。


GDAPI 原理


在笔者所在的稿定,之前用的是 CTMediator 方案做组件间通信,当然也就有上面的那些问题,甚至线上也出现过因为 Protocol 找不到 Mediator 导致的线上 crash。


为了解决定义和实现不匹配的问题,我们希望定义一定要有实现,实现一定要跟定义一致。


那是否就可以换个思路,先有实现,再有定义,从实现生成定义。


这点参考了 JAVA 的注解机制,我们定义了一个宏 GDM_EXPORT_MODULE(),用于说明哪些方法是需要开发给其他模块使用的。

// XXLoginManager.h

/// 判断是否登陆
- (BOOL)isLogin GDM_EXPORT_MODULE();

这样在组件开发方就完成了 API 开放,剩下的工作就是如何生成一个调用层代码。


调用层代码其实也就是 CTMediator 的翻版,通过 iOS 的运行时反射机制去寻找实现类

// XXService.m

static id<GDXXXAPI> _mXXXService = nil;
+ (id<GDXXXAPI>)XXXService {
if (_mXXXService == nil) {
_mXXXService = [self implementorOfName:@"GDXXXManager"];
}
return _mXXXService;
}

我们把这些生成的方法调用,生成到一个 GDAPI 模块统一存储,当然这个模块除了上述模块的 Service 层是要有具体的 .m 来做落地,其他都是 .h 的头文件。


那调用侧只需要 pod 增加依赖 s.dependency 'GDAPI/XXXXService' 即可调用到具体实现了

@import GDAPI;

...

bool isLogin = [GDAPI.XXService isLogin];


这里肯定有同学会问,生成过程呢???


笔者是用 Ruby 代码实现了整个 codegen 过程,当时没选择 Python 主要是为了跟 cocoapods 使用相同的开发语言,易于做侵入设计,但其实用其他语言都没问题,通过 shell 脚本做中转即可。




这里源码有些定制化实现,放出来现在也是徒增大家烦恼,所以讲一下生成关键过程:

  1. 遍历组件所在目录,取出所有的 .h 文件,缓存在 Map<文件路径,文件内容>(一级缓存)
  2. 解析存在 GDM_EXPORT_MODULE() 的方法,将方法的名称、参数、注释通过正则手段分解成相应的属性,存储到 Map<模块名,API 模型列表> (二级缓存)
  3. 对于每一个 API 模型进行进一步解析,解析入参和出参,判断参数类型是否为自定义类型(模型、代理、枚举、包括复杂的 NSArray<CustomModel *> * 等),如果有存在,则遍历一级缓存,找到自定义类型的定义,生成对应的 Model -> Procotol 等,且存储在多个 Map 中 Map<类名/代理名/枚举名,具体解析后的模型>(三级缓存)
  4. 有了 AST 生成就变得很简单,模版代码 + 模版输出即可


有了上述各种模型,就差不多完成了 AST (抽象语法树) 的生成过程,至于为什么是用的正则而不是 iOS 的 AST 工具,主要原因是想做的很轻,尽量减少大家的构建时长,不要通过编译来实现。0



可以看到已经有大量模块生成了相应的 GDAPI




执行时长在 2S 左右,因为有一个预执行的过程,来做组件项目化,这个也算是特殊实现了。
实质上执行也就 1S 即可。


还有一点要说的是执行时机是在 pod install / update 之前,这个是通过 hooks cocoapods 的执行过程做到的。


一些难点


嵌套模型


上面虽然粗略的讲了下 Model / Procotol 会生成 Protocol,但其实这一部分确实是最困难的,也是因为历史积债问题,下沉在公共模块的庞大的模型在各个组件里传输。


那要把它完全的 API 化,就需要对它的属性进行递归解析,生成完全符合的 protocol


例如:

... 举例为伪代码,OC 代码确实很啰嗦

class A extends B {
C c;

NSArray<D> d;
}

/// 测试
- (void)test:(A *)a GDM_EXPORT_MODULE();

生成结果就如下图(伪代码):


@protocol GDAPI_A {
NSObject<GDAPI_C> c;

NSArray<NSObject<GDAPI_D>> d;
}

@protocol GDAPI_B {
}

@protocol GDAPI_C {
}

@protocol GDAPI_D {
}

以及调用服务

@protocol GDXXXAPI <NSObject>
/// 测试
- (void)test:(NSObject<GDAPI_A, GDAPI_B>)a;


这个在落地过程中坑确实非常多。


B 模块想创建 A 模块的模型


当然这个是很不合理的,但现实中确实很多这样的历史问题。


当然也不能用模型下沉开倒车,那解决上用了一个巧劲

/// 创建 XX
- (XXXModel *)createXXX GDM_EXPORT_MODULE();

提供一个创建模型的 API 给外部使用,这样对于 Model 的管理还是在模块内,外部模块使用上从 new XXX() 改为 [GDAPI.XXService createXX]; 即可。


零零碎碎


用正则判断抓取 AST,在一些二三方库中也是很常见的,但来处理 OC 确实挺痛苦的,再加上历史代码很多没什么规范,空格、注释各式各样,写个通用的适配算是比较耗时的。


还有就是一些个性化的兼容,也存在一些硬编码的情况,比如有些组件去关联到的 Model 在 framework 中,维护一个对应表,用 @class 来兼容解决。


后续


篇(jing)幅(li)有限,就不再展开说明,这个实现思路影响了笔者后续的很多开发过程,有兴趣可以看下笔者 Flutter 的文章,里面也是 codegen 的广泛运用。


如果有任何问题,都可以评论区一起讨论。


手敲不易,如果对你学习工作上有所启发,请留个赞, 感谢阅读 ~~


作者:园宵
链接:https://juejin.cn/post/7137240001112178702
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Swift是时候使用Codable了

用不起: 苹果发布Swift支持Codable已经有一定历史年限了,为什么还用不起来,无非就是苹果的Codable太强势了, 比如模型里的定义比数据返回的json多一个key,少一个key,key的值类型不匹配(如定义为String,返回的是Int),苹果老子...
继续阅读 »

用不起:


苹果发布Swift支持Codable已经有一定历史年限了,为什么还用不起来,无非就是苹果的Codable太强势了,


比如模型里的定义比数据返回的json多一个key,少一个key,key的值类型不匹配(如定义为String,返回的是Int),苹果老子直接掀桌子,整个模型为nil。这。。。


而且模型的属性想要默认值,无。。。




你牛,牛到大家不知道怎么用


于是网络一边夸他Codable好用,一边真正工程开发中却还用不起来。


搞起来:


最近研究网上有没有好用的Codable库的时候,找到了这个。2021 年了,Swift 的 JSON-Model 转换还能有什么新花样github.com/iwill/ExCod…


经过他的封装,把苹果包装的服服帖帖。经测试,解决如下问题:

  1. 多一个key
  2. 少一个key
  3. key的类型不匹配的时候,自动做类型转换
  4. 默认值处理好。 



他的模型定义可以简化为:

struct testModel: ExAutoCodable {
@ExCodable
var courseId: Int = -1
@ExCodable
var totalSectionCount: Int = -1 // 总的章节
@ExCodable
var courseImageUrl: String = ""
@ExCodable
var tudiedSectionCount: Int = 0 // 已经学习章节
}

既然他这么好,那就用起来啰喂,,,,等等,等等


定义模型这样,竟然不行:

struct testModel: ExAutoCodable {
@ExCodable
var jumpParam: [String: Any]? = [:]

@ExCodable
var matchs: [Any] = []
}

苹果老子说Any不支持Codable???转模型的时候,这个全是空,nil。


一看工程,基本每个模型的定义都有这个呀,全有Any的定义,懵逼


研究起来:


通过研究stackoverflow.com/questions/4…, 发现可以给Any封装一个支持Codable的类型,比如AnyCodable这样。然后模型里面用到Any的,全部给换成AnyCodable。




模型改为如下,使用AnyCodable

struct testModel: ExAutoCodable {
@ExCodable
var jumpParam: [String: AnyCodable]? = [:]

@ExCodable
var matchs: [AnyCodable] = []
}

AnyCodable.swift代码如下:

//
// AnyCodable.swift
//
// 因为Any不支持Codable,但是模型里面经常会用到[String: Any]。
// 所以添加类AnyCodable,代替Any,来支持Codable, 如:[String: AnyCodable]。
// https://stackoverflow.com/questions/48297263/how-to-use-any-in-codable-type

import Foundation

public struct AnyCodable: Decodable {
var value: Any

struct CodingKeys: CodingKey {
var stringValue: String
var intValue: Int?
init?(intValue: Int) {
self.stringValue = "\(intValue)"
self.intValue = intValue
}
init?(stringValue: String) { self.stringValue = stringValue }
}

init(value: Any) {
self.value = value
}

public init(from decoder: Decoder) throws {
if let container = try? decoder.container(keyedBy: CodingKeys.self) {
var result = [String: Any]()
try container.allKeys.forEach { (key) throws in
result[key.stringValue] = try container.decode(AnyCodable.self, forKey: key).value
}
value = result
} else if var container = try? decoder.unkeyedContainer() {
var result = [Any]()
while !container.isAtEnd {
result.append(try container.decode(AnyCodable.self).value)
}
value = result
} else if let container = try? decoder.singleValueContainer() {
if let intVal = try? container.decode(Int.self) {
value = intVal
} else if let doubleVal = try? container.decode(Double.self) {
value = doubleVal
} else if let boolVal = try? container.decode(Bool.self) {
value = boolVal
} else if let stringVal = try? container.decode(String.self) {
value = stringVal
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "the container contains nothing serialisable")
}
} else {
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Could not serialise"))
}
}
}

extension AnyCodable: Encodable {
public func encode(to encoder: Encoder) throws {
if let array = value as? [Any] {
var container = encoder.unkeyedContainer()
for value in array {
let decodable = AnyCodable(value: value)
try container.encode(decodable)
}
} else if let dictionary = value as? [String: Any] {
var container = encoder.container(keyedBy: CodingKeys.self)
for (key, value) in dictionary {
let codingKey = CodingKeys(stringValue: key)!
let decodable = AnyCodable(value: value)
try container.encode(decodable, forKey: codingKey)
}
} else {
var container = encoder.singleValueContainer()
if let intVal = value as? Int {
try container.encode(intVal)
} else if let doubleVal = value as? Double {
try container.encode(doubleVal)
} else if let boolVal = value as? Bool {
try container.encode(boolVal)
} else if let stringVal = value as? String {
try container.encode(stringVal)
} else {
throw EncodingError.invalidValue(value, EncodingError.Context.init(codingPath: [], debugDescription: "The value is not encodable"))
}
}
}
}

这个结合Excodable,经过测试,完美。数据转换成功。


如果模型的定义忘记了,还是定义为Any呢。 再给Excodable库里面的源码,做安全检查,修改代码如下:

public extension Encodable {
func encode(to encoder: Encoder, nonnull: Bool, throws: Bool) throws {
var mirror: Mirror! = Mirror(reflecting: self)
while mirror != nil {
for child in mirror.children where child.label != nil {
try (child.value as? EncodablePropertyWrapper)?.encode(to: encoder, label: child.label!.dropFirst(), nonnull: false, throws: false)
// 注意:Any不支持Codable, 可以使用AnyCodable代替。
// 注意枚举类型,要支持Codable
assert((child.value as? EncodablePropertyWrapper) != nil, "模型:\(mirror)里面的属性:\(child.label) 需要支持 Encodable")
}
mirror = mirror.superclassMirror
}
}
}

public extension Decodable {
func decode(from decoder: Decoder, nonnull: Bool, throws: Bool) throws {
var mirror: Mirror! = Mirror(reflecting: self)
while mirror != nil {
for child in mirror.children where child.label != nil {
try (child.value as? DecodablePropertyWrapper)?.decode(from: decoder, label: child.label!.dropFirst(), nonnull: false, throws: false)
// 注意:Any不支持Codable, 可以使用AnyCodable代替。
// 注意枚举类型,要支持Codable
assert((child.value as? DecodablePropertyWrapper) != nil, "模型:\(mirror)里面的属性:\(child.label) 需要支持 Decodable")
}
mirror = mirror.superclassMirror
}
}
}

嗯,这下模型如果定义为Any,可以在运行的时候报错,提醒要改为AnyCodable。


能愉快的编码了。。。


不过总感觉还差点东西。


再研究起来:


找到这个 github.com/levantAJ/An…


可以实现

let dictionary: [String: Any] = try container.decode([String: Any].self, forKey: key)
let array: [Any] = try container.decode([Any].self, forKey: key)

通过自定义[String: Any]和[Any]的解码,实现Any的Codble。


是否可以把这个合并到Excodable里面吧,从而什么都支持了,666。


在Excodable里面提issues,作者回复有空可以弄弄。


我急用呀,那就搞起来。


花了九牛二虎,终于搞出下面兼容代码:

// Make `Any` support Codable, like: [String: Any], [Any]
fileprivate protocol EncodableAnyPropertyWrapper {
func encode<Label: StringProtocol>(to encoder: Encoder, label: Label, nonnull: Bool, throws: Bool) throws
}
extension ExCodable: EncodableAnyPropertyWrapper {
fileprivate func encode<Label: StringProtocol>(to encoder: Encoder, label: Label, nonnull: Bool, throws: Bool) throws {
if encode != nil { try encode!(encoder, wrappedValue) }
else {
let t = type(of: wrappedValue)
if let key = AnyCodingKey(stringValue: String(label)) {
if (t is [String: Any].Type || t is [String: Any?].Type || t is [String: Any]?.Type || t is [String: Any?]?.Type) {
var container = try encoder.container(keyedBy: AnyCodingKey.self)
try container.encodeIfPresent(wrappedValue as? [String: Any], forKey: key)
} else if (t is [Any].Type || t is [Any?].Type || t is [Any]?.Type || t is [Any?]?.Type) {
var container = try encoder.container(keyedBy: AnyCodingKey.self)
try container.encodeIfPresent(wrappedValue as? [Any], forKey: key)
}
}
}
}
}
fileprivate protocol DecodableAnyPropertyWrapper {
func decode<Label: StringProtocol>(from decoder: Decoder, label: Label, nonnull: Bool, throws: Bool) throws
}
extension ExCodable: DecodableAnyPropertyWrapper {
fileprivate func decode<Label: StringProtocol>(from decoder: Decoder, label: Label, nonnull: Bool, throws: Bool) throws {
if let decode = decode {
if let value = try decode(decoder) {
wrappedValue = value
}
} else {
let t = type(of: wrappedValue)
if let key = AnyCodingKey(stringValue: String(label)) {
if (t is [String: Any].Type || t is [String: Any?].Type || t is [String: Any]?.Type || t is [String: Any?]?.Type) {
let container = try decoder.container(keyedBy: AnyCodingKey.self)
if let value = try container.decodeIfPresent([String: Any].self, forKey: key) as? Value {
wrappedValue = value
}
} else if (t is [Any].Type || t is [Any?].Type || t is [Any]?.Type || t is [Any?]?.Type) {
let container = try decoder.container(keyedBy: AnyCodingKey.self)
if let value = try container.decodeIfPresent([Any].self, forKey: key) as? Value {
wrappedValue = value
}
}
}
}
}
}

再在他用的地方添加

// MARK: - Encodable & Decodable - internal

public extension Encodable {
func encode(to encoder: Encoder, nonnull: Bool, throws: Bool) throws {
var mirror: Mirror! = Mirror(reflecting: self)
while mirror != nil {
for child in mirror.children where child.label != nil {
if let wrapper = (child.value as? EncodablePropertyWrapper) {
try wrapper.encode(to: encoder, label: child.label!.dropFirst(), nonnull: false, throws: false)
} else { //添加
try (child.value as? EncodableAnyPropertyWrapper)?.encode(to: encoder, label: child.label!.dropFirst(), nonnull: false, throws: false)
}
}
mirror = mirror.superclassMirror
}
}
}

public extension Decodable {
func decode(from decoder: Decoder, nonnull: Bool, throws: Bool) throws {
var mirror: Mirror! = Mirror(reflecting: self)
while mirror != nil {
for child in mirror.children where child.label != nil {
if let wrapper = (child.value as? DecodablePropertyWrapper) {
try wrapper.decode(from: decoder, label: child.label!.dropFirst(), nonnull: false, throws: false)
} else { //添加
try (child.value as? DecodableAnyPropertyWrapper)?.decode(from: decoder, label: child.label!.dropFirst(), nonnull: false, throws: false)
}
}
mirror = mirror.superclassMirror
}
}
}


完美:


综上,终于可以让Excodable库支持[String: Any]和[Any]的Codable了,撒花撒花。


从而模型定义这样,也能自动编解码:

struct testModel: ExAutoCodable {
@ExCodable
var jumpParam: [String: Any]? = [:]

@ExCodable
var matchs: [Any] = []
}

针对这个库的更新修改,改到这github.com/yxh265/ExCo…


也把对应的更新提交给Excodable的作者了,期待合并。
(作者iwill说,用ExCodable提供的 ExCodableDecodingTypeConverter 协议来实现是否可行。
我看了,因为Any不支持Codable,所以要想用ExCodableDecodingTypeConverter协议,也得要大改。也期待作者出马添加这个功能。)


最后的使用方法:


引入如下:

pod 'ExCodable', :git => 'https://github.com/yxh265/ExCodable.git', :commit => '4780fb8'

模型定义:

struct TestStruct: ExAutoCodable {
@ExCodable // 字段和属性同名可以省掉字段名和括号,但 `@ExCodable` 还是没办法省掉
var int: Int = 0
@ExCodable("string", "str", "s", "nested.string") // 支持多个 key 以及嵌套 key 可以这样写
var string: String? = nil
@ExCodable
var anyDict: [String: Any]? = nil
@ExCodable
var anyArray: [Any] = []
}

编解码:

let test = TestStruct(int: 304, string: "Not Modified", anyDict: ["1": 2, "3": "4"], anyArray: [["1": 2, "3": "4"]])
let data = try? test.encoded() as Data?
let copy1 = try? data?.decoded() as TestStruct?
let copy2 = data.map { try? TestStruct.decoded(from: $0) }
XCTAssertEqual(copy1, test)
XCTAssertEqual(copy2, test)

引用:


2021 年了,Swift 的 JSON-Model 转换还能有什么新花样


github.com/iwill/ExCod…


stackoverflow.com/questions/4…


stackoverflow.com/questions/4…


Property wrappers in Swift和Codable


作者:清点游玩
链接:https://juejin.cn/post/7168748765946806303
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

iOS应用内弹窗通知怎么实现?其实很简单,这样,这样,再这样.....你学会了么?

iOS
携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第29天,点击查看活动详情。 项目背景 消息通知可以及时地将状态、内容的更新触达到用户,用户则可以根据收到的消息做后续判断。这是最常见的信息交换方式的产品设计。 而顶部向下弹出的消息通知本质上...
继续阅读 »

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第29天,点击查看活动详情


项目背景


消息通知可以及时地将状态、内容的更新触达到用户,用户则可以根据收到的消息做后续判断。这是最常见的信息交换方式的产品设计。


而顶部向下弹出的消息通知本质上是根据条件触发的“中提醒”通知类型,示例:每次在网购时,支付成功后在App会展示消息通知。


因此本章中,我们就来试试使用SwiftUI来实现应用内弹窗通知交互。


项目搭建


首先,创建一个新的SwiftUI项目,命名为NotificationToast


消息弹窗样式

我们构建一个新的视图NotificationToastView,然后声明好弹窗视图的内容变量,示例:

struct NotificationToastView: View {
    var notificationImage: String
    var notificationTitle: String
    var notificationContent: String
    var notificationTime: String

    var body: some View {
        //弹窗样式
    }
}

上述代码中,我们声明了4个String类型的变量:notificationImage图标信息、notificationTitle标题信息、notificationContent内容信息、notificationTime推送时间。


然后我们构建样式内容,示例:

HStack {
    Image(notificationImage)
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(width: 60)
        .clipShape(Circle())
        .overlay(Circle().stroke(Color(.systemGray5), lineWidth: 1))
    VStack(spacing: 10) {
        HStack {
            Text(notificationTitle)
                .font(.system(size: 17))
                .foregroundColor(.black)
            Spacer()
            Text(notificationTime)
                .font(.system(size: 14))
                .foregroundColor(.gray)
        }
        Text(notificationContent)
            .font(.system(size: 14))
            .foregroundColor(.black)
            .lineLimit(4)
            .multilineTextAlignment(.leading)
    }
}
.padding()
.frame(minWidth: 10, maxWidth: .infinity, minHeight: 10, maxHeight: 80)
.background(.white)
.cornerRadius(8)
.shadow(color: Color(.systemGray4), radius: 5, x: 1, y: 1)
.padding()

上述代码中,我们构建了样式排布,Image使用notificationImage图片信息变量,它和其他元素是HStack横向排布关系。


右边则是HStack横向排布的notificationTitle标题变量的文字和notificationTime推送时间的文字,使用Spacer撑开。


而底下是notificationContent内容信息,它和标题信息及推送时间信息是VStack纵向排布。


我们在ContentView中展示看看效果,示例:

NotificationToastView(notificationImage: "me", notificationTitle: "文如秋雨", notificationContent: "一只默默努力变优秀的产品汪,独立负责过多个国内细分领域Top5的企业级产品项目,擅长B端、C端产品规划、产品设计、产品研发,个人独立拥有多个软著及专利,欢迎产品、开发的同僚一起交流。", notificationTime: "2分钟前")


消息弹窗交互


交互方面,我么可以做个简单的交互,创建一个按钮,点击按钮时展示消息弹窗,消息弹窗显示时等待2秒后自动消失。


实现逻辑也很简单,我们可以让弹窗加载的时候在视图之外,然后点击按钮的时候,让消息弹窗从下往下弹出,然后等待2秒后再回到视图之外


首先我们声明一个偏移量,定义消息弹窗的初始位置,示例:

@State var offset: CGFloat = -UIScreen.main.bounds.height / 2 - 80

然后给弹窗视图加上偏移量和动画的修饰符,示例:

ZStack {
    NotificationToastView(notificationImage: "me", notificationTitle: "文如秋雨", notificationContent: "一只默默努力变优秀的产品汪,独立负责过多个国内细分领域Top5的企业级产品项目,擅长B端、C端产品规划、产品设计、产品研发,个人独立拥有多个软著及专利,欢迎产品、开发的同僚一起交流", notificationTime: "2分钟前")
        .offset(x: 0, y: offset)
        .animation(.interpolatingSpring(stiffness: 120, damping: 10))
    Button(action: {
        if offset <= 0 {
            offset += 180
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                self.offset -= 180
            }
        }
    }) {
        Text("弹出通知")
    }
}

上述代码中,我们让NotificationToastView弹窗视图偏移位置Y轴为我们声明好的变量offset位置,然后使用ZStack叠加展示一个按钮,当我们offset在视图外时,点击按钮修改偏移量的位置为180,然后调用成功后等待2秒再扣减偏移量回到最初的位置


项目预览


我们看看最终效果。


恭喜你,完成了本章的全部内容!

快来动手试试吧。

如果本专栏对你有帮助,不妨点赞、评论、关注~

作者:文如秋雨
链接:https://juejin.cn/post/7136104673248804878
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

iOS:零碎整理iOS音视频开发API

在ios开发过程中,音频经常会用到,而音频根据使用场合分为音效和音乐,音效一般只播放1~2秒ios音效支持的格式: ios 支持的音频格式有:aac、alac、he-aac、iLBc、IMA4、Linea PCM、MP3、CAF,其中,aac、alac、he-...
继续阅读 »

在ios开发过程中,音频经常会用到,而音频根据使用场合分为音效和音乐,音效一般只播放1~2秒

  • ios音效支持的格式: ios 支持的音频格式有:aac、alac、he-aac、iLBc、IMA4、Linea PCM、MP3、CAF,其中,aac、alac、he-aac、mp3、caf支持硬件解码,其他只支持软件解码, 软件界面因为比较耗电,所以,我们在开发过程中,经常采用的是caf、mp3

  • 音频库: AVFoundation.framework

代码

// 打开资源
NSURL* url =[[NSBundle mainBundle]URLForResource:@"m_03" withExtension:@"wav"];
SystemSoundID soundID;
AudioServicesCreateSystemSoundID((__bridge CFURLRef)(url), &soundID);
// 播放音效
AudioServicesPlaySystemSound(self.soundID);
// 删除音效
AudioServicesDisposeSystemSoundID(self.soundID);
  • 框架

  • 加载音乐资源并播放

AVAudioPlayer* player = musicDict[fileName];
if (!player) {
NSURL* url = [[NSBundle mainBundle] URLForResource:fileName withExtension:nil];
NSCAssert(url != nil, @"fileName not found musics");

NSError* error;
player = [[AVAudioPlayer alloc]initWithContentsOfURL:url error:&error];
if (error) {
NSLog(@"load music error");
return;
}
[musicDict setObject:player forKey:fileName];
}
if (player.isPlaying == NO) {
[player play];
}
  • 暂停 停止操作

[player pause];// 暂停
[player stop];// 停止
[player isplaying];// 是否在播放

好了,现在能播放音乐了,但我们在看其他的应用的时候,一般当应用切换到后台的时候也能播放音乐,那这个又是如何实现的呢?这个只要设置音频的后台播放,具体为:

1> 在后台开启一个任务

- (void)applicationDidEnterBackground:(UIApplication *)application
{
// 开启后台任务,让音乐继续播放
[application beginBackgroundTaskWithExpirationHandler:nil];
}

  2> 设置项目配置文件


   3> 设置音频链接会话,这个主要告诉设备如何处理音频事件的

1234// 设置音频会话类型``   ``AVAudioSession* session = [AVAudioSession sharedInstance];``   ``[session setCategory:AVAudioSessionCategorySoloAmbient error:``nil``];``   ``[session setActive:``YES error:``nil``];

这里有很多会话类型,如果想详细了解,可参考:blog.csdn.net/daiyelang/a…

现在应该可以播放音乐了。


作者:会飞的金鱼
链接:https://juejin.cn/post/7238110426147373112
来源:稀土掘金
收起阅读 »

iOS中UICollectionView的item增删、拖拽和排序动画

iOS
效果图 这个是前段时间项目新增的一个功能,刚刚开始组员是用UIScrollView + UIView 实现的,但这种实现方式属实是有点low,后续闲暇时笔者用UICollectionView简单实现了下。 思路 简单理一下思路,首先是把整个页面先布局出来,这...
继续阅读 »

效果图




这个是前段时间项目新增的一个功能,刚刚开始组员是用UIScrollView + UIView 实现的,但这种实现方式属实是有点low,后续闲暇时笔者用UICollectionView简单实现了下。


思路


简单理一下思路,首先是把整个页面先布局出来,这里涉及到一个UICollectionViewSection背景色的问题,有需要的可以点这里,有详细的介绍。移动动画也很简单,先获取起点cell和终点cell,再新建一个动画的AnimationItem,根据获取的起点和终点cell动画就行,最后再实现拖拽排序效果。


大致总结一下:

  • 布局

  • 移动动画

  • 拖拽排序


下面就根据思路一步步来。我们一定要有一个意识,不管是多么复杂的动画,只要把它分解开来,按步骤一步一步实现就很简单。


实现


我们就跟上面的思路一步一步实现。


布局


首先想到肯定是新建一个Model来管理这个数据,新建Model也要有点技巧。

struct ItemModel {
    var section: Int = -1 // cell的section索引
    var item: Int = -1 // cell的item索引
    var name: String = "" // 名称
    var isAdded: Bool = false // 是否添加到首页应用(第0区)
    var id: String { // 唯一标识,可以用这个来命名图片的名称,也可以用来作判断
        get {
            "\(section)_\(item)"
        }
    }
    init(){}
}

看得出来,ItemModel的属性section + item = IndexPath,可以根据 model 知道当前cell的所在位置了。


笔者这用的是 struct ,感觉用 class 会更好点,因为后续会改变数组中Model的属性值。已经写了就懒得再改了。


存在2个数组数据:

  • var editItems = [ItemModel](),由前一页传入的、可编辑、拖拽的数据,位于UICollectionView的第0个Section。

  • var datas = [[ItemModel]](),按照Section的顺序,存放所有的数据。


注意:Section要从1开始,因为第0个Section是可以编辑拖拽的区域。


datas中存放全部的数据:

for i in 0..<names.count {
let subNames = names[i]
var items = [ItemModel]()
for j in 0..<subNames.count {
var model = ItemModel()
model.section = i+1 // 注意这里的Section要从1开始
model.item = j
model.name = subNames[j]
model.isAdded = editItems.contains(where: { $0.id == model.id})
items.append(model)
}
datas.append(items)
}

根据数据布局UICollectionView


移动动画


移动只要2个操作,添加应用和删除应用。


添加


笔者这里规定了最多可以添加8个应用。


大致思路:

  • 获取当前点击的 cell,为了得到其坐标作为动画起始位置

  • 在 collectionView 中插入一个空白的 cell 占位,此举是为了增加或减少行数的动画过渡更自然;对应也应该在 editItems 中添加一个空白的 model 作为数据源,等移动动画结束后再给model重新赋值。

  • 获取新插入的空白 cell,为了得到其坐标作为动画的结束位置

  • 生成动画的 cell,起始 -> 结束 动画。

  • 更新数据,刷新


删除


思路与添加雷同,且比之更简单


具体的思路和步骤,代码中都有一步步的注释,可自行查阅。


拖拽排序


这个拖拽排序,在iOS11之前的比较麻烦,都是靠自己计算,这里也简单说下思路:


iOS11.0之前的实现思路

  1. 在UICollectionView上添加一个长按的手势

  2. 在UICollectionView上面添加一个浮动隐藏的cell,便于拖拽

  3. 通过长按操作找到需要被拖动的cellA

  4. 通过拖动cellA找到找到和它交换位置的cellB

  5. 交换cellA和cellB的位置

  6. 替换数据源,把起始位置的数据模型删除,然后将起始位置的数据模型插入到拖拽位置


这种比较复杂的是结合位置判断需要交换的cell。但是在iOS11之后,UICollectionView新增了dragDelegatedropDelegate,用来实现拖拽排序的效果。


dragDelegate、dropDelegate


直接上代码:

collectionView.dragDelegate = self
collectionView.dropDelegate = self
collectionView.dragInteractionEnabled = true
collectionView.reorderingCadence = .immediate
collectionView.isSpringLoaded = true

  • dragInteractionEnabled 属性要设置为 true,才可以进行 drag 操作。此属性在 iPad 默认是 true,在 iPhone 默认是 false。

  • reorderingCadence 重排序节奏,可以调节集合视图重排序的响应性。

  • UICollectionViewReorderingCadenceImmediate 默认值。当开始移动的时候就立即回流集合视图布局,实时的重新排序。

  • UICollectionViewReorderingCadenceFast 快速移动,不会立即重新布局,只有在停止移动的时候才会重新布局

  • UICollectionViewReorderingCadenceSlow 停止移动再过一会儿才会开始回流,重新布局

  • isSpringLoaded 弹性加载效果,也可以使用代理方法:func collectionView(_ collectionView: UICollectionView, shouldSpringLoadItemAt indexPath: IndexPath, with context: UISpringLoadedInteractionContext) -> Bool。

需要实现UICollectionViewDropDelegateUICollectionViewDragDelegate协议方法。下面是常用的几个方法,按照调用的先后顺序说明一下:

/*
* 识别到拖动,一次拖动一个;若一次拖动多个,则需要选中多个
* 提供一个给定 indexPath 的可进行 drag 操作的 item
* NSItemProvider, 拖放处理时,携带数据的容器,通过对象初始化,该对象需满足 NSItemProviderWriting 协议
*/
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem]

/*
* 使用自定义预览,如果该方法没有实现或者返回nil,那么整个 cell 将用于预览
* UIDragPreviewParameters有2个属性:backgroundColor设置背景颜色;visiblePath设置视图的可见区域
* 笔者使用这个方法除去了拖拽过程中item的阴影
*/
func collectionView(_ collectionView: UICollectionView, dragPreviewParametersForItemAt indexPath: IndexPath) -> UIDragPreviewParameters?

/*
* 开始拖拽后,继续添加拖拽的任务,处理雷同`itemsForBeginning`方法
*/
func collectionView(_ collectionView: UICollectionView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem]

/*
* 拖拽开始,可自行处理
*/
func collectionView(_ collectionView: UICollectionView, dragSessionWillBegin session: UIDragSession)

/*
* 判断对应的 item 能否被执行drop会话,是否能放置
*/
func collectionView(_ collectionView: UICollectionView, canHandle session: UIDropSession) -> Bool

/*
* 处理拖动中放置的策略,此方法会 频繁调用,在此方法中应尽可能减少工作量。
* 四种分别:move移动;copy拷贝;forbidden禁止,即不能放置;cancel用户取消。
* 效果一般使用2种:.insertAtDestinationIndexPath 挤压移动;.insertIntoDestinationIndexPath 取代。
* 在某些情况下,目标索引路径可能为空(比如拖到一个没有cell的空白区域)你可以通过 session.locationInView 做你自己的命中测试
*/
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal

/*
* 当drop会话进入到 collectionView 的坐标区域内就会调用
*/
func collectionView(_ collectionView: UICollectionView, dropSessionDidEnter session: UIDropSession)

/*
* 结束放置时的处理
* 如果该方法不做任何事,将会执行默认的动画
*/
func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator)

/*
* 拖拽开始,可自行处理
*/
func collectionView(_ collectionView: UICollectionView, dragSessionDidEnd session: UIDragSession)

/*
* 当dropSession 完成时会被调用,不管结果如何。一般进行清理或刷新操作
*/
func collectionView(_ collectionView: UICollectionView, dropSessionDidEnd session: UIDropSession)

这里大概的拖拽动画就差不多了,代码中会有更详细的注释。


总结


将一个大功能拆分成一个个小模块,按部就班一步一步实现就不难了。这里只是中间囊括了各种小动画和刷新,设计好思路,不行就多试几次肯定可以。


代码自取:RCDragDropAnimation




若存在什么不对的地方,欢迎指正!


作者:云层之上
链接:https://juejin.cn/post/7246777949100933177
来源:稀土掘金
收起阅读 »

iOS 中如何精准还原 Sketch 线性渐变效果

iOS
背景 这样的渐变效果当然用切图是可以方便的实现,但切图不够灵活,而且会增加包大小 那如何用代码实现呢? 首先看下 iOS 中渐变的几个参数 colors startPoint endPoint locations colors 很好获取,其他三个参数怎么...
继续阅读 »

背景




这样的渐变效果当然用切图是可以方便的实现,但切图不够灵活,而且会增加包大小


那如何用代码实现呢?


首先看下 iOS 中渐变的几个参数



  • colors

  • startPoint

  • endPoint

  • locations


colors 很好获取,其他三个参数怎么办呢,似乎只能看图猜出个大概?


猜想


众所周知,sketch 有一键导出标注的插件 ,但是只能获取到 locations 信息

background-image: linear-gradient(-73deg, #361CE6 0%, #7DA7EB 50%, #96A4FF 100%);

并且这个 -73deg 对于 iOS 中的 startPoint``endPoint 来说还不太友好,需要经过一番转换。


这个时候心中有个想法💡,这个插件能导出这些信息应该是对 sketch 的源文件进行了解析,那么 sketch 的源文件是个什么样的文件呢,会不会像 .ipa 那样是个压缩包呢?


实践


file 命令可以查看文件的信息

file Test.sketch

输出如下结果

Test.sketch: Zip archive data, at least v2.0 to extract, compression method=deflate

可以看到这确实是一个压缩包

那就可以用 unzip 命令来解压一下

unzip Test.sketch -d ./temp

👀看看解压出了个啥呢?

.
├── document.json
├── meta.json
├── pages
│   └── 7832D4DC-A896-40BE-8F96-45850CE9FC53.json
├── previews
│   └── preview.png
└── user.json

有 json 文件!欣喜若狂😁!!!最终在 pages 这个目录下的 json 文件找到了想要的东西

{
"_class": "gradient",
"elipseLength": 0,
"from": "{1.1356274384397782, 0.99999999999999978}",
"gradientType": 0,
"to": "{-0.13533980933892775, -0.49069446290249097}",
"stops": [
{
"_class": "gradientStop",
"position": 0,
"color": {
"_class": "color",
"alpha": 1,
"blue": 0.903056932532269,
"green": 0.1092045213150163,
"red": 0.2098672970162421
}
},
{
"_class": "gradientStop",
"position": 0.4973543951952161,
"color": {
"_class": "color",
"alpha": 1,
"blue": 0.9204804793648098,
"green": 0.6532974892326747,
"red": 0.4919794574547816
}
},
{
"_class": "gradientStop",
"position": 1,
"color": {
"_class": "color",
"alpha": 1,
"blue": 1,
"green": 0.6418734727143864,
"red": 0.5896740424923781
}
}
]
}

结论

  • from 是 startPoint

  • to 是 endPoint

  • stops 中的 position 是 locations


Tips: UI 给的 sketch 文件可能图层太多,json 文件会非常大,打开比较卡,可以把图层复制到自己新建的 sketch 文件中再解压


作者:LittleYuuuuu
链接:https://juejin.cn/post/7222179242946641978
来源:稀土掘金
收起阅读 »

iOS17适配指南之UIContentUnavailableView(一)

iOS
介绍 新增视图,表示内容不可达,特别适用于没有数据时的占位视图。 UIContentUnavailableConfigurationUIContentUnavailableView 的配置参数,用于设置不可达时的占位内容。既可以使用 UIKit,又可以使用 S...
继续阅读 »

介绍


新增视图,表示内容不可达,特别适用于没有数据时的占位视图。


UIContentUnavailableConfiguration

  • UIContentUnavailableView 的配置参数,用于设置不可达时的占位内容。

  • 既可以使用 UIKit,又可以使用 SwiftUI。

  • 系统提供了 3 种配置,分别为empty()、loading()与search()。

  • UIViewController 增加了一个该类型的参数contentUnavailableConfiguration,用于设置view内容不可达时的占位内容。


案例一

import UIKit

class ViewController: UIViewController {
lazy var tableView: UITableView = {
let tableView = UITableView(frame: UIScreen.main.bounds, style: .plain)
tableView.dataSource = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "abc")
return tableView
}()
// UIContentUnavailableView
lazy var unavailableView: UIContentUnavailableView = {
var config = UIContentUnavailableConfiguration.empty()
// 配置内容
config.text = "暂无数据"
config.textProperties.color = .red
config.secondaryText = "正在加载数据..."
config.image = UIImage(systemName: "exclamationmark.triangle")
config.imageProperties.tintColor = .red
var buttonConfig = UIButton.Configuration.filled()
buttonConfig.title = "加载数据"
config.button = buttonConfig
config.buttonProperties.primaryAction = UIAction(title: "") { _ in
self.loadData()
}
var backgroundConfig = UIBackgroundConfiguration.listPlainCell()
backgroundConfig.backgroundColor = .systemGray6
config.background = backgroundConfig
// 创建UIContentUnavailableView
let unavailableView = UIContentUnavailableView(configuration: config)
unavailableView.frame = UIScreen.main.bounds
return unavailableView
}()
var content: [String] = []

override func viewDidLoad() {
super.viewDidLoad()

view.addSubview(tableView)
if content.isEmpty {
view.addSubview(unavailableView)
}
}

func loadData() {
content = ["iPhone 12 mini", "iPhone 12", "iPhone 12 Pro", "iPhone 12 Pro Max",
"iPhone 13 mini", "iPhone 13", "iPhone 13 Pro", "iPhone 13 Pro Max",
"iPhone 14", "iPhone 14 Plus", "iPhone 14 Pro", "iPhone 14 Pro Max"]
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.tableView.reloadData()
self.unavailableView.removeFromSuperview()
}
}
}

// MARK: - UITableViewDataSource
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return content.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "abc", for: indexPath)
cell.textLabel?.text = content[indexPath.row]
cell.imageView?.image = UIImage(systemName: "iphone")
return cell
}
}

效果:


案列二

import UIKit

class ViewController: UIViewController {
lazy var emptyConfig: UIContentUnavailableConfiguration = {
var config = UIContentUnavailableConfiguration.empty()
config.text = "暂无数据"
config.image = UIImage(systemName: "exclamationmark.triangle")
return config
}()

override func viewDidLoad() {
super.viewDidLoad()

contentUnavailableConfiguration = emptyConfig
}

// MARK: - 更新UIContentUnavailableConfiguration
override func updateContentUnavailableConfiguration(using state: UIContentUnavailableConfigurationState) {
// 切换
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
let loadingConfig = UIContentUnavailableConfiguration.loading()
self.contentUnavailableConfiguration = loadingConfig
}
// 移除
DispatchQueue.main.asyncAfter(deadline: .now() + 6) {
self.contentUnavailableConfiguration = nil
self.view.backgroundColor = .systemTeal
}
}
}


作者:YungFan
链接:https://juejin.cn/post/7257732392541634621
来源:稀土掘金

收起阅读 »

新时代,你需要了解一下苹果的 VisionOS 系统

iOS
这是一个全新的平台。熟悉的框架和工具。请准备好为 Apple vision Pro 设计和构建全新的应用程序和游戏世界。 沉浸的光谱。 Apple vision Pro 提供无限的空间画布供您探索、试验和玩耍,让您自由地完全重新思考您的 3D 体验。人们可以在...
继续阅读 »

这是一个全新的平台。熟悉的框架和工具。请准备好为 Apple vision Pro 设计和构建全新的应用程序和游戏世界。


沉浸的光谱。


Apple vision Pro 提供无限的空间画布供您探索、试验和玩耍,让您自由地完全重新思考您的 3D 体验。人们可以在与周围环境保持联系的同时与您的应用互动,或者完全沉浸在您创造的世界中。您的体验可以是流畅的:从一个窗口开始,引入 3D 内容,过渡到完全身临其境的场景,然后马上回来。


选择权在您手中,这一切都始于 visionOS 上的空间计算构建块。




窗口(Windows)


您可以在 visionOS 应用程序中创建一个或多个窗口。它们使用 SwiftUI 构建,包含传统视图和控件,您可以通过添加 3D 内容来增加体验的深度。


体积(Volumes)


使用 3D 体积为您的应用添加深度。 Volumes 是一种 SwiftUI 场景,可以使用 RealityKit 或 Unity 展示 3D 内容,从而创建可从共享空间或应用程序的完整空间中的任何角度观看的体验。


空间(Space)


默认情况下,应用程序启动到共享空间,在那里它们并排存在——很像 Mac 桌面上的多个应用程序。应用程序可以使用窗口和音量来显示内容,用户可以将这些元素重新放置在他们喜欢的任何位置。为了获得更身临其境的体验,应用程序可以打开一个专用的完整空间,其中只会显示该应用程序的内容。在完整空间内,应用程序可以使用窗口和体积、创建无限的 3D 内容、打开通往不同世界的门户,甚至可以让某人完全沉浸在某个环境中。




Apple 框架 - 扩展空间计算


SwiftUI


无论您是要创建窗口、体积还是空间体验,SwiftUI 都是构建新的 visionOS 应用程序或将现有 iPadOS 或 iOS 应用程序引入该平台的最佳方式。凭借全新的 3D 功能以及对深度、手势、效果和沉浸式场景类型的支持,SwiftUI 可以帮助您为 Vision Pro 构建精美且引人入胜的应用程序。 RealityKit 还与 SwiftUI 深度集成,以帮助您构建清晰、响应迅速且立体的界面。 SwiftUI 还可以与 UIKit 无缝协作,帮助您构建适用于 visionOS 的应用程序。


RealityKit


使用 Apple 的 3D 渲染引擎 RealityKit 在您的应用程序中呈现 3D 内容、动画和视觉效果。 RealityKit 可以自动调整物理光照条件并投射阴影、打开通往不同世界的门户、构建令人惊叹的视觉效果等等。为了创作您的材料,RealityKit 采用了 MaterialX,这是一种用于指定表面和几何着色器的开放标准,由领先的电影、视觉效果、娱乐和游戏公司使用。


ARKit


在 vision Pro 上,ARKit 可以完全了解一个人的周围环境,为您的应用提供与周围空间交互的新方式。默认情况下,ARKit 支持内核系统功能,您的应用程序在共享空间中时会自动受益于这些功能——但是当您的应用程序移动到完整空间并请求许可时,您可以利用强大的 ARKit API,例如平面估计、场景重建、图像锚点、世界轨道和骨骼手部轨道。所以在墙上泼水。从地板上弹起一个球。通过将现实世界与您的内容融合在一起,打造令人惊叹的体验。


Accessibility


visionOS 的设计考虑了可访问性,适用于希望完全通过眼睛、声音或两者的组合与设备交互的人。对于喜欢以不同方式导航内容的人,Pointer Control 允许他们选择食指、手腕或头部作为替代指针。您可以使用已在其他 Apple 平台上使用的相同技术和工具为 visionOS 创建易于访问的应用程序,并帮助使 vision Pro 成为每个人的绝佳体验。




您需要的所有工具。


Xcode


visionOS 的开发从 Xcode 开始,其中包括 visionOS SDK。将 visionOS 目标添加到您现有的项目或构建一个全新的应用程序。在 Xcode 预览中迭代您的应用程序。在全新的 visionOS Simulator 中与您的应用程序交互,探索各种房间布局和照明条件。创建测试和可视化以探索空间内容的碰撞、遮挡和场景理解。


reality composer Pro


探索全新的 reality composer Pro,旨在让您轻松预览和准备 visionOS 应用程序的 3D 内容。随 Xcode 一起提供的 reality composer Pro 可以帮助您导入和组织资产,例如 3D 模型、材料和声音。最重要的是,它与 Xcode 构建过程紧密集成以预览和优化您的 visionOS 资产。


Unity


现在,您可以使用 Unity 强大、熟悉的创作工具来创建新的应用程序和游戏,或者为 visionOS 重新构想现有的 Unity 创建的项目。除了熟悉的 Unity 功能(如 AR foundation)之外,您的应用程序还可以获得 visionOS 的所有优势,例如直通和动态注视点渲染。通过将 Unity 的创作和模拟功能与 RealityKit 管理的应用程序渲染相结合,使用 Unity 创建的内容在 visionOS 上看起来和感觉起来就像在家里一样。




您的 visionOS 之旅从这里开始。


visionOS SDK 本月晚些时候与 Xcode、visionOS 模拟器、reality composer Pro、文档、示例代码、设计指南等一起发布。


为 visionOS 做准备


无论您已经在 App Store 上拥有应用程序,还是这是您第一次为 Apple 平台开发应用程序,您现在都可以做很多事情来为 visionOS SDK 的到来做好准备。了解如何更新您的应用程序并探索现有框架,让您更轻松地开始使用 visionOS。


Prepare for visionOS


了解 visionOS


visionOS 拥有一流的框架和工具,是帮助您创造令人难以置信的空间体验的完美平台。无论您是在构想游戏、构建媒体体验、设计与 SharePlay 的连接和协作时刻、创建业务应用程序,还是更新您的网站以支持 visionOS,我们都有会议和信息来帮助您制定计划。为第 46 场 WWDC23 会议准备好 visionOS SDK,以帮助您了解平台开发、空间体验设计以及测试和工具。


Learn about visionOS


与苹果合作


在为 visionOS 开发应用程序和游戏时,获得 Apple 的直接支持。了解即将举行的活动、测试机会和其他计划,以支持您为此平台创造令人难以置信的体验。


Learn about working with Apple


#visionOS #苹果MR #苹果VR #苹果AR


翻译原文地址


作者:稻草人家
链接:https://juejin.cn/post/7241393511618347045
来源:稀土掘金
收起阅读 »

iOS设置圆角后阴影不显示

iOS
问题 设计图中View有阴影和圆角,里面填充了四个按钮。同时设置View的圆角和阴影,阴影并不显示。试了很多次,找了很多办法,记录一下过程。 为了展示父视图的圆角,设置了masksToBounds=YES,将超出父视图的内容clip掉,这样圆角就OK了。不设置...
继续阅读 »

问题


设计图中View有阴影和圆角,里面填充了四个按钮。同时设置View的圆角和阴影,阴影并不显示。试了很多次,找了很多办法,记录一下过程。


为了展示父视图的圆角,设置了masksToBounds=YES,将超出父视图的内容clip掉,这样圆角就OK了。不设置时,Subview会超出父视图,看起来像圆角没有设置成功。


接着给父视图设置Shadow却没有成功。一开始以为是代码的问题,写了很多次,数值啊颜色啊大小啊都设置的很大,却还是不显示。百度了一下,提示阴影为超出父视图的部分,如果使用masksToBounds会把超出部分切掉,但去掉masksToBounds会导致圆角失效。


解决方案


给View外面套一层ShadowView,把阴影加到ShadowView上,不设置masksToBounds,再设置真正需要圆角的View,设置masksToBounds即可。

self.shadowView.layer.cornerRadius = 50;
self.shadowView.layer.shadowOffset = CGSizeMake(1, 5);
self.shadowView.layer.shadowOpacity = 0.8;
self.shadowView.layer.shadowColor = [UIColor lightGrayColor].CGColor;
self.actionView.layer.cornerRadius = 50;
self.actionView.layer.masksToBounds = YES;

设置没有SubView的View时并不需要设置masksToBounds属性就可以同时拥有圆角和阴影,但有SubView时为了不让它超出父视图内容(如果都为白色的话超出看着特别像设置没有成功的样子)就必须设置masksToBounds属性了,才会有上面的问题。


Demo


写了一个Demo,展示一下不同的设置效果,给大家参考。
github.com/Yadea-Web/R…

1.第一个View是没有Subview的情况,直接设置cornerRadius即可,不需要设置masksToBounds属性。

2.第二个View有一个子View,不设置masksToBounds属性显示阴影但圆角没生效,因为子视图超出了。

3.第三个View有一个子View,设置masksToBounds属性圆角生效了,但阴影消失了,因为把超出部分截掉了。

4.第四个View有一个子View,设置View的阴影,不设置masksToBounds。设置Subview的cornerRadius,再设置masksToBounds属性。Subview将超出部分截掉,外层展示它应该展示的阴影效果。

5.第五第六展示的是有多个子控件的情况,与三四类似,详见Demo。



作者:木小棉
链接:https://juejin.cn/post/6844903984793124878
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Xcode快捷Behavior

前言在Xcode开发环境中,有一些可以自定义的快捷Behavior,可以大大提高开发效率。如何配置Behavior以下是在Xcode中配置Behavior的通用步骤:1.打开Xcode的偏好设置。2.点击“Behaviors”选项卡。3.点击左下角的"+"号创...
继续阅读 »

前言

在Xcode开发环境中,有一些可以自定义的快捷Behavior,可以大大提高开发效率。


如何配置Behavior

以下是在Xcode中配置Behavior的通用步骤:

1.打开Xcode的偏好设置。

2.点击“Behaviors”选项卡。

3.点击左下角的"+"号创建一个新的Behavior。

4.为Behavior命名,例如你希望调用的脚本名。

5.在“Run”下选择“Script”,然后粘贴你的脚本。

6.按需配置快捷键,并保存。

现在,每当你使用配置的快捷键时,它就会运行你的脚本。

Behavior1:打开终端并cd到当前工作目录
创建open_terminal.sh,写入以下内容

#!/bin/bash
open -a terminal "`pwd`"

并添加执行权限

sudo chmod +x open_terminal.sh

添加Behavior并在Run处选中该脚本路径,配置好快捷键。 当你使用配置的快捷键时,就会打开终端并cd到当前工作目录。

Behavior2:打开项目文件夹
创建open_project_folder.sh,写入以下内容

#!/bin/bash

# Path to your project
project_path="$1"

# Open the project folder in Finder
open "$project_path"

并添加执行权限

sudo chmod +x open_project_folder.sh

添加Behavior并在Run处选中该脚本路径,配置好快捷键。
当你使用配置的快捷键时,就会在Finder中打开你的项目文件夹。

总结

通过配置Behavior,我们可以更快速地访问项目文件夹和命令行等,从而提高开发效率。自定义Behavior是Xcode强大功能的一个体现,它允许我们根据自己的需求调整开发环境。

作者:冰淇淋真好吃
链接:https://juejin.cn/post/7262634764301844536
来源:稀土掘金

收起阅读 »

iOS热修复,看这里就够了(手把手教你玩热修)

背景 对于app store的审核周期不确定性,可长到2星期,短到1天。假如线上的应用出现了一些bug,甚至是致命的崩溃,这时候假如按照苹果的套路乖乖重新发布一个版本,然后静静等待看似漫无期限的审核周期,最终结果就是:用户大量流失。因此,对于一些线上...
继续阅读 »

背景


对于app store的审核周期不确定性,可长到2星期,短到1天。假如线上的应用出现了一些bug,甚至是致命的崩溃,这时候假如按照苹果的套路乖乖重新发布一个版本,然后静静等待看似漫无期限的审核周期,最终结果就是:用户大量流失。因此,对于一些线上的bug,需要有及时修复的能力,这就是所谓的热修复(hotfix)。


随着迭代频繁或者次数的增多,应用出现功能异常或不可用的情况也会随之增多。这时候又有什么方法可以快速解决线上的问题呢?第一、在一开始功能设计的时候就设计降级方案,但随之开发成本和测试成本都会双倍增加;第二、每个功能加上开关配置,这样治标不治本,当开关关掉的时候,就意味着用户无法使用该功能。这时候热修复就是解决这种问题的最佳之选,既能修复问题,又能让用户无感知,两全其美。iOS热修复技术从最初的webView到最近的SOT,技术的发展越来越快,新技术已经到来:


一. 首先是原理篇MangoFix:(知道原理才能更好的干活)


热修复的核心原理:


1.  拦截目标方法调用,让其调用转发到预先埋好的特定方法中

1.  获取目标方法的调用参数


只要完成了上面两步,你就可以随心所欲了。在肆意发挥前,你需要掌握一些 Runtime 的基础理论,

Runtime 可以在运行时去动态的创建类和方法,因此你可以通过字符串反射的方式去动态调用OC方法、动态的替换方法、动态新增方法等等。下面简单介绍下热修复所需要用到的 Runtime 知识点。


OC消息转发机制



由上图消息转发流程图可以看出,系统给了3次机会让我们来拯救。


第一步,在resolveInstanceMethod方法里通过class_addMethod方法来动态添加未实现的方法;


第二步,在forwardingTargetForSelector方法里返回备用的接受者,通过备用接受者里的实现方法来完成调用;


第三步,系统会将方法信息打包进行最终的处理,在methodSignatureForSelector方法里可以对自己实现的方法进行方法签名,通过获取的方法签名来创建转发的NSInvocation对象,然后再到forwardInvocation方法里进行转发。


方法替换就利用第三步的转发进行替换。


当然现在有现成的,初级及以上iOS开发工程师很快就可以理解的语法分析,大概了解一下mangofix是可以转化oc和swift代码的:具体详情请看

http://www.jianshu.com/p/7ae91a2da…


那么为什么它可以执行转化呢,转化逻辑是什么?

MangoFix项目主页上中已经讲到,MangoFix既是一个iOS热修复SDK,但同时也是一门DSL(领域专用语言),即iOS热修复领域专用语言。既然是一门语言,那肯定要有相应的编译器或者解析器。相对于编译器,使用解析器实现语言的执行,虽然效率低了点,但显然更加简单和灵活,所以MangoFix选择了后者。下面我们先用一张简单流程图,看一下MangoFix的运行原理,然后逐一解释。




1、MangoFix脚本


首先热修复之前,我们先要准备好热修复脚本文件,以确定我们的修复目标和执行逻辑,这个热修复脚本文件便是我们这里要介绍的MangoFix脚本,正常是放在我们的服务端,然后由App在启动时或者适当的运行期间进行下载,利用MangoFix提供的MFContext对象进行解析执行。对于MangoFix脚本的语法规则,这点可以参考MangoFix Quick Start,和OC的语法非常类似,你如果有OC开发经验,相信你花10分钟便可以学会。当然,在后续的文章中我可能也会介绍这一块。


2、词法分析器


几乎所有的语言都有词法分析器,主要是将我们的输入文件内容分割成一个个token,MangoFix也不例外,MangoFix词法分析器使用Lex所编写,如果你想了解MangoFix词法分析器的代码,可以点击这里


3、语法分析器


和词法分析器类似,几乎所有语言也都有自己的语法分析器,其主要目的是将词法分析器输出的一个个token构建成一棵抽象语法树,而且这颗抽象语法树是符合我们预先设计好的上下文无关文法规则的,如果你想了解MangoFix语法分析器的代码,可以点击这里


4、语义检查


由于语法分析器输出的抽象语法树,只是符合上下文无关文法规则,没有上下文语义关联,所以MangoFix还会进一步做语义检查。比如我们看下面代码:

less  
复制代码
@interface MyViewController : UIViewController

@end
angelscript  
复制代码
class MyViewController : BaseViewController{

- (void)viewDidLoad{
    //TODO
}

}

上面部分是OC代码,下面部分是MangoFix代码,从文法角度MangoFix这个代码是没有问题的,但是在逻辑上却有问题, MyViewController在原来OC中和MangoFix中继承的父类不一致,这是OC runtime所不允许的。


5、创建内置对象


MangoFix脚本中很多功能都是通过预先创建内置对象的方式支持的,比如常用结构体的声明、变量、宏、C函数和GCD相关的操作等,如果想详细了解MangoFix中有哪些内置对象,可以点击这里。当然MangoFix也开放了相关接口,你也可以向MangoFix执行上下文中注入你需要的对象。


6、执行顶层语句


在做完上面的操作后,MangoFix解析器就开 始真正执行MangoFix脚本了,比如顶层语句的执行、结构体的声明、类的定义等。


7、利用runtime热修复


现在就到了最关键一步了,就是利用runtime替换掉原来Method的IMP指针,MangoFix利用libffi库动态创建C函数,在创建的C函数中调用MangoFix脚本中方法,然后用刚刚创建的C函数替换原来Method的IMP指针,当然MangoFix也会保留原有的IMP指针,只不过这时候该IMP指针对应的selector要在原有的基础上在前面拼接上ORG,这一点和JSPatch一致。当然,MangoFix也支持对属性的添加。


8、MangoFix方法执行


最后当被修复的OC方法在被调用的时候,程序会走到我们动态创建的C函数中,在该函数中我们通过查找一个全局的方法替换表,找到对应的MangoFix方法实现,然后利用MangoFix解析器执行该MangoFix的方法。


二. 具体执行(OC修复OC)。


1.后台分发补丁平台:


补丁平台:patchhub.top/mangofix/lo…


github地址:github.com/yanshuimu/M…

1.首先你要明白:必须得有个后台去上传,分发bug的文件,安全起见,脚本已经通过AES128加密,终端收到加密的脚本再去解密,防止被劫持和篡改,造成代码出现问题。
登录这个补丁平台,可以快速创建appid。
github地址下载并配合使用:
以下是MangoFixUtil的说明:
MangoFixUtil是对MangoFix进行了简单的封装,该库在OC项目中实战已经近2年多,经过多次迭代,比较成熟。但需要搭配补丁管理后台一起使用,后台由作者开发维护,目前有50+个已上架AppStore的应用在使用,欢迎小伙伴们使用。

2.举个实战中的例子:

我们快速迭代中遇到的一些问题:



有一次我们解析到后台数据从中间截取字符串,然而忘了做判空操作,后台数据一旦不给返回,那么项目立马崩溃,所以做了热修复demo.mg文件放到Patch管理平台,具体具体代码如OC基本一致:

class JRMineLoginHeaderView:JRTableViewHeaderView {  

- (NSString *)getNetStringNnm:(NSString *)str{
    NSError *error = nil;
    if(str.length<=0) {
        return @"";
    }
    
    NSRegularExpression *regex = NSRegularExpression.regularExpressionWithPattern:options:error:(@"\d+",0,&error);

    if (error) {
        return @"";
    } else {
    
    if (str.length == 0) {
        return @"";
    }
        
        NSArray *matches = regex.matchesInString:options:range:(str,0,NSMakeRange(0, str.length));
        for (NSTextCheckingResult *match in matches) {
            NSString *matchString = str.substringWithRange:(match.range);
            return matchString;
        }
    }
    return @"";
}

}

以上代码中,新增了对象长度判空操作: if(str.length<=0) {
return @"";
}
完美的解决了崩溃的问题。


2.oc转换成DSL语言。


一切准备就绪,oc转换成DSL语言浪费人力,而且准确率又低怎么办?怎么可以快速的用oc转换成mangofix语言呢?

这是macOS系统上的可视化辅助工具,将OC语言转成mangofix脚本。


做iOS热修复时,大量时间浪费在OC代码翻译成脚本上,提供这个辅助工具,希望能给iOSer提供便利,
本人写了一个mac应用,完美的解决了不同语法障碍,转换问题。

mac版本最低(macos10.11)支持内容:


(1)OC代码 一键 批量转换成脚本


(2)支持复制.m内容粘贴,转换


(3)支持单个OC API转换,自动补全


(4)报错提示:根据行号定位到OC代码行



自动转化文件QQ群获取。



3.打不开“OC2PatchTool.app”,因为它来自身份不明的开发者


方案1.系统偏好设置>>安全与隐私>>允许安装未知来源


方案2.打开 Terminal 终端后 ,在命令提示后输入

sudo spctl --master-disable

OC转换成 脚本 支持两种方式

方式1.拷贝.m文件所有内容,粘贴到OC输入框内。 示例代码:AFHTTPSessionManager.m


方式2. 拷贝某个方法粘贴到OC输入框内,转换时会自动补全



三.App 审核分析


其实能不能成功上线是热修复的首要前提,我们辛辛苦苦开的框架如果上不了线,那一切都是徒劳无功。下面就来分析下其审核风险。


-   首先这个是通过大量C语言混编转换的,所以苹果审核无法通过静态代码识别,这一点是没有问题的。

-   其次系统库内部也大量使用了消息转发机制。这一点可以通过符号断点验证_objc_msgForwardforwardInvocation:。所以不存在风险。此外,你还可以通过一些字符串拼接和base64编码方式进行混淆,这样就更加安全了。

-   除非苹果采用动态检验消息转发,非系统调用都不能使用,但这个成本太大了,几乎不可能。

-   Mangofix 库目前线上有大量使用,为此不用担心。就算 Mangofix 被禁用,参考 Mangofix 自己开发也不难。


综上所述:超低审核风险。


热修复框架只是为了更好的控制线上bug影响范围和给用户更好的体验。

建议:

Hotfix会在应用程序运行时动态地加载代码,因此需要进行充分的测试,以确保修复的bug或添加的新功能不会导致应用程序崩溃或出现其他问题。


有兴趣的一起来研究,QQ群:770600683


作者:洞窝技术
链接:https://juejin.cn/post/7257333598469783610
来源:稀土掘金
收起阅读 »

如何使用 Xcode 15 新组件 TipKit

iOS
TipKit 介绍今年的 WWDC 发布了一个新的 UI 组件库 TipKit,使用 TipKit 可以很方便的在 iOS/macOS/watchOS 等平台的 App 上展示一个提示框,并且内置了 UI 布局,并且支持配置展示频率、规则等功能。今天 Xcod...
继续阅读 »

TipKit 介绍

今年的 WWDC 发布了一个新的 UI 组件库 TipKit,使用 TipKit 可以很方便的在 iOS/macOS/watchOS 等平台的 App 上展示一个提示框,并且内置了 UI 布局,并且支持配置展示频率、规则等功能。

今天 Xcode 15 Beta 5 发布了,TipKit 也终于带了进来,我大概尝试了一下这套 API,和一个月前 WWDC 的视频教程上有些不一样的地方,今天就来讲讲怎么使用。

今天的代码使用 SwiftUI 来演示。

启动配置

想要正常展示 Tip 组件,需要在 App 启动入口加载和配置应用程序中所有 Tip 的统一状态:

import SwiftUI
import TipKit

@main
struct TipKitDemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.task {
try? await Tips.configure()
}
}
}
}

这里的 Tips.configure() 函数支持设置一系列用于自定义 Tip 的选项,我这里没有传参数,它会自动帮我配置默认值。

自定义 Tip

首先导入 TipKit 框架:

然后声明一个 struct 继承 Tip:

struct MyTip: Tip {
var title: Text {
Text("Tip Title")
}
}

Tip 是一个协议,title 是必须实现的,其他值都可选。

展示 Tip

Tip 有两种展示方式,popover 和 Inline,popover 需要在指定的元素上使用 popoverTip 方法挂载这个 Tip,Tip 展示出来后会有个箭头指向这个元素,比如我在收藏按钮下展示这个 Tip:

struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "star")
.imageScale(.large)
.foregroundStyle(.tint)
.popoverTip(MyTip(), arrowEdge: Edge.top) { action in
print(action)
}
}
.padding()
}
}

看下效果:


Inline 的方式是作为一个独立的 View 展示在视图上的,需要用到 TipView 组件:


Tip 的 UI 配置

刚刚提到 Tip 是一个协议,可以配置一些其他 UI,比如,在标题下方增加一行描述 (下边的效果截图均以 popover 的方式展示):

struct MyTip: Tip {
var title: Text {
Text("Save as a Favorite")
}

var message: Text? {
Text("Your favorite backyards always appear at the top of the list.")
}
}


添加图标:

struct MyTip: Tip {
// 其他代码...
var asset: Image? {
Image(systemName: "star")
}
}


添加按钮

struct MyTip: Tip {
// 其他代码...
var actions: [Action] {
[
Action(id: "1", title: "Learn More", perform: {
print("点击了第一个按钮")
}),
Action(id: "2", title: "OK", perform: {
print("点击了第二个按钮")
})
]
}
}


配置规则

除此之外,还可以配置一系列显示的规则,比如我定义一个 Bool 来控制这个 Tip 的展示与隐藏:

struct MyTip: Tip {
@Parameter
static var isShowing: Bool = false

// ...其他代码...

var rules: [Rule] {
[
#Rule(MyTip.$isShowing) { $0 == true }
]
}
}

然后我们再稍微改一下 ContentView 的代码,每次点击按钮的时候反转 isShowing 这个参数,来控制 Tip 的出现和消失:

struct ContentView: View {
var body: some View {
VStack {
Button(action: {
// 控制隐藏和出现
MyTip.isShowing.toggle()
}, label: {
Image(systemName: "star.fill")
})
.popoverTip(MyTip(), arrowEdge: Edge.top) { action in
print(action)
}
}
.padding()
}
}

这样我们就可以通过点击按钮来展示和隐藏这个提示框了:



这里需要注意,目前 Xcode Beta 5 有个已知的问题是不能正常访问 @Parameter 这个宏,解决办法是在项目的 Build Settings 的 Other Swift Flags 中手动添加 -external-plugin-path (SYSTEM\_DEVELOPER\_DIR)/Platforms/iPhoneOS.platform/Developer/usr/lib/swift/host/plugins#(SYSTEM_DEVELOPER_DIR)/Platforms/iPhoneOS.platform/Developer/usr/bin/swift-plugin-server,否则无法编译通过


配置显示选项

通过 TipOption 可以配置一些额外的展示选项,比如我这里配置这个 Tip 最大显示 5 次:

struct MyTip: Tip {

// ...其他代码...

var options: [TipOption] {
[ Tips.MaxDisplayCount(5) ]
}
}

更多的配置大家可以自行探索。


作者:杂雾无尘
链接:https://juejin.cn/post/7262162940971139109
来源:稀土掘金

收起阅读 »

iOS 灵动岛上岛指南

零、关于灵动岛的认识灵动岛,即实时活动(Live Activity)它允许人们以瞥见的形式来观察事件或任务的状态.我的理解是"我不需要一直盯着看,但是我偶尔想看的时候能很方便的看到".这就需要再设计的时候尽可能扔掉没用的信息,保持信息的简洁.实时活动的事件构成...
继续阅读 »

零、关于灵动岛的认识

灵动岛,即实时活动(Live Activity)

它允许人们以瞥见的形式来观察事件或任务的状态.我的理解是"我不需要一直盯着看,但是我偶尔想看的时候能很方便的看到".这就需要再设计的时候尽可能扔掉没用的信息,保持信息的简洁.

实时活动的事件构成最好是包含明确开始 + 结束的事件.例如:外卖、球赛等.

实时活动在结束前最多存活8小时,结束后在锁屏界面最多再保留4小时.

关于更多灵动岛(实时活动)的最佳实践及设计思路可以参考一下:
知乎-苹果开放第三方App登岛,灵动岛设计指南来了!

一、灵动岛的UI布局

接入灵动岛后,有的设备支持(iPhone14Pro / iPhone14ProMax)展示灵动岛+通知中心,有的设备不支持灵动岛则只在通知中心展示一条实时活动的通知.
所以以下四种UI都需要实现:

1.紧凑型


2. 最小型


3. 扩展型


4. 通知


二、代码实现

1.在主工程中创建灵动岛Widget工程

Xcode -> Editor -> Add Target


如图勾选即可


2.在主工程的info.plist中添加key

Supports Live Activities = YES (允许实时活动)

Supports Live Activities Frequent Updates = YES(实时活动支持频繁更新) 这个看项目的需求,不是强制的


3.添加主工程与widget数据交互模型

在主工程中,新建Swift File,作为交互模型的文件.这里将数据管理与模型都放到这一个文件里了.


创建文件后的目录结构


import Foundation
import ActivityKit

//整个数据交互的模型
struct TestWidgetAttributes: ActivityAttributes {
    public typealias TestWidgetState = ContentState
    //可变参数(动态参数)
    public struct ContentState: Codable, Hashable {
        var data: String
    }
    //不可变参数 (整个实时活动都不会改变的参数)

    var id: String

}

如果参数过多.或者与OC混编,默认给出的这种结构体可能无法满足要求.此时可以使用单独的模型对象,这样OC中也可直接构造与赋值.注意,此处的模型需要遵循Codable协议

import Foundation
import ActivityKit

struct TestWidgetAttributes: ActivityAttributes {
    public typealias TestWidgetState = ContentState
    //可变参数(动态参数)
    public struct ContentState: Codable, Hashable {
        var dataModel: TestLADataModel
    }
    //不可变参数 (整个实时活动都不会改变的参数)
    //var name: String
}

@objc public class TestLADataModel: NSObject, Codable {
    @objc var idString : String = ""
    @objc var nameDes : String = ""
    @objc var contentDes : String = ""
    @objc var completedNum : Int//已完成人数
    @objc var notCompletedNum : Int//未完成人数
    var allPeopleNum : Int {
        get {
            return completedNum + notCompletedNum
        }
    }

    public override init() {
        self.nameDes = ""
        self.contentDes = ""
        self.completedNum = 0
        self.notCompletedNum = 0
        super.init()
    }

    /// 便利构造
    @objc convenience init(nameDes: String, contentDes: String, completedNum: Int, notCompletedNum: Int) {
        self.init()
        self.nameDes = nameDes
        self.contentDes = contentDes
        self.completedNum = completedNum
        self.notCompletedNum = notCompletedNum
    }
}

4.Liveactivity widget的UI

打开前文创建的widget,我的叫demoWLiveActivity.swift

这里给出了默认代码的注释,具体的布局代码就不再此处赘述了.

import ActivityKit
import WidgetKit
import SwiftUI

struct demoWLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: demoWAttributes.self) { context in
            // 锁屏之后,显示的桌面通知栏位置,这里可以做相对复杂的布局
            VStack {
                Text("Hello")
            }
            .activityBackgroundTint(Color.cyan)
            .activitySystemActionForegroundColor(Color.black)
        } dynamicIsland: { context in
            DynamicIsland {
                /*
                 这里是长按灵动岛[扩展型]的UI
                 有四个区域限制了布局,分别是左、右、中间(硬件下方)、底部区域
                 */
                DynamicIslandExpandedRegion(.leading) {
                    Text("Leading")
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text("Trailing")
                }
                DynamicIslandExpandedRegion(.center) {
                    Text("Center")
                }
                DynamicIslandExpandedRegion(.bottom) {
                    Text("Bottom")
                    // more content
                }
            } compactLeading: {
                // 这里是灵动岛[紧凑型]左边的布局
                Text("L")
            } compactTrailing: {
                // 这里是灵动岛[紧凑型]右边的布局
                Text("T")
            } minimal: {
                // 这里是灵动岛[最小型]的布局(有多个任务的情况下,展示优先级高的任务,位置在右边的一个圆圈区域)
                Text("Min")
            }
            .widgetURL(URL(string: "http://www.apple.com"))
            .keylineTint(Color.red)
        }
    }
}

5.Liveactivity 的启动 / 更新(主工程) / 停止

启动

let attributes = TestWidgetAttributes()
let initialConetntState = TestWidgetAttributes.TestWidgetState(dataModel: dataModel)
do {
let activity = try Activity.request(
attributes: attributes,
content: .init(state: initialConetntState, staleDate: nil),
pushType: nil
//              pushType: .token
)
print("请求开启实时活动: \(activity.id)")
} catch (let error) {
print("请求开启实时出错: \(error.localizedDescription)")
}

更新

let updateState = TestWidgetAttributes.TestWidgetState(dataModel: dataModel)
let alertConfig = AlertConfiguration(
title: "\(dataModel.nameDes) has taken a critical hit!",
body: "Open the app and use a potion to heal \(dataModel.nameDes)",
sound: .default
)
await activity.update(
ActivityContent<TestWidgetAttributes.ContentState>(
state: updateState,
staleDate: nil
),
alertConfiguration: alertConfig
)
print("更新实时活动: \(activity.id)")

结束

let finalContent = TestWidgetAttributes.ContentState(
dataModel: TestLADataModel()
)
let dismissalPolicy: ActivityUIDismissalPolicy = .default
await activity.end(
ActivityContent(state: finalContent, staleDate: nil),
dismissalPolicy: dismissalPolicy)
removeActivityState(id: idString);
print("结束实时活动: \(activity)")

三、更新数据

数据的更新主要通过两种方式:

1.服务端推送

2.主工程更新

其中主工程的更新参见(2.5.Liveactivity 的启动 / 更新(主工程) / 停止)

这里主要讲通过推送方式的更新

首先为主工程开启推送功能,但不要使用registerNotifications()为ActivityKit推送通知注册您的实时活动,具体的注册方法见下.

1. APNs 认证方式选择

APNs认证方式分为两种:

1.cer证书认证

2.Token-Based认证方式

此处只能选择Token-Based认证方式,选择cer证书认证发送LiveActivity推送时,会报TopicDisallowed错误.

Token-Based认证方式的key生产方法 参见:Apple Documentation - Establishing a token-based connection to APNs

2. Liveactivity 的启动

let attributes = TestWidgetAttributes()
let initialConetntState = TestWidgetAttributes.TestWidgetState(dataModel: dataModel)
do {
let activity = try Activity.request(
attributes: attributes,
content: .init(state: initialConetntState, staleDate: nil),
pushType: .token//须使用此值,声明启动需要获取token
)
//判断启动成功后,获取推送令牌 ,发送给服务器,用于远程推送Live Activities更新
//不是每次启动都会成功,当已经存在多个Live activity时会出现启动失败的情况
print("请求开启实时活动: \(activity.id)")
Task {
for await pushToken in activity.pushTokenUpdates {
let pushTokenString = pushToken.reduce("") { $0 + String(format: "x", $1) }
//这里拿到用于推送的token,将该值传给后端
pushTokenDidUpdate(pushTokenString, pushToken);
}
}
} catch (let error) {
print("请求开启实时出错: \(error.localizedDescription)")
}

3. 模拟推送

1.可以使用terminal简单的构建推送,这里随便放上一个栗子

2.也可以使用这个工具 - SmartPushP8

此处使用SmartPushP8


发出推送后,在设备上查看推送结果即可.

注意:模拟器也是可以收到liveactivity的推送的但是需要满足:使用T2安全芯片 or 运行macOS13以上的 M1 芯片设备

四、问题排查

推送失败:

1.TooManyProviderTokenUpdates

测试环境对推送次数有一定的限制.尝试切换线路(sandbox / development)可以获得更多推送次数.

如果切换线路无法解决问题,建议重新run一遍工程,这样可以获得新的deviceToken,完成推送测试.

2.InvalidProviderToken / InternalServerError

尝试重新选择证书,重新run工程吧...暂时无解.

推送成功,但设备并未收到更新

这种情况需要打开控制台来观察日志

1.选择对应设备

2.点击错误和故障

3.过滤条件中添加这三项进程

liveactivitiesd
apsd
chronod

4.点击开始

在下方可以看到错误日志.

demo参考:demo


作者:大功率拖拉机
链接:https://juejin.cn/post/7254101170951192613
来源:稀土掘金

收起阅读 »

iOS 16 又又崩了

背景iOS 16 崩了: juejin.cn/post/715360…iOS 16 又崩了:juejin.cn/post/722551…本文分析的崩溃同样只在 iOS16 系统会触发,我们的 APP 每天有 2k+ 崩溃上报。崩溃原因:Cannot ...
继续阅读 »

背景

iOS 16 崩了: juejin.cn/post/715360…
iOS 16 又崩了:juejin.cn/post/722551…
本文分析的崩溃同样只在 iOS16 系统会触发,我们的 APP 每天有 2k+ 崩溃上报。

崩溃原因:

Cannot form weak reference to instance (0x1107c6200) of class _UIRemoteInputViewController. It is possible that this object was over-released, or is in the process of deallocation.
无法 weak 引用类型为 _UIRemoteInputViewController 的对象。可能是因为这个对象被过度释放了,或者正在被释放。weak 引用已经释放或者正在释放的对象会 crash,这种崩溃业务侧经常见于在 dealloc 里面使用 __weak 修饰 self。
_UIRemoteInputViewController 明显和键盘相关,看了下用户的日志也都是在弹出键盘后崩了。

崩溃堆栈:

0	libsystem_kernel.dylib	___abort_with_payload()
1 libsystem_kernel.dylib _abort_with_payload_wrapper_internal()
2 libsystem_kernel.dylib _abort_with_reason()
3 libobjc.A.dylib _objc_fatalv(unsigned long long, unsigned long long, char const*, char*)()
4 libobjc.A.dylib _objc_fatal(char const*, ...)()
5 libobjc.A.dylib _weak_register_no_lock()
6 libobjc.A.dylib _objc_storeWeak()
7 UIKitCore __UIResponderForwarderWantsForwardingFromResponder()
8 UIKitCore ___forwardTouchMethod_block_invoke()
9 CoreFoundation ___NSSET_IS_CALLING_OUT_TO_A_BLOCK__()
10 CoreFoundation -[__NSSetM enumerateObjectsWithOptions:usingBlock:]()
11 UIKitCore _forwardTouchMethod()
12 UIKitCore -[UIWindow _sendTouchesForEvent:]()
13 UIKitCore -[UIWindow sendEvent:]()
14 UIKitCore -[UIApplication sendEvent:]()
15 UIKitCore ___dispatchPreprocessedEventFromEventQueue()
16 UIKitCore ___processEventQueue()
17 UIKitCore ___eventFetcherSourceCallback()
18 CoreFoundation ___CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__()
19 CoreFoundation ___CFRunLoopDoSource0()
20 CoreFoundation ___CFRunLoopDoSources0()
21 CoreFoundation ___CFRunLoopRun()
22 CoreFoundation _CFRunLoopRunSpecific()
23 GraphicsServices _GSEventRunModal()
24 UIKitCore -[UIApplication _run]()
25 UIKitCore _UIApplicationMain()

堆栈分析

崩溃发生在系统函数内部,先分析堆栈理解崩溃的上下文,好在 libobjc 有开源的代码,极大的提高了排查的效率。

_weak_register_no_lock

抛出 fatal errr 最上层的代码,删减部分非关键信息后如下。

id 
weak_register_no_lock(weak_table_t *weak_table, id referent_id,
id *referrer_id, WeakRegisterDeallocatingOptions deallocatingOptions)
{
objc_object *referent = (objc_object *)referent_id;
if (deallocatingOptions == ReturnNilIfDeallocating ||
deallocatingOptions == CrashIfDeallocating) {
bool deallocating;
if (!referent->ISA()->hasCustomRR()) {
deallocating = referent->rootIsDeallocating();
}
else {
deallocating =
! (*allowsWeakReference)(referent, @selector(allowsWeakReference));
}

if (deallocating) {
if (deallocatingOptions == CrashIfDeallocating) {
_objc_fatal("Cannot form weak reference to instance (%p) of " <=== 崩溃
"class %s. It is possible that this object was "
"over-released, or is in the process of deallocation.",
(void*)referent, object_getClassName((id)referent));
} else {
return nil;
}
}
}
}

直接原因是  _UIRemoteInputViewController 实例的 allowsWeakReference 返回了 false。

options == CrashIfDeallocating 就会 crash。否则的话返回 nil。不过 CrashIfDeallocating 写死在了代码段,没有权限修改。整个 storeWeak 的调用链路上都没有可以 hook 的方法。

__UIResponderForwarderWantsForwardingFromResponder

调用 storeWeak 的地方反汇编

if (r27 != 0x0) {
r0 = [[&var_60 super] init];
r27 = r0;
if (r0 != 0x0) {
objc_storeWeak(r27 + 0x10, r25);
objc_storeWeak(r27 + 0x8, r26);
}
}

xcode debug r27 的值

<_UITouchForwardingRecipient: 0x2825651d0> - recorded phase = began, autocompleted phase = began, to responder: (null), from responder: (null)

otool 查看 _UITouchForwardingRecipient 这个类的成员变量

ivars          0x1cfb460 __OBJC_$_INSTANCE_VARIABLES__UITouchForwardingRecipient
entsize 32
count 4
offset 0x1e445d0 _OBJC_IVAR_$__UITouchForwardingRecipient.fromResponder 8
name 0x19c7af3 fromResponder
type 0x1a621c5 @"UIResponder"
alignment 3
size 8
offset 0x1e445d8 _OBJC_IVAR_$__UITouchForwardingRecipient.responder 16
name 0x181977f responder
type 0x1a621c5 @"UIResponder"

第一个 storeweak  赋值 offset 0x10 responder: UIResponder 取值 r25。

第二个 storeweak 赋值 offset 0x8 fromResponder: UIResponder 取值 r26。

XCode debug 采集 r25 r26 的值

到这里就比较清晰了,_UITouchForwardingRecipient 是在保存响应者链。其中_UITouchForwardingRecipient.responder = _UITouchForwardingRecipient.fromResponder.nextReponder(这里省略了一长串的证明过程,最近卷的厉害,没有时间整理之前的文档了)。崩溃发生在 objc_storeWeak(_UITouchForwardingRecipient.responder), 我们可以从 nextReponder 这个方法入手校验 responder 是否合法。

结论

修复方案

找到 nextresponder_UIRemoteInputViewController 的类,hook 掉它的 nextresponder 方法,在new_nextresponder 方法里面判断,如果 allowsWeakReference == NO 则 return nil
在崩溃的地址断点,可以找到这个类是 _UISizeTrackingView

- (UIResponder *)xxx_new_nextResponder {
    UIResponder *res = [self xxx_new_nextResponder];
    if (res == nil){
        return nil;
    }
    static Class nextResponderClass = nil;
    static bool initialize = false;
    if (initialize == false && nextResponderClass == nil) {
        nextResponderClass = NSClassFromString(@"_UIRemoteInputViewController");
        initialize = true;
    }

if (nextResponderClass != nil && [res isKindOfClass:nextResponderClass]) {
if ([res respondsToSelector:NSSelectorFromString(@"allowsWeakReference")]) {
BOOL (*allowsWeakReference)(id, SEL) =
(__typeof__(allowsWeakReference))class_getMethodImplementation([res class], NSSelectorFromString(@"allowsWeakReference"));
if (allowsWeakReference && (IMP)allowsWeakReference != _objc_msgForward) {
if (!allowsWeakReference(res, @selector(allowsWeakReference))) {
return nil;
}
}
}
}
return res;
}

友情提示

1. 方案里面涉及到了两个私有类,建议都使用开关下发,避免审核的风险。

2. 系统 crash 的修复还是老规矩,一定要加好开关,限制住系统版本,在修复方案触发其它问题的时候可以及时回滚,hook 存在一定的风险,这个方案 hook 的点相对较小了。

3. 我只剪切了核心代码,希望看懂并认可后再采用这个方案。

作者:yuec
链接:https://juejin.cn/post/7240789855138873403
来源:稀土掘金

收起阅读 »

iOS组件化初探

安装本地库,cd到Example文件下,进行pod install:具体执行如下图:打开Example文件夹中的工程:此时可以看到导入本地库成功:导入头文件,此时就可以愉快的,使用了三、制作多个本地库四、添加资源文件之后cd到Example文件夹中,打开工程,...
继续阅读 »

一、创建本地化组件化

首先创建一个存储组件化的文件夹:例如

组件化文件夹

cd到这个文件夹中,使用下边命令创建本地组件库
(注:我在创建的过程中,使用WiFi一直创建失败,后来连自己热点才能创建成功,可能跟我的网络有关系,这里加个提醒)

pod lib create UIViewcontroller_category_Module

之后会出出现创建组件的选项,如下图:


组件化创建选项
① 组件化适用的平台
② 组件化使用的语言
③ 组件化是否包含一个application
④ 组件化目前还不清楚是啥,直接选none即可
⑤ 组件化是否包含Test
⑥ 组件化文件的前缀


至此组件创建完成,此时会自动打开你创建的工程

二、 创建组件化功能

关闭当前工程,打开你创建的工程文件夹,在classes文件中,放入你的组件化代码,文件夹具体路径如下:


安装本地库,cd到Example文件下,进行pod install:具体执行如下图:


打开Example文件夹中的工程:


此时可以看到导入本地库成功:


导入头文件,此时就可以愉快的,使用了


三、制作多个本地库

关闭工程,重新cd到最外层文件夹


使用:

pod lib create Load_pic_Module

后续创建步骤,选项参照一

四、添加资源文件


之后cd到Example文件夹中,打开工程,在Load_pic_Module.podspec,添加图片资源的搜索路径,具体如下图所示:

# 加载图片资源文件
s.resource_bundles = {
'Load_pic_Module' => ['Load_pic_Module/Assets/*']
}


之后在命令行中,执行pod install指令,效果如下图所示:


(注:每次对组件进行修改时,每次都需要进行一次pod install,这个很重要,切记)

五、添加本地其他依赖库

还是在Load_pic_Module工程中进行引入,在Podfile中进行本地库引入

# 添加本地其他依赖库
pod 'UIViewcontroller_category_Module', :path => '../../UIViewcontroller_category_Module'


执行pod install

六、添加外部引用库

有时候,也需要一些从网上下载的三方库,例如afn,masonry等

# 添加额外依赖库
s.dependency 'AFNetworking'
s.dependency 'Masonry'

添加位置如下


添加效果图


七、全局通用引入

作用:类似prefix header

#  s.prefix_header_contents = '#import "LGMacros.h"','#import "Masonry.h"','#import "AFNetworking.h"','#import "UIKit+AFNetworking.h"','#import "CTMediator+LGPlayerModuleAction.h"'
s.prefix_header_contents = '#import "Masonry.h"'

多个引入看第一条,单个引入是第二条
注:改完记得pod install

收起阅读 »

手把手教你从零开始集成声网音视频功能(iOS版)

说明1.环信音视频和声网音视频 是两个不同的系统,所以如果要切换的话,需要集成声网的sdk,环信音视频的sdk可以直接废弃2.文章会介绍如何用声网的音视频跑通demo,可以了解整个音视频通话的流程,3.文章会介绍已经集成了环信im功能如何在集成声网添加音视频功...
继续阅读 »

说明

1.环信音视频和声网音视频 是两个不同的系统,所以如果要切换的话,需要集成声网的sdk,环信音视频的sdk可以直接废弃

2.文章会介绍如何用声网的音视频跑通demo,可以了解整个音视频通话的流程,

3.文章会介绍已经集成了环信im功能如何在集成声网添加音视频功能

前提条件

1.有环信开发者账号和声网的开发者账号

2.macOS系统,安装了xcode集成环境

跑通Demo

1.下载iOS Demo 地址:https://www.easemob.com/download/im

2.我这边下载的是4.0.3 版本,如果你的Xcode 版本运行demo报错的话,先找到podfile文件打开注释,并加上:config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '11.0’,如下图 ,在pod install

3.为了测试方便可以先把这个appkey 配置成自己的

4.连续点击版本号,切换成账号密码登录,到此im部分完成

搭建App Server生成声网token

2.如果出现Starting server at port 8082 说明搭建成功

3.在下图这里替换成自己声网的appid

4.在callDidRequestRTCTokenForAppId 这个方法做一下修改,主要是换成你自己的服务器生成的token,

5.以上修改完成就可以进行音视频通话了,如果通话正常可以去声网的控制台,看到通话记录。

到此恭喜你跑通Demo

把声网集成到已有项目中

说明:如果你之前集成环信的音视频,那么就直接废弃掉,从头集成声网音视频,我这边从新建项目开始

1.新建项目,并添加相应的库,pod install 一下,添加麦克风和摄像头权限

2.AppDelegate 文件里面进行环信初始化

3.使用xib 创建几个控件,并进行绑定

4.在 login点击事件调登录操作,登录成功之后进行EaseCallManager 类的初始化

注意:EaseCallManager只能在登录成功之后才能初始化,要不然发起通话会报错


5.实现EaseCallDelegate代理方法,需要在callDidRequestRTCToken回调中,获取APPserver的token,并设置,如下图

6.在call方法发起一对一视频通话,如下图

至此 代码完成,可以运行在两台设备上查看效果,如果能正常进行视频通话,那么恭喜你集成成功

总结

1.在环信控制台创建im项目,拿到appkey

2.在声网控制台创建音视频项目拿到appid 和 appCertificate

3.参考声网给的go语言的APPserver示例,全部复制下来,填上声网的appid 和 appCertificate,就直接运行

4.创建iOS项目,集成

pod 'AgoraRtcEngine_iOS/RtcBasic' //声网音视频库

pod 'HyphenateChat', '~> 4.0.3' //环信im库

pod 'EaseCallKit' //环信IMSDK作为信令封装的声网音视频SDK通话带UI组件库

这三个库

5.AppDelegate 文件里面进行环信初始化填上环信的appkey

6.登录成功的方法里面初始化EaseCallManager

7.发起视频通话邀请

8.邀请方和被邀请方都会走 func callDidRequestRTCToken(forAppId aAppId: String, channelName aChannelName: String, account aUserAccount: String, uid aAgoraUid: Int)
这个加入音视频通话频道前触发该回调,在这个回调函数里面获取各自的声网token,然后调用setRTCToken:channelName:方法将token设置进来

完毕

参考链接

收起阅读 »

《环信十周年趴——我的程序人生之一路向西》

六年前,我毕业于一个著名的计算机学院。在校期间,我就非常热爱计算机专业,对编程有着浓厚的兴趣。就像很多人一样,我梦想能写出自己的程序,让它变得更好。 于是我开始了另一段工作旅程。我加入了一家小程序公司,开始专注于小程序的开发,这是新的开始,也是全新的挑战。在这...
继续阅读 »

六年前,我毕业于一个著名的计算机学院。在校期间,我就非常热爱计算机专业,对编程有着浓厚的兴趣。就像很多人一样,我梦想能写出自己的程序,让它变得更好。


当我进入我的第一家公司时,我的兴奋和期待之情无以言表。这家公司以开发iOS应用为主,我也开始从事iOS开发。在这个公司里,我经历了很多挑战和机遇。在这里我学到了很多关于软件开发的知识,也养成了很好的开发习惯和团队协作能力。我从一名初学者变成了一个熟练的iOS开发工程师。但是,在某个阶段,我突然发现自己好像停滞不前,感到很无聊,也觉得缺乏动力。


于是我开始了另一段工作旅程。我加入了一家小程序公司,开始专注于小程序的开发,这是新的开始,也是全新的挑战。在这家公司里,我需要从头开始学习新的开发技能,适应小程序的开发环境和工作方式。在这个过程中,我也发现了小程序和iOS尽管有着共同的底层技术,但是却有着截然不同的开发方式,和值得深入研究的地方。在这家公司里,我经历了团队的合作,让我感受到了小程序技术能够如何影响一个团队的凝聚力和升华。


作为一个开发者,我非常喜欢关注新技术,不断地尝试新东西。这让我尝试学习 Flutter,并在一家制造业公司担任 Flutte 工程师,继续我的职业生涯。Flutter 能够提供极高的开发效率和跨平台兼容性,这让我非常留下深刻的印象。同时在这家公司里,我应对更为复杂和有挑战性的技术难题,这让我不断成长和进步。


除了不断学习新技能,我的程序人生也因为自己的勇气而得以改变,我曾在几年间换过不同的公司和城市。我从一个陌生的城市一步一步地适应过来,也从完全新的团队和开发环境中学会自我调节和协作。换工作或换城市,可能会让你失去一些舒适和熟悉的东西,但是也会给你带来新的成长和机会。


这六年的程序人生,让我成长为一个更加成熟和自信的人。我已经拥有了丰富的代码编写经验和技术能力,同时也学会了如何处理工作上的各种挑战,看各种複雜问题,并持续保持了学习的动力和热情。虽然这些年我经历了很多疲惫和挑战,但我也再一次发现自己的阻力和激情,让我不断前进并充满信心地继续我的程序人生。

收起阅读 »

环信十周年趴——我的程序人生

我是一名网瘾少年...记得上小学那会,每天中午我都会去学校的机房打传奇,那时候跟着老师一起弄了一个私服,感觉很牛,可能正是因为这个,才开始我的计算机编码启蒙。然而,也仅仅是启蒙了,初中和高中沉迷在了游戏中,不可自拔;也正因为如此,考了一个普通的大学,还得服从调...
继续阅读 »

我是一名网瘾少年...

记得上小学那会,每天中午我都会去学校的机房打传奇,那时候跟着老师一起弄了一个私服,感觉很牛,可能正是因为这个,才开始我的计算机编码启蒙。

然而,也仅仅是启蒙了,初中和高中沉迷在了游戏中,不可自拔;也正因为如此,考了一个普通的大学,还得服从调剂,但万幸的是专业是计算机科学与技术,我可以早一年在寝室配电脑。

整个大学依然沉迷游戏,颓废度过,到了大四,由于别的同学已经有找到实习工作的了,我才开始出现焦虑;为了未来,我踏上了去北京的火车,去学习iOS开发,当时是2015年,已经过了最火爆的时候,学成之后,我在北京四处碰壁,有些是因为我没有工作经验,有些则是因为我是培训出身,苦熬半个月,马上过年了,没办法只能打道回府。

回到老家本想着过完年再战北京,但阴差阳错,我在老家找了一份iOS开发工作,工作稳定,挣得钱够花,也就渐渐放弃了北京梦。

如今,我已在iOS开发这个领域做了6年多,在不断学习中,有很多收获,同时也用业余的时间学习python和MySQL,安卓也有涉猎,并且微信小程序可以接私活,挣外快;我坚信,继续坚持自我的修行之路,不断的提高自己的技能,一定能成为更加优秀的程序员。

生活虽然平淡如水,但总能在不经意间有一些小收获,我想,这也算一种幸福的生活。

最后,环信真的是一款优秀的产品,文档通俗易懂,接口功能丰富,在这个环信十周年之际,我祝愿环信越办越好,发展壮大,奋勇向前。

收起阅读 »

《环信十周年趴——程序之路也有得失,不必介怀》

我的程序生涯可谓是充满了曲折和成长的旅程。从一开始的业余爱好,到如今的职业发展,我经历了许多挑战和机遇,也积累了不少宝贵的经验。回顾起来,我最初接触编程是在大学期间。当时,我被计算机的神秘和无限可能性所吸引,开始学习编写简单的代码。起初,我对编程还不太熟悉,但...
继续阅读 »


我的程序生涯可谓是充满了曲折和成长的旅程。从一开始的业余爱好,到如今的职业发展,我经历了许多挑战和机遇,也积累了不少宝贵的经验。

回顾起来,我最初接触编程是在大学期间。当时,我被计算机的神秘和无限可能性所吸引,开始学习编写简单的代码。起初,我对编程还不太熟悉,但是通过不断的学习和实践,我渐渐掌握了一些基本的编程语言和技巧。

毕业后,我决定将编程作为我的职业。我投身于软件开发行业,从一名初级程序员开始。在职场中,我面对了各种项目和团队合作的挑战。通过不断学习和与同事的交流,我的编程能力得到了提升,我也逐渐熟悉了软件开发的流程和方法。

随着时间的推移,我逐渐担任更高级的职位,并开始负责一些重要项目的开发。同时,我也积极追求自我提升,不断学习新的编程技术和工具。我学习了机器学习和数据科学的知识,掌握了一些流行的开发框架和库。这些新技能不仅提升了我的职业竞争力,还让我能够解决更加复杂的问题。

在职场上,我也遇到了一些奇葩和不愉快的经历。有一次,我加入了一家初创公司,他们开发了一款虚拟现实游戏。我被聘为首席程序员,负责游戏的核心功能开发。开始时,我对这个机会充满了期待,希望能够在这个新兴行业有所突破。

然而,不久之后,我发现这家公司的管理层存在着一些奇葩的决策和不合理的要求。他们对于开发进度的期望过高,要求我们在短时间内完成大量的工作。同时,他们也没有给予足够的资源和支持,导致我们在技术上遇到了很多困难。

更糟糕的是,公司的管理层对于员工的待遇也非常吝啬。工资低于行业平均水平,福利待遇简直可以说是微乎其微。而且,他们还经常加班,但却不提供加班补偿。这让我感到非常不满和失望。

在与同事的交流中,我发现大家都对公司的管理方式感到不满。许多人都在考虑离职,寻找更好的机会。尽管我对这个项目充满了热情,但最终我还是做出了离职的决定。

离开这家公司后,我感到一种解脱和自由。我决定重新评估自己的职业规划,并寻找更好的工作环境。我参加了一些技术研讨会和行业活动,扩展了人脉和知识面。

通过努力和坚持,我最终找到了一家知名游戏开发公司的工作机会。这家公司有着良好的声誉和优秀的团队氛围。在这里,我得到了更好的薪资待遇和职业发展机会。与同事们的合作也非常愉快,他们互相支持和激励,共同追求技术的进步和项目的成功。

在新的公司,我不仅继续提升自己的技术能力,还积极参与项目的管理和领导工作。我逐渐晋升为高级程序员,并负责指导和培养新人。我享受着这种成长和发展的过程,同时也在职业道路上收获了不断增长的薪资。

除了职业发展,我还热衷于扩展自己的知识领域。我广泛阅读与编程相关的书籍,不仅加深了对技术的理解,还开拓了思维的广度。这些书籍不仅拓宽了我的知识面,也为我在工作中遇到的问题提供了解决思路。

在编程道路上,我结识了许多优秀的同行和导师。他们在我职业发展中起到了关键的作用。他们与我分享自己的经验和知识,给予了我很多指导和支持。有时候,在解决问题的过程中,他们的帮助让我事半功倍。

总结而言,我的程序生涯经历了起伏和挑战,但也收获了许多成长和成功。通过不断学习和努力,我掌握了新的技能,取得了薪资的增长,结识了良师益友。我学会了在职场中勇敢面对困难,果断做出改变并寻找更好的机会。这些经历让我明白了职业选择的重要性,一个良好的工作环境和管理团队对于个人的成长和幸福感至关重要。

在我的职业规划中,我也意识到了不断学习和适应新技术的重要性。随着科技的迅猛发展,编程领域也在不断演进。我持续关注行业的趋势,并主动学习新的编程语言、框架和工具。这使我能够跟上潮流,提升自己的竞争力,并为公司的发展做出贡献。

此外,我也始终注重个人的硬件装备。一台高效的电脑和适合编程需求的工具是提高工作效率的关键。我不断更新我的硬件设备,并保持其良好状态,以确保在工作中能够高效地完成任务。

在这个职业生涯中,我经历了职场的起伏和挑战,但我始终坚持不懈地追求自己的梦想和目标。通过遇到的困难和不愉快的经历,我学会了坚持和勇敢面对挑战,也学会了在逆境中寻找机会和改变。

通过不断学习、拓展技能、寻找良师益友和适应职业发展的机会,我在程序生涯中取得了成长和进步。我不仅拥有了稳定的职业发展和增长的薪资,还培养了自己的领导能力和团队合作精神。

总的来说,程序生涯是一段充满挑战和机遇的旅程。通过坚持不懈的努力和持续学习,我在职业道路上取得了一定的成就。我相信,只要保持对技术的热情和对自我提升的追求,我将继续在编程的世界中不断成长和创造出更多的价值。

自己总结了一句话。

对于命运,不必抱怨什么。因为,你就是你的上帝。

                                                              ---- 致自己

收起阅读 »

iOS推送证书不受信任

问题:iOS推送证书不受信任 问题分析: 苹果已经使用了新的签名证书。 原文: Apple Worldwide Developer Relations Intermediate Certificate Expiration 解决方法: 打开苹果官方证书下载链接...
继续阅读 »

问题:iOS推送证书不受信任



问题分析:


苹果已经使用了新的签名证书。

原文: Apple Worldwide Developer Relations Intermediate Certificate Expiration


解决方法:


打开苹果官方证书下载链接:Apple PKI




下载G4证书,安装一下就可以了

收起阅读 »

移动端页面加载耗时监控方案

iOS
本文阐述了个人对移动端页面加载耗时监控的一些理解,主要从:节点划分及对应的实现方案,线上监控注意点,后续还能做的事 三个方面来和大家分享。前言移动端的页面加载速度,作为最为影响用户体验的因素之一,是我们做移动端性能优化的重点方向之一。而优化的效果体现,需要置信...
继续阅读 »

本文阐述了个人对移动端页面加载耗时监控的一些理解,主要从:节点划分及对应的实现方案,线上监控注意点,后续还能做的事 三个方面来和大家分享。

前言

移动端的页面加载速度,作为最为影响用户体验的因素之一,是我们做移动端性能优化的重点方向之一。

而优化的效果体现,需要置信的指标进行衡量(常见方法论:寻找方向->确定指标->实践->量化收益),而本文想要分享的就是:如何真实、完整、方便的获得页面加载时间,并会向线上监控环节,有一定延伸。

本文的示例代码都是OC(因为Java和kotlin我也不会😅),但相关思路和方案也适用于Android(Android端已实现并上线)。

页面加载耗时

常见方案

页面加载时长是一直以来大家都在攻坚的方向,所以市面上也有非常非常多的度量方案,从节点划分角度看:

较为基础的:ViewController 的 init -> viewDidLoad -> viewDidAppear

更进一步的:ViewController 的 init -> viewDidLoad -> viewDidAppear -> user Interactable

主流方案:ViewController 的 init -> viewDidLoad -> viewDidAppear -> view render completed -> user Interactable

还有什么地方可以改进的吗?

对于这些成熟方案,我还有什么可以更进一步的吗?主要总结为以下几个方面吧:

  • 完整反映用户体感

我们做性能优化,归根结底,更是用户体验优化,在满足功能需要的同时,不影响用户的使用体验。 所以,我个人认为,大多数的性能指标,都要考虑到用户体验这个方向;页面启动速度这一块,更是如此;而传统的方案,能够完整的反应用户体感吗? 我觉得还是有一部分的缺失的:用户主动发起交互到ViewController这个阶段。这一部分有什么呢,不就是直接tap触发的action里vc就初始化了吗? 实际在一些较为复杂、大型的项目中,并不然,中间可能会有很多其他处理,例如:方法hook、路由调度、参数解析、containerVC的初始化、动态库加载等等。这一部分的耗时,实际上也是用户体感的一部分,而这一部分的耗时,如果不加监控的话,也会对整体耗时产生劣化。(这里可能会有小伙伴问了,这些东西,不应该由各自负责的同学,例如负责路由的同学,自行监控吗?这里我想阐述的一个观点时,时长类的监控,如果由几个时间段拼接,相比于endTime - startTime,难免会产生gap,即,加入endTime = 10,startTime = 0,那么中间分成两段,很有可能endTime2 = 10,startTime2 = 6;endTime1 = 4,startTime1 = 0,造成总时长不准。总而言之,还是希望得到一个能够完整反映用户体感的时长。)

  • 数据采集与业务解耦

这一点其实市面上的很多方案已经做得很好了。解耦,一方面是为了,提效:避免后续有新的页面需要监控时,需要进行新的开发;另一方面,也是避免业务迭代对于监控数据的影响:如果是手动侵入性埋点,很难保证后续新增的耗时任务对监控数据不产生影响。 而本文方案,不需要在业务代码中插入任何代码,大都是通过方法hook来实现数据采集的;而对范围、以及匹配关系等的控制,也都是通过配置来完成的。

具体实现

节点确定&数据采集方式


根据一个页面(ViewController)的加载过程中,开发主要进行的处理,以及可能对用户体感产生影响的因素,将页面加载过程划分为如上图所示的11个节点,具体解释及实现方案如下:

1. 用户行为触发页面跳转

由于页面的跳转一般是通过用户点击、滑动等行为触发的,因此这里监听用户触摸屏幕的时间点;但有效节点仅为VC在初始化前的最后一次点击/交互。

具体实现: hook UIWidow 的 sendEvent:方法,在swizzle方法内记录信息;为了性能考虑,目前仅记录一个uint64_t的时间戳,且仅内存写; 注意这里需要记录手指抬起的时间,即 touch.phase == UITouchPhaseEnded,因为一般action被调用的时机就是此时; 同时,为了适配各种行为触发的新页面出现,还增加了一个手动添加该节点的方法,使一些较复杂且不通用,业务特性较强的初始化场景,也能够有该节点数据,且不依赖hook;但注意该手动方法为侵入式数据采集方式。

2. ViewController的初始化

具体实现:hook UIViewController或你的VC基类 的 - (instancetype)init 的方法;

3. 本地UI初始化

不依赖于网络数据的UI开始初始化。

这个节点,我实际上并没有在本次实现,这里的一个理想态是:将这部分行为(即UI初始化的代码),通过协议的方式,约束到指定方法中;例如,架构层面约束一个setupSubviews的接口,回调给各业务VC,供其进行基础UI绘制(目前这种方式再一些更复杂的业务场景下实现并运行较好);有这个基础约束的前提下,才能准确的采集我理想中该节点的耗时。而我目前所负责的模块,并没有这种强约束,而又不能简单的去认为所有基础UI都是在viewDidLoad中去完成的。因此需要 对原有架构的一定修改 或 能够保证所有基础UI行为都在viewDidLoad中实现,才能够实现该节点数据的准确采集。 因此2 ~ 3和3 ~ 4间的耗时,被融合为了一段2 ~ 4的耗时。

4. 本地UI初始化完成

不依赖于网络数据的UI初始化完成。

具体实现:监听主线程的闲时状态,VC初始化 节点后的首个闲时状态表示 本地UI初始化完成;(闲时状态即runloop进入kCFRunLoopBeforeWaiting

5. 发起网络请求

调用网络SDK的时间点。

这里描述的就是上面的节点划分图的第二条线,因为两条线的节点间没有强制的线性关系,虽然图中当前节点是放在了VC初始化平行的位置,但实际上,有些实现会在VC初始化之前就发起网络请求,进行预加载,这种情况在实现的时候也是需要兼容的。

具体实现:hook 业务调用网络SDK发起请求方法的api;这里的网络库各家实现方案就可能有较大差异了,根据自身情况实现即可。

6. 网络SDK回调

网络SDK的回调触发的时间点。

具体实现:hook 网络SDK向业务层回调的api;差异性同5。

7. send request
8. receive response

真正 发出网络请求 和 收到response 的时间点,用于计算真正的网络层耗时。 这俩和5、6是不是重复了啊?并不然,因为,网络库在接收到发起网络请求的请求后,实际上在端阶段,还会进行很多处理,例如公参的处理、签名、验签、json2Model等,都会产生耗时;而真正离开了端,在网上逛荡那一段,更是几乎“完全不可控”的状态。所以,分开来统计:端部分 和 网络阶段,才能够为后续的优化提供数据基础,这也是数据监控的意义所在

具体实现: 实际上系统网络api中就有对网络层详细性能数据的收集

- (void)URLSession:(NSURLSession *)session 
             task:(NSURLSessionTask *)task
didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics;

根据官方文档中的描述


可以发现,我们实际上需要的时长就是从 fetchStartDateresponseEndDate 间的时间。 因此可以该delegate,获取这两个时间点。

9. 详细UI初始化

详细UI指,依赖于网络接口数据的UI,这部分UI渲染完成才是页面达到对用户可见的状态。

具体实现:这里我们认为从网络SDK触发回调时,即开始进行详细UI的渲染,因此该节点和节点6是同一个节点。

10. 详细UI渲染完成

页面对用户来说,真正达到可见状态的节点。

具体实现: 对于一个常规的App页面来说,如何定义一个页面是否真正渲染完成了呢?

被有效的视图铺满

什么是有效视图呢?视频,图片,文字,按钮,cell,能向用户传递信息,或者产生交互的view; 铺满,并不是指完全铺满,而是这些有效视图填充到一定比例即可,因为按照正常的视觉设计和交互体验,都不会让整个屏幕的每一个像素点都充满信息或具备交互能力;而这个比例,则是根据业务的不同而不同的。 下面则是上述逻辑的实现思路:

确定有效视图的具体类
UITextView 
UITextField
UIButton
UILabel
UIImageView
UITableViewCell
UICollectionViewCell

主流方案中比较常见的,是前几种类,并不包括最后的两个cell;而这里为什么将cell也作为有效视图类呢? 首先,出于业务特征考虑,目前应用该套监控方案的页面,主要是以卡片列表样式呈现的;而且个人认为,市面上很多App的页面也都是列表形式来呈现内容的;当然,如果业务特征并不相符,例如全屏的视频播放页,就可以不这样处理。 其次,将cell作为有效视图,确实能够极大的降低每次计算覆盖率的耗时的。性能监控本身产生的性能消耗,是性能方向一直以来需要着重关注的点,毕竟你一个为了性能优化服务的工具,反而带来了不小的劣化,怎样也说不太过去啊😂~ 我也测试了是否包含cell对计算耗时的影响: 下表中为,在一个层级较为复杂的业务页面,页面完全渲染完成之后,完成一次覆盖率达到阈值的扫描所需的时长。

有效视图包含 cell不包含 cell
检测一次覆盖率耗时(ms)1~515~18
耗时减少15ms/次(83%)

而且,有效视图的类,建议支持在线配置,也可以是一些自定义类。

将cell作为有效视图,大家可能会产生一个新的顾虑:占位cell的情况,再具体点,就是常见的骨架图怎么办?骨架图是什么,就是在网络请求未返回的时候,用缓存的data或者模拟样式,渲染出一个包含大致结构,但不包含具体内容的页面状态,例如这种:

这种情况下,cell已经铺满了屏幕,但实际上并未完成渲染。这里就要依赖于节点的前后顺序了,详细UI是依赖于网络数据的,而骨架图是在网络返回之前绘制完成的,所以真正的覆盖率计算,是从网络数据返回开始的,因此骨架图的填充完成节点,并不会被错误统计未详细UI渲染完成的节点。

覆盖率的计算方式


如上图所示,开辟两个数组a、b,数组空间分别为屏幕长宽的像素数,并以0填充,分别代表横纵坐标; 从ViewController的view开始递归遍历他的subView,遇见有效视图时,将其frame的width和height,对应在数组a、b中的range的内存空间,都填充为1,每次遍历结束后,计算数组a、b中内容为1的比例,当达到阈值比例时,则视为可见状态。 示例代码如下:

- (void)checkPageRenderStatus:(UIView *)rootView {
  if (kPhoneDeviceScreenSize.width <= 0 || kPhoneDeviceScreenSize.height <= 0) {
      return;
  }

  memset(_screenWidthBitMap, 0, kPhoneDeviceScreenSize.width);
  memset(_screenHeightBitMap, 0, kPhoneDeviceScreenSize.height);

  [self recursiveCheckUIView:rootView];
}

- (void)recursiveCheckUIView:(UIView *)view {
  if (_isCurrentPageLoaded) {
      return;
  }

  if (view.hidden) {
      return;
  }

  // 检查view是否是白名单中的实例,直接用于填充bitmap
  for (Class viewClass in _whiteListViewClass) {
      if ([view isKindOfClass:viewClass]) {
          [self fillAndCheckScreenBitMap:view isValidView:YES];
          return;
      }
  }

  // 最后递归检查subviews
  if ([[view subviews] count] > 0) {
      for (UIView *subview in [view subviews]) {
          [self recursiveCheckUIView:subview];
      }
  }
}

- (BOOL)fillAndCheckScreenBitMap:(UIView *)view isValidView:(BOOL)isValidView {

  CGRect rectInWindow = [view convertRect:view.bounds toView:nil];

  NSInteger widthOffsetStart = rectInWindow.origin.x;
  NSInteger widthOffsetEnd = rectInWindow.origin.x + rectInWindow.size.width;
  if (widthOffsetEnd <= 0 || widthOffsetStart >= _screenWidth) {
      return NO;
  }
  if (widthOffsetStart < 0) {
      widthOffsetStart = 0;
  }
  if (widthOffsetEnd > _screenWidth) {
      widthOffsetEnd = _screenWidth;
  }
  if (widthOffsetEnd > widthOffsetStart) {
      memset(_screenWidthBitMap + widthOffsetStart, isValidView ? 1 : 0, widthOffsetEnd - widthOffsetStart);
  }

  NSInteger heightOffsetStart = rectInWindow.origin.y;
  NSInteger heightOffsetEnd = rectInWindow.origin.y + rectInWindow.size.height;
  if (heightOffsetEnd <= 0 || heightOffsetStart >= _screenHeight) {
      return NO;
  }
  if (heightOffsetStart < 0) {
      heightOffsetStart = 0;
  }
  if (heightOffsetEnd > _screenHeight) {
      heightOffsetEnd = _screenHeight;
  }
  if (heightOffsetEnd > heightOffsetStart) {
      memset(_screenHeightBitMap + heightOffsetStart, isValidView ? 1 : 0, heightOffsetEnd - heightOffsetStart);
  }

  NSUInteger widthP = 0;
  NSUInteger heightP = 0;
  for (int i=0; i< _screenWidth; i++) {
      widthP += _screenWidthBitMap[i];
  }
  for (int i=0; i< _screenHeight; i++) {
      heightP += _screenHeightBitMap[i];
  }

  if (widthP > _screenWidth * kPageLoadWidthRatio && heightP > _screenHeight * kPageLoadHeightRatio) {
      _isCurrentPageLoaded = YES;
      return YES;
  }

  return NO;
}

但是也会有极端情况(类似下图)


无法正确反应有效视图的覆盖情况。但是出于性能考虑,并不会采用二维数组,因为w*h的量太大,遍历和计算的耗时,会有指数级的激增;而且,正常业务形态,应该不太会有类似的极端形态。

即使真的会较高频的出现类似情况,也有一套备选方案:计算有效视图的面积 占 总面积 的比例;该种方式会涉及到UI坐标系的频繁转换,耗时也会略差于当前的方式。

在某些业务场景下,例如 无/少结果情况,关于页面等,完全渲染后,也无法达到铺满阈值。 这种情况,会以用户发生交互(同 1、用户行为触发页面跳转 的获取方式)和 主线程闲时状态超过5s (可配)来做兜底,看是否属于这种状态,如果是,则相关性能数据不上报,因为此种页面对性能的消耗较正常铺满的情况要低,并不能真实的反应性能消耗、瓶颈,因此,仅正常铺满的业务场景进行监控并优化,即可。

扫描的触发时机

以帧刷新为准,因为只有每次帧刷新后,UI才会真正产生变化;出于性能考虑,不会每帧都进行扫描,每间隔x帧(x可配,默认为1),扫描一次;同时,考虑高刷屏 和 大量UI绘制时会丢帧 的情况,设置 扫描时间间隔 的上下限,即:满足 隔x帧 的前提下,如果和上次扫描的时间差小于 下限,仍不扫描;如果 某次扫描时,和上次扫描的时间间隔 大于 上限,则无论中间隔几帧,都开启一次扫描。

11. 用户可交互

用户可见之后的下一个对用户来说至关重要的节点。如果只是可见,然后就疯狂占用主线程或其他资源,造成用户的点击等交互行为,还是会被卡主,用户只能看,不能动,这个体感也是很差的;

具体实现:详细UI渲染完成 后的 首次主线程闲时状态。

监控方案

这里由于各家的基建并不相同,因此只是总结一些小的建议,可能会比较零散,大家见谅。

  1. 建议采样收集

首先,数据的采集或者其他的新增行为/方法,一定是会产生耗时的,虽然可能不多,但还是秉着尽善尽美的原则,还是能少点就少点的,所以数据的采集,包括前面的hook等等一切行为,都只是随机的面向一部分用户开放,降低影响范围; 而且,如果数据量极大,全量的数据上报,其实对数据链路本身也会产生压力、增加成本。 当前,采样的前提是基本数据量足够,不然的话,采样样本量过小,容易对统计结果产生较大波动,造成不置信的结果。

  1. 可配置

除了基本的是否开启的开关之外,还有其他的很多的点 需要/可以/建议 使用线上配置控制。个人认为,线上配置,除了实现对逻辑的控制,更重要的一个作用,就是出现问题时及时止损。 举一些我目前使用的配置中的例子: - 有效视图类 - 渲染完成状态,横纵坐标的填充百分比阈值 - 终态的兜底阈值 - VC的类名、对应的网络请求 等等。

  1. 本地异常数据过滤

由于我们的样本数据量会非常大,所以对于异常数据我们不需要“手软”,我们需要有一套本地异常数据过滤的机制,来保证上报的数据都是符合要求的;不然我们后续统计处理的时候,也会因此出现新的问题需要解决。

后续还能做的事

这一部分,是对后续可实现方案的一个美好畅想~

1)页面可见态的终点,不只是覆盖率

其实,实际业务场景中,很多cell,即使绘制完,并渲染到屏幕上,此时,用户可见的也没有达到我们真正希望用户可见的状态,很多内容,都还是一个placeholder的状态。例如,通过url加载的image,我们一般都是先把他的size算好,把他的位置留好,cell渲染完就直接展示了;再进一步,如果是一个视频的播放卡片,即使网络图片加载好了,还要等待视频帧的返回,才能真正达到这张卡片的业务终态\color{red}{业务终态}业务终态(求教这里标红后如何能够让字体大小一致)。

这个非常后置,而且我们端上可能也影响不了什么的节点,采集起来有意义吗?

我觉得这是一个非常有价值的节点。一直都在说“技术反哺业务”,那么业务想要用户真正看到的那个终态,就是很重要的一环;因此,用户能在什么时间点看到,从业务角度说,能够影响其后续的方案设计(表现形式),完善用户体感对业务指标的影响;从技术角度说,可以感知真实的全链路的表现(不只是端),从而有针对性的进行优化。

如何获取到所有的业务终态呢?

这里一定是和业务有所耦合的,因为每个业务的终态,只有业务自身才知道;但是我们还是要尽量降低耦合度。 这里可以用协议的方式,为各个业务增加一个达到终态的标识,那么在某个业务达到终态之后,设置该标识即可,这里就是唯一对业务的侵入了;然后和计算覆盖率类似,这里的遍历,是业务维度(这里想象为卡片更好理解一点),只有全部业务的标识都ready之后,才是真正达到业务上的终态。

2)性能指标 关联 业务行为

其实,现在性能监控,各类平台,各个团队,或多或少的都在做,我相信,性能数据采集的代码,在工程中,也不仅仅只有一份;这个现状,在很多成一定规模的互联网公司中都可能存在。

而如果您和我一样,作为一个业务团队,如何在不重复造轮子的情况下,夹缝中求生存呢?

我个人目前的理解:将 性能表现 与 业务场景 相关联。

帧率、启动耗时、CPU、内存等等,这些性能指标数据的获取,在业界都有非常成熟的方案,而且我们的工程里,一定也有相关的代码;而我们能做的,仅仅是,调一下人家的api,然后把数据再自己上传一份(甚至有的连上传都包含了),就完事了吗?

这样我觉得并不能体现出我们自建监控的价值。个人理解,监控的意义在于:暴露问题 + 辅助定位问题 + 验证问题的解决效果

所以我们作为业务团队,将 性能数据 和 我们的业务做了什么 bind 到一起了,是不是就能一定程度上完成了上面的目的呢?


我们可以明确,我们什么样的业务行为,会影响我们的性能数据,也就是影响我们的用户基础体验。这样,不仅会帮助我们定位问题的原因,甚至会影响产品侧的一些产品能力设计方案。

完成这些建设之后,可能我们的监控就可以变成这样,甚至更好的状态:


3)完善全链路对性能表现的关注

性能数据的关注、监控,不应该仅仅在线上阶段,开发期 → 测试期 → 线上,全链路各个环节都应该具有。

  • 目前各家都比较关注线上监控,相信都已经较为完善;

  • 测试期的业务流程性能脚本;对于测试的性能测试方案,开放应该参与共建或者有一定程度的参与,这样才能从一定程度上保证数据的准确性,以及双方性能数据的相互认可;

  • 开发期,目前能够提供展示实时CPU、FPS、内存数据的基础能力的工具很常见,也比较容易实现;但实际上,在日常开发的过程中,很难让RD同时关注需求情况与性能数据表现。因此,还是需要一些工具来辅助:例如,我们可以对某些性能指标,设置一些阈值,当日常开发中,超过阈值时,则弹窗提醒RD确认是否原因、是否需要优化,例如,详细UI绘制阶段的耗时阈值是800ms,如果某位同学在进行变更后,实际绘制耗时多次超越该值,则弹窗提醒。

作者:XTShow
来源:juejin.cn/post/7184033051289059384

收起阅读 »

Sourcery 的 Swift Package 命令行插件

什么是Sourcery?Sourcery 是当下最流行的 Swift 代码生成工具之一。其背后使用了 SwiftSyntax[1],旨在通过自动生成样板代码来节省开发人员的时间。Sourcery 通过扫描一组输入文件,然后借助模板的帮助,自动生成模板中定义的 ...
继续阅读 »

什么是Sourcery?

Sourcery 是当下最流行的 Swift 代码生成工具之一。其背后使用了 SwiftSyntax[1],旨在通过自动生成样板代码来节省开发人员的时间。Sourcery 通过扫描一组输入文件,然后借助模板的帮助,自动生成模板中定义的 Swift 代码。

示例

考虑一个为摄像机会话服务提供公共 API 的协议:

protocol Camera {
func start()
func stop()
func capture(_ completion: @escaping (UIImage?) -> Void)
func rotate()
}

当使用此新的 Camera service 进行单元测试时,我们希望确保 AVCaptureSession 没有被真的创建。我们仅仅希望确认 camera service 被测试系统(SUT)正确的调用了,而不是去测试 camera service 本身。
因此,创建一个协议的 mock 实现,使用空方法和一组变量来帮助我们进行单元测试,并断言(asset)进行了正确的调用是有意义的。这是软件开发中非常常见的一个场景,如果你曾维护过一个包含大量单元测试的大型代码库,这么做也可能有点乏味。
好吧~不用担心!Sourcery 会帮助你!⭐️ 它有一个叫做 AutoMockable[2] 的模板,此模板会为任意输入文件中遵守 AutoMockable 协议的协议生成 mock 实现。

注意:在本文中,我扩展地使用了术语 Mock,因为它与 Sourcery 模板使用的术语一致。Mock 是一个相当重载的术语,但通常,如果我要创建一个 双重测试[3],我会根据它的用途进一步指定类型的名称(可能是 Spy 、 Fake 、 Stub 等)。如果您有兴趣了解更多关于双重测试的信息,马丁·福勒(Martin Fowler)有一篇非常好的文章,可以解释这些差异。

现在,我们让 Camera 遵守 AutoMockable。该接口的唯一目的是充当 Sourcery 的目标,从中查找并生成代码。

import UIKit

// Protocol to be matched
protocol AutoMockable {}

public protocol Camera: AutoMockable {
func start()
func stop()
func capture(_ completion: @escaping (UIImage?) -> Void)
func rotate()
}

此时,可以在上面的输入文件上运行 Sourcery 命令,指定 AutoMockable 模板的路径:

sourcery --sources Camera.swift --templates AutoMockable.stencil --output .

💡 本文通过提供一个 .sourcery.yml 文件来配置 Sourcery 插件。如果提供了配置文件或 Sourcery 可以找到配置文件,则将忽略与其值冲突的所有命令行参数。如果您想了解有关配置文件的更多信息,Sourcery的 repo 中有一节[4]介绍了该主题。
命令执行完毕后,在输出目录下会生成一个 模板名 加 .generated.swift 为后缀的文件。在此例是 ./AutoMockable.generated.swift:

// Generated using Sourcery 1.8.2 — https://github.com/krzysztofzablocki/Sourcery
// DO NOT EDIT
// swiftlint:disable line_length
// swiftlint:disable variable_name

import Foundation
#if os(iOS) || os(tvOS) || os(watchOS)
import UIKit
#elseif os(OSX)
import AppKit
#endif

class CameraMock: Camera {

//MARK: - start

var startCallsCount = 0
var startCalled: Bool {
return startCallsCount > 0
}
var startClosure: (() -> Void)?

func start() {
startCallsCount += 1
startClosure?()
}

//MARK: - stop

var stopCallsCount = 0
var stopCalled: Bool {
return stopCallsCount > 0
}
var stopClosure: (() -> Void)?

func stop() {
stopCallsCount += 1
stopClosure?()
}

//MARK: - capture

var captureCallsCount = 0
var captureCalled: Bool {
return captureCallsCount > 0
}
var captureReceivedCompletion: ((UIImage?) -> Void)?
var captureReceivedInvocations: [((UIImage?) -> Void)] = []
var captureClosure: ((@escaping (UIImage?) -> Void) -> Void)?

func capture(_ completion: @escaping (UIImage?) -> Void) {
captureCallsCount += 1
captureReceivedCompletion = completion
captureReceivedInvocations.append(completion)
captureClosure?(completion)
}

//MARK: - rotate

var rotateCallsCount = 0
var rotateCalled: Bool {
return rotateCallsCount > 0
}
var rotateClosure: (() -> Void)?

func rotate() {
rotateCallsCount += 1
rotateClosure?()
}

}

上面的文件(AutoMockable.generated.swift)包含了你对mock的期望:使用空方法实现与目标协议的一致性,以及检查是否调用了这些协议方法的一组变量。最棒的是… Sourcery 为您编写了这一切!🎉

怎么运行 Sourcery?

怎么使用 Swift package 运行 Sourcery?
至此你可能在想如何以及怎样在 Swift package 中运行 Sourcery。你可以手动执行,然后讲文件拖到包中,或者从包目录中的命令运行脚本。但是对于 Swift Package 有两种内置方式运行可执行文件:
通过命令行插件,可根据用户输入任意运行
通过构建工具插件,该插件作为构建过程的一部分运行。
在本文中,我将介绍 Sourcery 命令行插件,但我已经在编写第二部分,其中我将创建构建工具插件,这带来了许多有趣的挑战。

创建插件包

让我们首先创建一个空包,并去掉测试和其他我们现在不需要的文件夹。然后我们可以创建一个新的插件 Target 并添加 Sourcery 的二进制文件作为其依赖项。
为了让消费者使用这个插件,它还需要被定义为一个产品:

// swift-tools-version: 5.6
import PackageDescription

let package = Package(
name: "SourceryPlugins",
products: [
.plugin(name: "SourceryCommand", targets: ["SourceryCommand"])
],
targets: [
// 1
.plugin(
name: "SourceryCommand",
// 2
capability: .command(
intent: .custom(verb: "sourcery-code-generation", description: "Generates Swift files from a given set of inputs"),
// 3
permissions: [.writeToPackageDirectory(reason: "Need access to the package directory to generate files")]
),
dependencies: ["Sourcery"]
),
// 4
.binaryTarget(
name: "Sourcery",
path: "Sourcery.artifactbundle"
)
]
)

让我们一步一步地仔细查看上面的代码:

1.定义插件目标。
2.以 custom 为意图,定义了 .command 功能,因为没有任何默认功能( documentationGeneration 和 sourceCodeFormatting)与该命令的用例匹配。给动词一个合理的名称很重要,因为这是从命令行调用插件的方式。
3.插件需要向用户请求写入包目录的权限,因为生成的文件将被转储到该目录。
4.为插件定义了一个二进制目标文件。这将允许插件通过其上下文访问可执行文件。
💡 我知道我并没有详细介绍上面的一些概念,但如果您想了解更多关于命令插件的信息,这里有一篇由 Tibor Bödecs 写的超级棒的文章⭐。如果你还想了解更多关于 Swift Packages 中二级制的目标(文件),我同样有一篇现今 Swift 包中的二进制目标。

编写插件

现在已经创建了包,是时候编写一些代码了!我们首先在 Plugins/SourceryCommand 下创建一个名为 SourceryCommand.swift 的文件,然后添加一个 CommandPlugin 协议的结构体,这将作为该插件的入口:

import PackagePlugin
import Foundation

@main
struct SourceryCommand: CommandPlugin {
func performCommand(context: PluginContext, arguments: [String]) async throws {

}
}

然后我们为命令编写实现:

func performCommand(context: PluginContext, arguments: [String]) async throws {
// 1
let configFilePath = context.package.directory.appending(subpath: ".sourcery.yml").string
guard FileManager.default.fileExists(atPath: configFilePath) else {
Diagnostics.error("Could not find config at: \(configFilePath)")
return
}

//2
let sourceryExecutable = try context.tool(named: "sourcery")
let sourceryURL = URL(fileURLWithPath: sourceryExecutable.path.string)

// 3
let process = Process()
process.executableURL = sourceryURL

// 4
process.arguments = [
"--disableCache"
]

// 5
try process.run()
process.waitUntilExit()

// 6
let gracefulExit = process.terminationReason == .exit && process.terminationStatus == 0
if !gracefulExit {
Diagnostics.error("🛑 The plugin execution failed")
}
}

让我们仔细看看上面的代码:

1.首先 .sourcery.yml 文件必须在包的根目录,否则将报错。这将使 Sourcery 神奇的工作,并使包可配置。
2.可执行文件路径的 URL 是从命令的上下文中检索的。
3.创建一个进程,并将 Sourcery 的可执行文件的 URL 设置为其可执行文件路径。
4.这一步有点麻烦。Sourcery 使用缓存来减少后续运行的代码生成时间,但问题是这些缓存是在包文件夹之外读取和写入的文件。插件的沙箱规则不允许这样做,因此 --disableCache 标志用于禁用此行为并允许命令运行。
5.进程同步运行并等待。
6.最后,检查进程终止状态和代码,以确保进程已正常退出。在任何其他情况下,通过 Diagnostics API 向用户告知错误。
就这样!现在让我们使用它

使用(插件)包

考虑一个用户正在使用插件,该插件将依赖项引入了他们的 Package.swift 文件:

// swift-tools-version: 5.6
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "SourceryPluginSample",
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "SourceryPluginSample",
targets: ["SourceryPluginSample"]),
],
dependencies: [
.package(url: "https://github.com/pol-piella/sourcery-plugins.git", branch: "main")
],
targets: [
.target(
name: "SourceryPluginSample",
dependencies: [],
exclude: ["SourceryTemplates"]
),
]
)

💡 注意,与构建工具插件不同,命令插件不需要应用于任何目标,因为它们需要手动运行。
用户只使用了上面的 AutoMockable 模板(可以在 Sources/SourceryPluginSample/SourceryTemplates 下找到),与本文前面显示的示例相匹配:

protocol AutoMockable {}

protocol Camera: AutoMockable {
func start()
func stop()
func capture(_ completion: @escaping (UIImage?) -> Void)
func rotate()
}

根据插件的要求,用户还提供了一个位于 SourceryPluginSample 目录下的 .sourcery.yml 配置文件:

sources:
- Sources/SourceryPluginSample
templates:
- Sources/SourceryPluginSample/SourceryTemplates
output: Sources/SourceryPluginSample

运行命令

用户已经设置好了,但是他们现在如何运行包?🤔 有两种方法:

命令行

运行插件的一种方法是用命令行。可以通过从包目录中运行 swift package plugin --list 来检索特定包的可用插件列表。然后可以从列表中选择一个包,并通过运行 swift package <command's verb> 来执行,在这个特殊的例子中,运行: swift package sourcery-code-generation。
注意,由于此包需要特殊权限,因此 --allow-writing-to-package-directory 必须与命令一起使用。
此时,你可能会想,为什么我要费心编写一个插件,仍然必须从命令行运行,而我可以用一个简单的脚本在几行 bash 中完成相同的工作?好吧,让我们来看看 Xcode 14 中会出现什么,你会明白为什么我会提倡编写插件📦。

Xcode

这是运行命令插件最令人兴奋的方式,但不幸的是,它仅在 Xcode 14 中可用。因此,如果您需要运行命令,但尚未使用 Xcode 14,请参阅命令行部分。
如果你正好在使用 Xcode 14,你可以通过在文件资源管理器中右键单击包,从列表中找到要执行的插件,然后单击它来执行包的任何命令。

下一步

这是插件的初始实现。我将研究如何改进它,使它更加健壮。和往常一样,我非常致力于公开构建,并使我的文章中的所有内容都开源,这样任何人都可以提交问题或创建任何具有改进或修复的 PRs。这没有什么不同😀, 这是 公共仓库的链接。
此外,如果您喜欢这篇文章,请关注即将到来的第二部分,其中我将制作一个 Sourcery 构建工具插件。我知道这听起来不多,但这不是一项容易的任务!

收起阅读 »

Sendable 和 @Sendable 闭包代码实例详解

前言Sendable 和 @Sendable 是 Swift 5.5 中的并发修改的一部分,解决了结构化的并发结构体和执行者消息之间传递的类型检查的挑战性问题。使用 Sendable应该在什么时候使用 Sendable?Sendable协议和闭包表明那些传递的...
继续阅读 »

前言

Sendable 和 @Sendable 是 Swift 5.5 中的并发修改的一部分,解决了结构化的并发结构体和执行者消息之间传递的类型检查的挑战性问题。

使用 Sendable

应该在什么时候使用 Sendable?
Sendable协议和闭包表明那些传递的值的公共API是否线程安全的向编译器传递了值。当没有公共修改器、有内部锁定系统或修改器实现了与值类型一样的复制写入时,公共API可以安全地跨并发域使用。
标准库中的许多类型已经支持了Sendable协议,消除了对许多类型添加一致性的要求。由于标准库的支持,编译器可以为你的自定义类型创建隐式一致性。
例如,整型支持该协议:

extension Int: Sendable {}

一旦我们创建了一个具有 Int 类型的单一属性的值类型结构体,我们就隐式地得到了对 Sendable 协议的支持。

// 隐式地遵守了 Sendable 协议
struct Article {
var views: Int
}

与此同时,同样的 Article 内容的类,将不会有隐式遵守该协议:

// 不会隐式的遵守 Sendable 协议
class Article {
var views: Int
}

类不符合要求,因为它是一个引用类型,因此可以从其他并发域变异。换句话说,该类文章(Article)的传递不是线程安全的,所以编译器不能隐式地将其标记为遵守Sendable协议。


使用泛型和枚举时的隐式一致性

很好理解的是,如果泛型不符合Sendable协议,编译器就不会为泛型添加隐式的一致性。

// 因为 Value 没有遵守 Sendable 协议,所以 Container 也不会自动的隐式遵守该协议
struct Container<Value> {
var child: Value
}

然而,如果我们将协议要求添加到我们的泛型中,我们将得到隐式支持:

// Container 隐式地符合 Sendable,因为它的所有公共属性也是如此。
struct Container<Value: Sendable> {
var child: Value
}

对于有关联值的枚举也是如此:


如果枚举值们不符合 Sendable 协议,隐式的Sendable协议一致性就不会起作用。

你可以看到,我们自动从编译器中得到一个错误:

Associated value ‘loggedIn(name:)’ of ‘Sendable’-conforming enum ‘State’ has non-sendable type ‘(name: NSAttributedString)’

我们可以通过使用一个值类型String来解决这个错误,因为它已经符合Sendable。

enum State: Sendable {
case loggedOut
case loggedIn(name: String)
}

从线程安全的实例中抛出错误

同样的规则适用于想要符合Sendable的错误类型。

struct ArticleSavingError: Error {
var author: NonFinalAuthor
}

extension ArticleSavingError: Sendable { }

由于作者不是不变的(non-final),而且不是线程安全的(后面会详细介绍),我们会遇到以下错误:

Stored property ‘author’ of ‘Sendable’-conforming struct ‘ArticleSavingError’ has non-sendable type ‘NonFinalAuthor’

你可以通过确保ArticleSavingError的所有成员都符合Sendable协议来解决这个错误。


如何使用Sendable协议

隐式一致性消除了很多我们需要自己为Sendable协议添加一致性的情况。然而,在有些情况下,我们知道我们的类型是线程安全的,但是编译器并没有为我们添加隐式一致性。
常见的例子是被标记为不可变和内部具有锁定机制的类:

/// User 是不可改变的,因此是线程安全的,所以可以遵守 Sendable 协议
final class User: Sendable {
let name: String

init(name: String) { self.name = name }
}

你需要用@unchecked属性来标记可变类,以表明我们的类由于内部锁定机制所以是线程安全的:

extension DispatchQueue {
static let userMutatingLock = DispatchQueue(label: "person.lock.queue")
}

final class MutableUser: @unchecked Sendable {
private var name: String = ""

func updateName(_ name: String) {
DispatchQueue.userMutatingLock.sync {
self.name = name
}
}
}


遵守 Sendable的限制

Sendable协议的一致性必须发生在同一个源文件中,以确保编译器检查所有可见成员的线程安全。
例如,你可以在例如 Swift package这样的模块中定义以下类型:

public struct Article {
internal var title: String
}

Article 是公开的,而标题title是内部的,在模块外不可见。因此,编译器不能在源文件之外应用Sendable一致性,因为它对标题属性不可见,即使标题使用的是遵守Sendable协议的String类型。
同样的问题发生在我们想要使一个可变的非最终类遵守Sendable协议时:


可变的非最终类无法遵守 Sendable 协议

由于该类是非最终的,我们无法符合Sendable协议的要求,因为我们不确定其他类是否会继承User的非Sendable成员。因此,我们会遇到以下错误:

Non-final class ‘User’ cannot conform to Sendable; use @unchecked Sendable

正如你所看到的,编译器建议使用@unchecked Sendable。我们可以把这个属性添加到我们的User类中,并摆脱这个错误:

class User: @unchecked Sendable {
let name: String

init(name: String) { self.name = name }
}

然而,这确实要求我们无论何时从User继承,都要确保它是线程安全的。由于我们给自己和同事增加了额外的责任,我不鼓励使用这个属性,建议使用组合、最终类或值类型来实现我们的目的。


如何使用 @Sendabele

函数可以跨并发域传递,因此也需要可发送的一致性。然而,函数不能符合协议,所以Swift引入了@Sendable属性。你可以传递的函数的例子是全局函数声明、闭包和访问器,如getters和setters。
SE-302的部分动机是执行尽可能少的同步

我们希望这样一个系统中的绝大多数代码都是无同步的。

使用@Sendable属性,我们将告诉编译器,他不需要额外的同步,因为闭包中所有捕获的值都是线程安全的。一个典型的例子是在Actor isolation中使用闭包。

actor ArticlesList {
func filteredArticles(_ isIncluded: @Sendable (Article) -> Bool) async -> [Article] {
// ...
}
}

如果你用非 Sendabel 类型的闭包,我们会遇到一个错误:

let listOfArticles = ArticlesList()
var searchKeyword: NSAttributedString? = NSAttributedString(string: "keyword")
let filteredArticles = await listOfArticles.filteredArticles { article in

// Error: Reference to captured var 'searchKeyword' in concurrently-executing code
guard let searchKeyword = searchKeyword else { return false }
return article.title == searchKeyword.string
}

当然,我们可以通过使用一个普通的String来快速解决这种情况,但它展示了编译器如何帮助我们执行线程安全。


Swift 6: 代码启用并发性检查

Xcode 14 允许您通过 SWIFT_STRICT_CONCURRENCY 构建设置启用严格的并发性检查。


启用严格的并发性检查,以修复 Sendable 的符合性

这个构建设置控制编译器对Sendable和actor-isolation检查的执行水平:
Minimal : 编译器将只诊断明确标有Sendable一致性的实例,并等同于Swift 5.5和5.6的行为。不会有任何警告或错误。
Targeted: 强制执行Sendable约束,并对你所有采用async/await等并发的代码进行actor-isolation检查。编译器还将检查明确采用Sendable的实例。这种模式试图在与现有代码的兼容性和捕捉潜在的数据竞赛之间取得平衡。
Complete: 匹配预期的 Swift 6语义,以检查和消除数据竞赛。这种模式检查其他两种模式所做的一切,并对你项目中的所有代码进行这些检查。
严格的并发检查构建设置有助于 Swift 向数据竞赛安全迈进。与此构建设置相关的每一个触发的警告都可能表明你的代码中存在潜在的数据竞赛。因此,必须考虑启用严格并发检查来验证你的代码。

Enabling strict concurrency in Xcode 14

你会得到的警告数量取决于你在项目中使用并发的频率。对于Stock Analyzer,我有大约17个警告需要解决:


并发相关的警告,表明潜在的数据竞赛.

这些警告可能让人望而生畏,但利用本文的知识,你应该能够摆脱大部分警告,防止数据竞赛的发生。然而,有些警告是你无法控制的,因为是外部模块触发了它们。在我的例子中,我有一个与SWHighlight有关的警告,它不符合Sendable,而苹果在他们的SharedWithYou框架中定义了它。
在上述SharedWithYou框架的例子中,最好是等待库的所有者添加Sendable支持。在这种情况下,这就意味着要等待苹果公司为SWHighlight实例指明Sendable的一致性。对于这些库,你可以通过使用@preconcurrency属性来暂时禁用Sendable警告:

@preconcurrency import SharedWithYou

重要的是要明白,我们并没有解决这些警告,而只是禁用了它们。来自这些库的代码仍然有可能发生数据竞赛。如果你正在使用这些框架的实例,你需要考虑实例是否真的是线程安全的。一旦你使用的框架被更新为Sendable的一致性,你可以删除@preconcurrency属性,并修复可能触发的警告。

收起阅读 »

iOS的CoreData技术笔记

前言最近因为新项目想用到数据持久化,本来这是很简单的事情,复杂数据一般直接SQLite就可以解决了。但是一直以来使用SQLite确实存在要自己设计数据库,处理逻辑编码,还有调试方面的种种繁琐问题。所以考虑使用iOS的Core Data方案。上网查了一堆资料后,...
继续阅读 »

前言

最近因为新项目想用到数据持久化,本来这是很简单的事情,复杂数据一般直接SQLite就可以解决了。
但是一直以来使用SQLite确实存在要自己设计数据库,处理逻辑编码,还有调试方面的种种繁琐问题。所以考虑使用iOS的Core Data方案。
上网查了一堆资料后,发现很多代码都已经是陈旧的了。甚至苹果官方文档提供的代码样例都未必是最新的Swift版本。于是萌生了自己写一篇文章来整理一遍思路的想法。尽可能让新人快速的上手,不但要知道其然,还要知道其设计的所以然,这样用起来才更得心应手。

什么是Core Data

我们写app肯定要用到数据持久化,说白了,就是把数据保存起来,app不删除的话可以继续读写。
iOS提供数据持久化的方案有很多,各自有其特定用途。
比如很多人熟知的UserDefaults,大部分时候是用来保存简单的应用配置信息;而NSKeyedArchiver可以把代码中的对象保存为文件,方便后来重新读取。
另外还有个常用的保存方式就是自己创建文件,直接在磁盘文件中进行读写。
而对于稍微复杂的业务数据,比如收藏夹,用户填写的多项表格等,SQLite就是更合适的方案了。关于数据库的知识,我这里就不赘述了,稍微有点技术基础的童鞋都懂。
Core DataSQLite做了更进一步的封装,SQLite提供了数据的存储模型,并提供了一系列API,你可以通过API读写数据库,去处理想要处理的数据。但是SQLite存储的数据和你编写代码中的数据(比如一个类的对象)并没有内置的联系,必须你自己编写代码去一一对应。
Core Data却可以解决一个数据在持久化层和代码层的一一对应关系。也就是说,你处理一个对象的数据后,通过保存接口,它可以自动同步到持久化层里,而不需要你去实现额外的代码。
这种 对象→持久化 方案叫 对象→关系映射(英文简称ORM)。
除了这个最重要的特性,Core Data还提供了很多有用的特性,比如回滚机制,数据校验等。


图1: Core Data与应用,磁盘存储的关系

数据模型文件 - Data Model

当我们用Core Data时,我们需要一个用来存放数据模型的地方,数据模型文件就是我们要创建的文件类型。它的后缀是.xcdatamodeld。只要在项目中选 新建文件→Data Model 即可创建。
默认系统提供的命名为 Model.xcdatamodeld 。下面我依然以 Model.xcdatamodeld 作为举例的文件名。
这个文件就相当于数据库中的“库”。通过编辑这个文件,就可以去添加定义自己想要处理的数据类型。

数据模型中的表格 - Entity

当在xcode中点击Model.xcdatamodeld时,会看到苹果提供的编辑视图,其中有个醒目的按钮Add Entity
什么是Entity呢?中文翻译叫“实体”,但是我这里就不打算用各种翻译名词来提高理解难度了。
如果把数据模型文件比作数据库中的“库”,那么Entity就相当于库里的“表格”。这么理解就简单了。Entity就是让你定义数据表格类型的名词。
假设我这个数据模型是用来存放图书馆信息的,那么很自然的,我会想建立一个叫BookEntity

属性 - Attributes

当建立一个名为BookEntity时,会看到视图中有栏写着Attributes,我们知道,当我们定义一本书时,自然要定义书名,书的编码等信息。这部分信息叫Attributes,即书的属性。
Book的Entity
属性名类型
nameString
isbmString
pageInteger32
其中,类型部分大部分是大家熟知的元数据类型,可以自行查阅。
同理,也可以再添加一个读者:Reader的Entity描述。
Reader的Entity
属性名类型
nameString
idCardString


图2: 在项目中创建数据模型文件

关系 - Relationship

在我们使用Entity编辑时,除了看到了Attributes一栏,还看到下面有Relationships一栏,这栏是做什么的?
回到例子中来,当定义图书馆信息时,刚书籍和读者的信息,但这两个信息彼此是孤立的,而事实上他们存在着联系。
比如一本书,它被某个读者借走了,这样的数据该怎么存储?
直观的做法是再定义一张表格来处理这类关系。但是Core Data提供了更有效的办法 - Relationship
Relationship的思路来思考,当一本书A被某个读者B借走,我们可以理解为这本书A当前的“借阅者”是该读者B,而读者B的“持有书”是A。
从以上描述可以看出,Relationship所描述的关系是双向的,即A和B互相以某种方式形成了联系,而这个方式是我们来定义的。
ReaderRelationship下点击+号键。然后在Relationship栏的名字上填borrow,表示读者和书的关系是“借阅”,在Destination栏选择Book,这样,读者和书籍的关系就确立了。
对于第三栏,Inverse,却没有东西可以填,这是为什么?
因为我们现在定义了读者和书的关系,却没有定义书和读者的关系。记住,关系是双向的。
就好比你定义了A是B的父亲,那也要同时去定义B是A的儿子一个道理。计算机不会帮我们打理另一边的联系。
理解了这点,我们开始选择Book的一栏,在Relationship下添加新的borrowByDestinationReader,这时候点击Inverse一栏,会发现弹出了borrow,直接点上。
这是因为我们在定义BookRelationship之前,我们已经定义了ReaderRelationship了,所以电脑已经知道了读者和书籍的关系,可以直接选上。而一旦选好了,那么在ReaderRelationship中,我们会发现Inverse一栏会自动补齐为borrowBy。因为电脑这时候已经完全理解了双方的关系,自动做了补齐。


一对一和一对多 - to one和to many


我们建立ReaderBook之间的联系的时候,发现他们的联系逻辑之间还漏了一个环节。
假设一本书被一个读者借走了,它就不能被另一个读者借走,而当一个读者借书时,却可以借很多本书。
也就是说,一本书只能对应一个读者,而一个读者却可以对应多本书。
这就是 一对一→to one 和 一对多→to many 。
Core Data允许我们配置这种联系,具体做法就是在RelationShip栏点击对应的关系栏,它将会出现在右侧的栏目中。(栏目如果没出现可以在xcode右上角的按钮调出,如果点击后栏目没出现Relationship配置项,可以多点击几下,这是xcode的小bug)。
Relationship的配置项里,有一项项名为Type,点击后有两个选项,一个是To One(默认值),另一个就是To Many了。


图3: 数据模型的关系配置


Core Data框架的主仓库 - NSPersistentContainer


当我们配置完Core Data的数据类型信息后,我们并没有产生任何数据,就好比图书馆已经制定了图书的规范 - 一本书应该有名字、isbm、页数等信息,规范虽然制定了,却没有真的引进书进来。
那么怎么才能产生和处理数据呢,这就需要通过代码真刀真枪的和Core Data打交道了。
由于Core Data的功能较为强大,必须分成多个类来处理各种逻辑,一次性学习多个类是不容易的,还容易混淆,所以后续我会分别一一列出。
要和这些各司其职的类打交道,我们不得不提第一个要介绍的类,叫NSPersistentContainer,因为它就是存放这多个类成员的“仓库类”。
这个NSPersistentContainer,就是我们通过代码和Core Data打交道的第一个目标。它存放着几种让我们和Core Data进行业务处理的工具,当我们拿到这些工具之后,就可以自由的访问数据了。所以它的名字 - Container 蕴含着的意思,就是 仓库、容器、集装箱。
进入正式的代码编写的第一步,我们先要在使用Core Data框架的swift文件开头引入这个框架:

import CoreData

早期,在iOS 10之前,还没有NSPersistentContainer这个类,所以Core Data提供的几种各司其职的工具,我们都要写代码一一获得,写出来的代码较为繁琐,所以NSPersistentContainer并不是一开始就有的,而是苹果框架设计者逐步优化出来的较优设计。


图4: NSPersistentContainer和其他成员的关系


NSPersistentContainer的初始化


在新建的UIKIT项目中,找到我们的AppDelegate类,写一个成员函数(即方法,后面我直接用函数这个术语替代):

private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
}

这样,NSPersistentContainer类的建立就完成了,其中"Model"字符串就是我们建立的Model.xcdatamodeld文件。但是输入参数的时候,我们不需要(也不应该)输入.xcdatamodeld后缀。
当我们创建了NSPersistentContainer对象时,仅仅完成了基础的初始化,而对于一些性能开销较大的初始化,比如本地持久化资源的加载等,都还没有完成,我们必须调用NSPersistentContainer的成员函数loadPersistentStores来完成它。

private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { (description, error) in
if let error = error {
fatalError("Error: \(error)")
}
print("Load stores success")
}
}

从代码设计的角度看,为什么NSPersistentContainer不直接在构造函数里完成数据库的加载?这就涉及到一个面向对象的开发原则,即构造函数的初始化应该是(原则上)倾向于原子级别,即简单的、低开销内存操作,而对于性能开销大的,内存之外的存储空间处理(比如磁盘,网络),应尽量单独提供成员函数来完成。这样做是为了避免在构造函数中出错时错误难以捕捉的问题。


表格属性信息的提供者 - NSManagedObjectModel


现在我们已经持有并成功初始化了Core Data的仓库管理者NSPersistentContainer了,接下去我们可以使用向这个管理者索取信息了,我们已经在模型文件里存放了读者和书籍这两个Entity了,如何获取这两个Entity的信息?
这就需要用到NSPersistentContainer的成员,即managedObjectModel,该成员就是标题所说的NSManagedObjectModel类型。
为了讲解NSManagedObjectModel能提供什么,我通过以下函数来提供说明:

private func parseEntities(container: NSPersistentContainer) {
let entities = container.managedObjectModel.entities
print("Entity count = \(entities.count)\n")
for entity in entities {
print("Entity: \(entity.name!)")
for property in entity.properties {
print("Property: \(property.name)")
}
print("")
}
}

为了执行上面这个函数,需要修改createPersistentContainer,在里面调用parseEntities

private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { (description, error) in
if let error = error {
fatalError("Error: \(error)")
}

self.parseEntities(container: container)
}
}

在这个函数里,我们通过NSPersistentContainer获得了NSManagedObjectModel类型的成员managedObjectModel,并通过它获得了文件Model.xcdatamodeld中我们配置好的Entity信息,即图书和读者。
由于我们配置了两个Entity信息,所以运行正确的话,打印出来的第一行应该是Entity count = 2
container的成员managedObjectModel有一个成员叫entities,它是一个数组,这个数组成员的类型叫NSEntityDescription,这个类名一看就知道是专门用来处理Entity相关操作的,这里就没必要多赘述了。
示例代码里,获得了entity数组后,打印entity的数量,然后遍历数组,逐个获得entity实例,接着遍历entity实例的properties数组,该数组成员是由类型NSPropertyDescription的对象组成。
关于名词Property,不得不单独说明下,学习一门技术最烦人的事情之一就是理解各种名词,毕竟不同技术之间名词往往不一定统一,所以要单独理解一下。
Core Data的术语环境下,一个Entity由若干信息部分组成,之前已经提过的EntityRelationship就是了。而这些信息用术语统称为propertyNSPropertyDescription看名字就能知道,就是处理property用的。
只要将这一些知识点梳理清楚了,接下去打印的内容就不难懂了:

Entity count = 2

Entity: Book
Property: isbm
Property: name
Property: page
Property: borrowedBy

Entity: Reader
Property: idCard
Property: name
Property: borrow

我们看到,打印出来我们配置的图书有4个property,最后一个是borrowedBy,明显这是个Relationship,而前面三个都是Attribute,这和我刚刚对property的说明是一致的。

Entity对应的类

开篇我们就讲过,Core Data是一个 对象-关系映射 持久化方案,现在我们在Model.xcdatamodeld已经建立了两个Entity,那么如果在代码里要操作他们,是不是会有对应的类?
答案是确实如此,而且你还不需要自己去定义这个类。
如果你点击Model.xcdatamodeld编辑窗口中的Book这个Entity,打开右侧的属性面板,属性面板会给出允许你编辑的关于这个Entity的信息,其中Entity部分的Name就是我们起的名字Book,而下方还有一个Class栏,这一栏就是跟Entity绑定的类信息,栏目中的Name就是我们要定义的类名,默认它和Entity的名字相同,也就是说,类名也是Book。所以改与不改,看个人思路以及团队的规范。
所有Entity对应的类,都继承自NSManagedObject
为了检验这一点,我们可以在代码中编写这一行作为测试:

var book: Book! // 纯测验代码,无业务价值

如果写下这一行编译通过了,那说明开发环境已经给我们生成了Book这个类,不然它就不可能编译通过。
测试结果,完美编译通过。说明不需要我们自己编写,就可以直接使用这个类了。
关于类名,官方教程里一般会把类名更改为Entity名 + MO,比如我们这个Entity名为Book,那么如果是按照官方教程的做法,可以在面板中编辑Class的名字为BookMO,这里MO大概就是Model Object的简称吧。
但是我这里为简洁起见,就不做任何更改了,Entity名为Book,那么类名也一样为Book
另外,你也可以自己去定义Entity对应的类,这样有个好处是可以给类添加一些额外的功能支持,这部分Core Data提供了编写的规范,但是大部分时候这个做法反而会增加代码量,不属于常规操作。


数据业务的操作员 - NSManagedObjectContext


接下来我们要隆重介绍NSPersistentContainer麾下的一名工作任务最繁重的大将,成员viewContext,接下去我们和实际数据打交道,处理增删查改这四大操作,都要通过这个成员才能进行。
viewContext成员的类型是NSManagedObjectContext
NSManagedObjectContext,顾名思义,它的任务就是管理对象的上下文。从创建数据,对修改后数据的保存,删除数据,修改,五一不是以它为入口。
从介绍这个成员开始,我们就正式从 定义数据 的阶段,正式进入到 产生和操作数据 的阶段。


数据的插入 - NSentityDescription.insertNewObject


梳理完前面的知识,就可以正式踏入数据创建的学习了。
这里,我们先尝试创建一本图书,用一个createBook函数来进行。示例代码如下:

private func createBook(container: NSPersistentContainer,
name: String, isbm: String, pageCount: Int) {
let context = container.viewContext
let book = NSEntityDescription.insertNewObject(forEntityName: "Book",
into: context) as! Book
book.name = name
book.isbm = isbm
book.page = Int32(pageCount)
if context.hasChanges {
do {
try context.save()
print("Insert new book(\(name)) successful.")
} catch {
print("\(error)")
}
}
}

在这个代码里,最值得关注的部分就是NSEntityDescription的静态成员函数insertNewObject了,我们就是通过这个函数来进行所要插入数据的创建工作。
insertNewObject对应的参数forEntityName就是我们要输入的Entity名,这个名字当然必须是我们之前创建好的Entity有的名字才行,否则就出错了。因为我们要创建的是书,所以输入的名字就是Book
into参数就是我们的处理增删查改的大将NSManagedObjectContext类型。
insertNewObject返回的类型是NSManagedObject,如前所述,这是所有Entity对应类的父类。因为我们要创建的EntityBook,我们已经知道对应的类名是Book了,所以我们可以放心大胆的把它转换为Book类型。
接下来我们就可以对Book实例进行成员赋值,我们可以惊喜的发现Book类的成员都是我们在Entity表格中编辑好的,真是方便极了。
那么问题来了,当我们把Book编辑完成后,是不是这个数据就完成了持久化了,其实不是的。
这里要提一下Core Data的设计理念:懒原则。Core Data框架之下,任何原则操作都是内存级的操作,不会自动同步到磁盘或者其他媒介里,只有开发者主动发出存储命令,才会做出存储操作。这么做自然不是因为真的很懒,而是出于性能考虑。
为了真的把数据保存起来,首先我们通过context(即NSManagedObjectContext成员)的hasChanges成员询问是否数据有改动,如果有改动,就执行contextsave函数。(该函数是个会抛异常的函数,所以用do→catch包裹起来)。
至此,添加书本的操作代码就写完了。接下来我们把它放到合适的地方运行。
我们对createPersistentContainer稍作修改:

private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { (description, error) in
if let error = error {
fatalError("Error: \(error)")
}

//self.parseEntities(container: container)
self.createBook(container: container,
name: "算法(第4版)",
isbm: "9787115293800",
pageCount: 636)
}
}

运行项目,会看到如下打印输出:

Insert new book(算法(第4版)) successful.

至此,书本的插入工作顺利完成!

因为这个示例没有去重判定,如果程序运行两次,那么将会插入两条书名都为"算法(第4版)"的book记录。

数据的获取

有了前面基础知识的铺垫,接下去的例子只要 记函数 就成了,读取的示例代码:

private func readBooks(container: NSPersistentContainer) {
let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
do {
let books = try context.fetch(fetchBooks)
print("Books count = \(books.count)")
for book in books {
print("Book name = \(book.name!)")
}
} catch {

}
}

处理数据处理依然是我们的数据操作主力context,而处理读取请求配置细节则是交给一个专门的类,NSFetchRequest来完成,因为我们处理读取数据有各种各样的类型,所以Core Data设计了一个泛型模式,你只要对NSFetchRequest传入对应的类型,比如Book,它就知道应该传回什么类型的对应数组,其结果是,我们可以通过Entity名为Book的请求直接拿到Book类型的数组,真是很方便。

打印结果:

Books count = 1
Book name = 算法(第4版)


数据获取的条件筛选 - NSPredicate


通过NSFetchRequest我们可以获取所有的数据,但是我们很多时候需要的是获得我们想要的特定的数据,通过条件筛选功能,可以实现获取出我们想要的数据,这时候需要用到NSFetchRequest的成员predicate来完成筛选,如下所示,我们要找书名叫 算法(第4版) 的书。
在新的代码示例里,我们在之前实现的readBooks函数代码里略作修改:

private func readBooks(container: NSPersistentContainer) {
let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "name = \"算法(第4版)\"")
do {
let books = try context.fetch(fetchBooks)
print("Books count = \(books.count)")
for book in books {
print("Book name = \(book.name!)")
}
} catch {
print("\(error)")
}
}

通过代码:

fetchBooks.predicate = NSPredicate(format: "name = \"算法(第4版)\"")

我们从书籍中筛选出书名为 算法(第4版) 的书,因为我们之前已经保存过这本书,所以可以正确筛选出来。
筛选方案还支持大小对比,如

fetchBooks.predicate = NSPredicate(format: "page > 100")

这样将筛选出page数量大于100的书籍。

数据的修改

当我们要修改数据时,比如说我们要把 isbm = "9787115293800" 这本书书名修改为 算法(第5版) ,可以按照如下代码示例:

let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "isbm = \"9787115293800\"")
do {
let books = try context.fetch(fetchBooks)
if !books.isEmpty {
books[0].name = "算法(第5版)"
if context.hasChanges {
try context.save()
print("Update success.")
}
}
} catch {
print("\(error)")
}

在这个例子里,我们遵循了 读取→修改→保存 的思路,先拿到筛选的书本,然后修改书本的名字,当名字被修改后,context将会知道数据被修改了,这时候判断数据是否被修改(实际上不需要判断我们也知道被修改了,只是出于编码规范加入了这个判断),如果被修改,就保存数据,通过这个方式,成功更改了书名。

数据的删除

数据的删除依然遵循 读取→修改→保存 的思路,找到我们想要的思路,并且删除它。删除的方法是通过contextdelete函数。
以下例子中,我们删除了所有 isbm="9787115293800" 的书籍:

let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "isbm = \"9787115293800\"")
do {
let books = try context.fetch(fetchBooks)
for book in books {
context.delete(books[0])
}
if context.hasChanges {
try context.save()
}
} catch {
print("\(error)")
}

扩展和进阶主题的介绍

如果跟我一步步走到这里,那么关于Core Data的基础知识可以说已经掌握的差不多了。当然了,这部分基础对于日常开发已经基本够用了。
关于Core Data开发的进阶部分,我在这里简单列举一下:
  1. Relationship部分的开发,事实上通过之前的知识可以独立完成。
  2. 回滚操作,相关类:UndoManager
  3. EntityFetched Property属性。
  4. 多个context一起操作数据的冲突问题。
  5. 持久化层的管理,包括迁移文件地址,设置多个存储源等。
以上诸个主题都可以自己进一步探索,不在这篇文章的讲解范围。不过后续不排除会单独出文探索。

结语

Core Data在圈内是比较出了名的“不好用”的框架,主要是因为其抽象的功能和机制较为不容易理解。本文已经以最大限度的努力试图从设计的角度去阐述该框架,希望对你有所帮助。

收起阅读 »

项目中第三方库并不是必须的

前言有时候集成一个特定的库(比如 PayPal)是必须的,有时候是避免去开发一些非常复杂的功能,有时候仅仅只是避免重复造轮子。虽然这些都是合理的考量,但使用第三方库的风险和相关成本往往被忽视或误解。在某些情况下,风险是值得的,但是在决定冒险之前,首先...
继续阅读 »

前言

有时候集成一个特定的库(比如 PayPal)是必须的,有时候是避免去开发一些非常复杂的功能,有时候仅仅只是避免重复造轮子。

虽然这些都是合理的考量,但使用第三方库的风险和相关成本往往被忽视或误解。在某些情况下,风险是值得的,但是在决定冒险之前,首先要能够明确的定义风险。为了使风险评估更加的透明和一致,我们制定了一个流程来衡量我们将其集成到app有多大的风险。


风险

大多数大型组织,包括我们,都有某种形式的代码审查,作为开发实践的一部分。对这些团队来说,添加一个第三方库就相当于添加了一堆由不属于团队成员开发,未经审查的代码。这破坏了团队一直坚持的代码审查原则,交付了质量未知的代码。这给app的运行方式以及长期开发带来了风险,对于大型团队而言,更是对整体业务带来了风险。

运行时风险

库代码通常来说,对于系统资源,和app拥有相同级别的访问权限,但它们不一定应用团队为管理这些资源而制定的最佳实践。这意味着它们可以在没有限制的情况下访问磁盘,网络,内存,CPU等等,因此,它们可以(过度)将文件写入磁盘,使用未优化的代码占用内存或CPU,导致死锁或主线程延迟,下载(和上传!)大量数据等等。更糟糕的是他们会导致崩溃,甚至崩溃循环。两次。

其中许多情况直到 app 已经上架才被发现,在这种情况下,修复它需要创建一个新版本,并通过审核,这通常需要大量时间和成本。这种风险可以通过一个变量控制是否调用来进行一定程度的控制,但是这种方法也并非万无一失(看下文)。

开发风险

引用一个同事的话:“每一行代码都是一种负担”,对不是你自己写的代码而言,这句话更甚。库在适配新技术或API时可能很慢,这阻碍了代码开发,或者太快,导致开发的版本过高。

库在采用新技术或API时可能很慢,阻碍了代码库,或者太快,导致部署目标太高。每当 Apple 和 Google 每年发布一个新 OS 版本时,他们通常要求开发人员根据SDK的变化更新代码,库开发人员也必须这样做。这需要协调一致的努力、优先事项的一致性以及及时完成工作的能力。

随着移动平台的不断变化,以及团队(成员)也不是一成不变,这将会成为一个持续不断的风险。当被集成的库不存在了,而库又需要更新时,会花很多时间来决定谁来做。事实证明一旦一个库存在,就很少也很难被移除,因此我们将其视为长期维护成本。

商业风险

如同我上面所说,现代的操作系统并没有对 app 代码和库代码进行区分,因此除了系统资源之外,它们还可以访问用户信息。作为 app 的开发者,我们负责恰当的使用这部分信息,也需要为任何第三方库负责。

如果用户给了 Lyft app 地理位置授权,任何第三方库也将自动得获得授权。他们可以将那些(地理位置)数据上传到自己服务器,竞对服务器,或者谁知道还有什么地方。当一个库需要我们没有的权限时,那问题就更大了。

同样,一个系统的安全取决于其最薄弱的环节,但如果其中包含未经审核的代码,那么你就不知道它到底有多安全。你精心设计的安全编码实践可能会被一个行为不当的库所破坏。苹果和谷歌实施的任何政策都是如此,例如“你不得对用户追踪”。


减少风险

当对一个库(是否)进行使用评估时,我们首先要问几个问题,以了解对库的需求。

我们内部能做么?

有时候我们只需要简单的粘贴复制真正需要的部分。在更复杂的场景中,库与自定义后端通信,我们对该API进行了逆向,并自己构建了一个迷你SDK(同样,只构建了我们需要的部分)。在90%的情况下,这是首选,但在与非常特定的供应商或需求集成时并不总是可行。

有多少用户从该库中受益?

在一种情况下,我们正在考虑添加一个风险很大的库(根据下面的标准),旨在为一小部分用户提供服务,同时将我们的所有用户都暴露在该库中。对于我们认为会从中受益的一小部分客户,我们冒了为我们所有用户带来问题的风险。

这个库有什么传递依赖?

我们还需要评估库的所有依赖项的以下标准。

退出标准是什么?

如果集成成功,是否有办法将其转移到内部?如果不成功,是否有办法删除?


评价标准

如果此时团队仍然希望集成库,我们要求他们根据一组标准对库进行“评分”。下面的列表并不全面,但应该能很好地说明我们希望看到的。

阻断标准

这些标准将阻止我们从技术上或者公司政策上集成此库,在进行下一步之前,我们必须解决:

过高的 deployment target/target SDKs。 我们支持过去4年主流的操作系统(版本),所以第三方库至少也需要支持一样多。

许可证不正确/缺失。 我们将许可文件与应用捆绑在一起,以确保我们可以合法使用代码并将其归属于许可持有人。

没有冲突的传递依赖关系。 一个库不能有一个我们已经包含但版本不同的传递依赖项。

不显示它自己的 UI 。 我们非常小心地使我们的产品看起来尽可能统一,定制用户界面对此不利。

它不使用私有 API 。 我们不愿意冒 app 因使用私有 API 而被拒绝的风险。

主要关注点

闭源。 访问源代码意味着我们可以选择我们想要包含的库的哪些部分,以及如何将该源代码与应用程序的其余部分捆绑在一起。对于我们来说,一个封闭源代码的二进制发行版更难集成。

编译时有警告。 我们启用了“警告视为错误”,具有编译警告的库是库整体质量(下降)的良好指示。

糟糕的文档。 我们希望有高质量的内联文档,外部”如何使用“文档,以及有意义的更新日志。

二进制体积。 这个库有多大?一些库提供了很多功能,而我们只需要其中的一小部分。尤其是在没有访问源码权限的情况下,这通常是一个全有或全无的情况。

外部的网络流量。 与我们无法控制的上游服务器/端点通信的库可能会在服务器关闭、错误数据被发回等时关闭整个应用程序。这也与我上面提到的隐私问题相同。

技术支持。 当事情不能正常工作时,我们需要能够报告/上报问题,并在合理的时间内解决问题。开源项目通常由志愿者维护,也很难有一个时间线,但至少我们可以自己进行修改。这在闭源项目是不可能的。

无法禁用。 虽然大多数库特别要求我们初始化它,但有些库在实例化时更“主动”,并且在我们不调用它的情况下可以自己执行工作。这意味着当库导致问题时,我们无法通过功能变量或其他机制将其关闭。

我们为所有这些(和其他一些)标准分配了点数,并要求工程师为他们想要集成的库汇总这些点数。虽然默认情况下,低分数并不难被拒绝,但我们通常会要求更多的理由来继续前进。


最后

虽然这个过程看起来非常严格,在许多情况下,潜在风险是假设的,但我们有我在这篇博文中描述的每个场景的实际例子。将评估记录下来并公开,也有助于将相对风险传达给不熟悉移动平台工作方式的人,并证明我们没有随意评估风险。

收起阅读 »

淘宝iOS扫一扫架构升级 - 设计模式的应用

iOS
本文在“扫一扫功能的不断迭代,基于设计模式的基本原则,逐步采用设计模式思想进行代码和架构优化”的背景下,对设计模式在扫一扫中新的应用进行了总结。背景扫一扫是淘宝镜头页中的一个重要组成,功能运行久远,其历史代码中较少采用面向对象编程思想,而较多采用面向过程的程序...
继续阅读 »

本文在“扫一扫功能的不断迭代,基于设计模式的基本原则,逐步采用设计模式思想进行代码和架构优化”的背景下,对设计模式在扫一扫中新的应用进行了总结。

背景

扫一扫是淘宝镜头页中的一个重要组成,功能运行久远,其历史代码中较少采用面向对象编程思想,而较多采用面向过程的程序设计。

随着扫一扫功能的不断迭代,我们基于设计模式的基本原则,逐步采用设计模式思想进行代码和架构优化。本文就是在这个背景下,对设计模式在扫一扫中新的应用进行了总结。

扫一扫原架构

扫一扫的原架构如图所示。其中逻辑&展现层的功能逻辑很多,并没有良好的设计和拆分,举几个例子:

  1. 所有码的处理逻辑都写在同一个方法体里,一个方法就接近 2000 多行。

  2. 庞大的码处理逻辑写在 viewController 中,与 UI 逻辑耦合。

按照现有的代码设计,若要对某种码逻辑进行修改,都必须将所有逻辑全量编译。如果继续沿用此代码,扫一扫的可维护性会越来越低。

图片

因此我们需要对代码和架构进行优化,在这里优化遵循的思路是:

  1. 了解业务能力

  2. 了解原有代码逻辑,不确定的地方通过埋点等方式线上验证

  3. 对原有代码功能进行重写/重构

  4. 编写单元测试,提供测试用例

  5. 测试&上线

扫码能力综述

扫一扫的解码能力决定了扫一扫能够处理的码类型,这里称为一级分类。基于一级分类,扫一扫会根据码的内容和类型,再进行二级分类。之后的逻辑,就是针对不同的二级类型,做相应的处理,如下图为技术链路流程。

图片

设计模式

责任链模式

图片

上述技术链路流程中,码处理流程对应的就是原有的 viewController 里面的巨无霸逻辑。通过梳理我们看到,码处理其实是一条链式的处理,且有前后依赖关系。优化方案有两个,方案一是拆解成多个方法顺序调用;方案二是参考苹果的 NSOperation 独立计算单元的思路,拆解成多个码处理单元。方案一本质还是没解决开闭原则(对扩展开放,对修改封闭)问的题。方案二是一个比较好的实践方式。那么怎么设计一个简单的结构来实现此逻辑呢?

码处理链路的特点是,链式处理,可控制处理的顺序,每个码处理单元都是单一职责,因此这里引出改造第一步:责任链模式。

责任链模式是一种行为设计模式, 它将请求沿着处理者链进行发送。收到请求后, 每个处理者均可对请求进行处理, 或将其传递给链上的下个处理者。

本文设计的责任链模式,包含三部分:

  1. 创建数据的 Creator

  2. 管理处理单元的 Manager

  3. 处理单元 Pipeline

三者结构如图所示

图片

创建数据的 Creator

包含的功能和特点:

  1. 因为数据是基于业务的,所以它只被声明为一个 Protocol ,由上层实现。

  2. Creator 对数据做对象化,对象生成后 self.generateDataBlock(obj, Id) 即开始执行

API 代码示例如下

/// 数据产生协议 <CreatorProtocol>
@protocol TBPipelineDataCreatorDelegate <NSObject>
@property (nonatomic, copy) void(^generateDataBlock)(id data, NSInteger dataId);
@end
复制代码

上层业务代码示例如下

@implementation TBDataCreator
@synthesize generateDataBlock;
- (void)receiveEventWithScanResult:(TBScanResult *)scanResult                                                        eventDelegate:(id <TBScanPipelineEventDeletate>)delegate {
   //对数据做对象化
   TBCodeData *data = [TBCodeData new];
   data.scanResult = scanResult;
   data.delegate = delegate;
   
   NSInteger dataId = 100;
   //开始执行递归
   self.generateDataBlock(data, dataId);
}
@end
复制代码

管理处理单元的 Manager

包含的功能和特点:

  1. 管理创建数据的 Creator

  2. 管理处理单元的 Pipeline

  3. 采用支持链式的点语法,方便书写

API 代码示例如下

@interface TBPipelineManager : NSObject
/// 添加创建数据 Creator
- (TBPipelineManager *(^)(id<TBPipelineDataCreatorDelegate> dataCreator))addDataCreator;
/// 添加处理单元 Pipeline
- (TBPipelineManager *(^)(id<TBPipelineDelegate> pipeline))addPipeline;
/// 抛出经过一系列 Pipeline 的数据。当 Creator 开始调用 generateDataBlock 后,Pipeline 就开始执行
@property (nonatomic, strong) void(^throwDataBlock)(id data);
@end
复制代码

实现代码示例如下

@implementation TBPipelineManager
- (TBPipelineManager *(^)(id<TBPipelineDataCreatorDelegate> dataCreator))addDataCreator {    
   @weakify
   return ^(id<TBPipelineDataCreatorDelegate> dataCreator) {
       @strongify
       if (dataCreator) {
          [self.dataGenArr addObject:dataCreator];
      }
       return self;
  };
}

- (TBPipelineManager *(^)(id<TBPipelineDelegate> pipeline))addPipeline {
   @weakify
   return ^(id<TBPipelineDelegate> pipeline) {
       @strongify
       if (pipeline) {
          [self.pipelineArr addObject:pipeline];
           
           //每一次add的同时,我们做链式标记(通过runtime给每个处理加Next)
           if (self.pCurPipeline) {
               NSObject *cur = (NSObject *)self.pCurPipeline;                
               cur.tb_nextPipeline = pipeline;
          }
           self.pCurPipeline = pipeline;
      }
       return self;
  };
}

- (void)setThrowDataBlock:(void (^)(id _Nonnull))throwDataBlock {
   _throwDataBlock = throwDataBlock;
   
   @weakify
   //Creator的数组,依次对 Block 回调进行赋值,当业务方调用此 Block 时,就是开始处理数据的时候    
  [self.dataGenArr enumerateObjectsUsingBlock:^(id<TBPipelineDataCreatorDelegate>  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
       obj.generateDataBlock = ^(id<TBPipelineBaseDataProtocol> data, NSInteger dataId) {                 @strongify
           data.dataId = dataId;
           //开始递归处理数据
          [self handleData:data];
      };
  }];
}

- (void)handleData:(id)data {
  [self recurPipeline:self.pipelineArr.firstObject data:data];
}

- (void)recurPipeline:(id<TBPipelineDelegate>)pipeline data:(id)data {
   if (!pipeline) {
       return;
  }
   
   //递归让pipeline处理数据
   @weakify
  [pipeline receiveData:data throwDataBlock:^(id  _Nonnull throwData) {
       @strongify
       NSObject *cur = (NSObject *)pipeline;
       if (cur.tb_nextPipeline) {
          [self recurPipeline:cur.tb_nextPipeline data:throwData];
      } else {
           !self.throwDataBlock?:self.throwDataBlock(throwData);
      }
  }];
}
@end
复制代码

处理单元 Pipeline

包含的功能和特点:

  1. 因为数据是基于业务的,所以它只被声明为一个 Protocol ,由上层实现。

API 代码示例如下

@protocol TBPipelineDelegate <NSObject>
//如果有错误,直接抛出
- (void)receiveData:(id)data throwDataBlock:(void(^)(id data))block;
@end
复制代码

上层业务代码示例如下

//以A类型码码处理单元为例
@implementation TBGen3Pipeline
- (void)receiveData:(id <TBCodeDataDelegate>)data throwDataBlock:(void (^)(id data))block {    
   TBScanResult *result = data.scanResult;
   NSString *scanType = result.resultType;
   NSString *scanData = result.data;
   
   if ([scanType isEqualToString:TBScanResultTypeA]) {
       //跳转逻辑
      ...
       //可以处理,终止递归
       BlockInPipeline();
  } else {
       //不满足处理条件,继续递归:由下一个 Pipeline 继续处理
       PassNextPipeline(data);
  }
}
@end
复制代码

业务层调用

有了上述的框架和上层实现,生成一个码处理管理就很容易且能达到解耦的目的,代码示例如下

- (void)setupPipeline { 
  //创建 manager 和 creator
  self.manager = TBPipelineManager.new;
  self.dataCreator = TBDataCreator.new;
   
  //创建 pipeline
  TBCodeTypeAPipelie *codeTypeAPipeline = TBCodeTypeAPipelie.new;
  TBCodeTypeBPipelie *codeTypeBPipeline = TBCodeTypeBPipelie.new;
  //...
  TBCodeTypeFPipelie *codeTypeFPipeline = TBCodeTypeFPipelie.new;
   
  //往 manager 中链式添加 creator 和 pipeline
  @weakify
  self.manager
  .addDataCreator(self.dataCreator)
  .addPipeline(codeTypeAPipeline)
  .addPipeline(codeTypeBPipeline)
  .addPipeline(codeTypeFPipeline)
  .throwDataBlock = ^(id data) {
      @strongify
      if ([self.proxyImpl respondsToSelector:@selector(scanResultDidFailedProcess:)]) {                   [self.proxyImpl scanResultDidFailedProcess:data];
      }
  };
}
复制代码

状态模式

image.png

image.png

回头来看下码展示的逻辑,这是我们用户体验优化的一项重要内容。码展示的意思是对于当前帧/图片,识别到码位置,我们进行锚点的高亮并跳转。这里包含三种情况:

  1. 未识别到码的时候,无锚点展示

  2. 识别到单码的时候,展示锚点并在指定时间后跳转

  3. 识别到多码额时候,展示锚点并等待用户点击

可以看到,这里涉及到简单的展示状态切换,这里就引出改造的第二步:状态模式

image.png

状态模式是一种行为设计模式, 能在一个对象的内部状态变化时改变其行为, 使其看上去就像改变了自身所属的类一样。

本文设计的状态模式,包含两部分:

  1. 状态的信息 StateInfo

  2. 状态的基类 BaseState

两者结构如图所示

image.png

状态的信息 StateInfo

包含的功能和特点:

  1. 当前上下文仅有一种状态信息流转

  2. 业务方可以保存多个状态键值对,状态根据需要执行相应的代码逻辑。

状态信息的声明和实现代码示例如下

@interface TBBaseStateInfo : NSObject {
   @private
   TBBaseState<TBBaseStateDelegate> *_currentState; //记录当前的 State
}
//使用当前的 State 执行
- (void)performAction;
//更新当前的 State
- (void)setState:(TBBaseState <TBBaseStateDelegate> *)state;
//获取当前的 State
- (TBBaseState<TBBaseStateDelegate> *)getState;
@end

@implementation TBBaseStateInfo
- (void)performAction {
   //当前状态开始执行
  [_currentState perfromAction:self];
}
- (void)setState:(TBBaseState <TBBaseStateDelegate> *)state {
   _currentState = state;
}
- (TBBaseState<TBBaseStateDelegate> *)getState {
   return _currentState;
}
@end
复制代码

上层业务代码示例如下

typedef NS_ENUM(NSInteger,TBStateType) {
TBStateTypeNormal, //空状态
TBStateTypeSingleCode, //单码展示态
TBStateTypeMultiCode, //多码展示态
};

@interface TBStateInfo : TBBaseStateInfo
//以 key-value 的方式存储业务 type 和对应的状态 state
- (void)setState:(TBBaseState<TBBaseStateDelegate> *)state forType:(TBStateType)type;
//更新 type,并执行 state
- (void)setType:(TBStateType)type;
@end

@implementation TBStateInfo

- (void)setState:(TBBaseState<TBBaseStateDelegate> *)state forType:(TBStateType)type {
[self.stateDict tb_setObject:state forKey:@(type)];
}

- (void)setType:(TBStateType)type {
id oldState = [self getState];
//找到当前能响应的状态
id newState = [self.stateDict objectForKey:@(type)];
//如果状态未发生变更则忽略
if (oldState == newState)
return;
if ([newState respondsToSelector:@selector(perfromAction:)]) {
[self setState:newState];
//转态基于当前的状态信息开始执行
[newState perfromAction:self];
}
}
@end
复制代码

状态的基类 BaseState

包含的功能和特点:

  1. 定义了状态的基类

  2. 声明了状态的基类需要遵循的 Protocol

Protocol 如下,基类为空实现,子类继承后,实现对 StateInfo 的处理。

@protocol TBBaseStateDelegate <NSObject>
- (void)perfromAction:(TBBaseStateInfo *)stateInfo;
@end
复制代码

上层(以单码 State 为例)代码示例如下

@interface TBSingleCodeState : TBBaseState
@end

@implementation TBSingleCodeState

//实现 Protocol
- (void)perfromAction:(TBStateInfo *)stateAction {
   //业务逻辑处理 Start
  ...
   //业务逻辑处理 End
}

@end
复制代码

业务层调用

以下代码生成一系列状态,在合适时候进行状态的切换。

//状态初始化
- (void)setupState {
   TBSingleCodeState *singleCodeState =TBSingleCodeState.new; //单码状态
   TBNormalState *normalState =TBNormalState.new; //正常状态
   TBMultiCodeState *multiCodeState = [self getMultiCodeState]; //多码状态
   
  [self.stateInfo setState:normalState forType:TBStateTypeNormal];
  [self.stateInfo setState:singleCodeState forType:TBStateTypeSingleCode];
  [self.stateInfo setState:multiCodeState forType:TBStateTypeMultiCode];
}

//切换常规状态
- (void)processorA {
   //...
  [self.stateInfo setType:TBStateTypeNormal];
   //...
}

//切换多码状态
- (void)processorB {
   //...
  [self.stateInfo setType:TBStateTypeMultiCode];
   //...
}

//切换单码状态
- (void)processorC {
   //...
  [self.stateInfo setType:TBStateTypeSingleCode];
   //...
}
复制代码

最好根据状态机图编写状态切换代码,以保证每种状态都有对应的流转。

次态→ 初态↓状态A状态B状态C
状态A条件A......
状态B.........
状态C.........

代理模式

图片

在开发过程中,我们会在越来越多的地方使用到上图能力,比如「淘宝拍照」的相册中、「扫一扫」的相册中,用到解码码展示码处理的能力。

因此,我们需要把这些能力封装并做成插件化,以便在任何地方都能够使用。这里就引出了我们改造的第三步:代理模式。

代理模式是一种结构型设计模式,能够提供对象的替代品或其占位符。代理控制着对于原对象的访问, 并允许在将请求提交给对象前后进行一些处理。 本文设计的状态模式,包含两部分:

  1. 代理单例 GlobalProxy

  2. 代理的管理 ProxyHandler

两者结构如图所示

图片

代理单例 GlobalProxy

单例的目的主要是减少代理重复初始化,可以在合适的时机初始化以及清空保存的内容。单例模式对于 iOSer 再熟悉不过了,这里不再赘述。

代理的管理 Handler

维护一个对象,提供了对代理增删改查的能力,实现对代理的操作。这里实现 Key - Value 的 Key 为 Protocol ,Value 为具体的代理。

代码示例如下

+ (void)registerProxy:(id)proxy withProtocol:(Protocol *)protocol {
   if (![proxy conformsToProtocol:protocol]) {
       NSLog(@"#TBGlobalProxy, error");
       return;
  }
   if (proxy) {
      [[TBGlobalProxy sharedInstance].proxyDict setObject:proxy forKey:NSStringFromProtocol(protocol)];
  }
}

+ (id)proxyForProtocol:(Protocol *)protocol {
   if (!protocol) {
       return nil;
  }
   id proxy = [[TBGlobalProxy sharedInstance].proxyDict objectForKey:NSStringFromProtocol(protocol)];
   return proxy;
}

+ (NSDictionary *)proxyConfigs {
   return [TBGlobalProxy sharedInstance].proxyDict;
}

+ (void)removeAll {
  [TBGlobalProxy sharedInstance].proxyDict = [[NSMutableDictionary alloc] init];
}
复制代码

业务层的调用

所以不管是什么业务方,只要是需要用到对应能力的地方,只需要从单例中读取 Proxy,实现该 Proxy 对应的 Protocol,如一些回调、获取当前上下文等内容,就能够获取该 Proxy 的能力。

//读取 Proxy 的示例
- (id <TBScanProtocol>)scanProxy {
   if (!_scanProxy) {
       _scanProxy = [TBGlobalProxy proxyForProtocol:@protocol(TBScanProtocol)];
  }
   _scanProxy.proxyImpl = self;
   return _scanProxy;
}

//写入 Proxy 的示例(解耦调用)
- (void)registerGlobalProxy {
   //码处理能力
  [TBGlobalProxy registerProxy:[[NSClassFromString(@"TBScanProxy") alloc] init]                                   withProtocol:@protocol(TBScanProtocol)];
   //解码能力
  [TBGlobalProxy registerProxy:[[NSClassFromString(@"TBDecodeProxy") alloc] init]                                 withProtocol:@protocol(TBDecodeProtocol)];}
复制代码

扫一扫新架构

基于上述的改造优化,我们将原扫一扫架构进行了优化:将逻辑&展现层进行代码分拆,分为属现层、逻辑层、接口层。已达到层次分明、职责清晰、解耦的目的。

image.png

总结

上述沉淀的三个设计模式作为扫拍业务的 Foundation 的 Public 能力,应用在镜头页的业务逻辑中。

通过此次重构,提高了扫码能力的复用性,结构和逻辑的清晰带来的是维护成本的降低,不用再大海捞针从代码“巨无霸”中寻找问题,降低了开发人日。


作者:阿里巴巴大淘宝技术
来源:https://juejin.cn/post/7127858822395199502

收起阅读 »