注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

扒一扒uniapp是如何做ios app应用安装的

iOS
为何要扒 因为最近有移动端业务的需求,用uniapp做了ios、Android双端的app应用,由于没有资质上架AppStore和test flight,所以只能使用苹果的超签(需要ios用户提供uuid才能加入测试使用,并且只支持100人安装使用)。打包出来...
继续阅读 »

为何要扒


因为最近有移动端业务的需求,用uniapp做了ios、Android双端的app应用,由于没有资质上架AppStore和test flight,所以只能使用苹果的超签(需要ios用户提供uuid才能加入测试使用,并且只支持100人安装使用)。打包出来生成的是一个ipa包,并不能直接安装,要通过爱思助手这类的应用装一下ipa包。但交付到客户手上就有问题了,还需要电脑连接助手才能安装,那岂不是每次安装新版什么的,都要打开电脑搞一下。因此,才有了这次的扒一扒,目标就是为了解决只提供一个下载链接用户即可下载,不用再通过助手类应用安装ipa包。




开干


官方模板




先打开uniapp云打包一下项目看看


image-20230824112232275.png




复制地址到移动端浏览器打开看看


image-20230824112410817.png


这就对味了,都知道ios是不能直接打开ipa文件进行安装的,接下来就研究下这个页面的执行逻辑。




开扒




F12打开choromdevtools,ctrl+s保存网页html。


image.png


保存成功,接下来看看html代码(样式代码删除了)


    <!DOCTYPE html>
<!-- saved from url=(0077)https://ide.dcloud.net.cn/build/download/2425a4b0-4229-11ee-bd1b-67afccf2f6a7 -->
<html>
   <head>
       <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
  <meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0, user-scalable=no, width=device-width">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
 </head>

<body>
<br><br>
   <center>
       <a class="button" href="itms-services://?action=download-manifest&amp;url=https://ide.dcloud.net.cn/build/ipa-xxxxxxxxxxx.plist">点击安装</a>
   </center>
   <br><br>
   <center>注意:只提供包名为io.dcloud.appid的包的直接下载安装,如果包名不一致请自行搭建下载服务器</center>
</body>
</html>



解析




从上面代码可以看出,关键代码就一行也就是a标签的href地址("itms-services://?action=download-manifest&url=ide.dcloud.net.cn/build/ipa-x…")


先看看itms-services是什么意思,下面是代码开发助手给的解释


image-20230824113418246.png


大概意思就是itms-services是苹果提供给开发者一个的更新或安装应用的协议,用来做应用分发的,需要指向一个可下载的plist文件地址。




什么又是plist呢,这里再请我们的代码开发助手解释一下


image-20230824113748570.png


对于没接触过ios相关开发的,连plist文件怎么写都不知道,既然如此,那接下来就来扒一下dcloud的pilst文件,看看官方是怎么写的吧。




打开浏览器,copy一下刚刚扒下来的html文件下a标签指向的地址,复制url后面plist文件的下载地址粘贴到浏览器保存到桌面。


image-20230824114108792.png


访问后会出现


image-20230824115354028.png




别担心,这时候直接按ctrl+s可以直接保存一个plist.xml文件,也可以打开devtools查看网络请求,找到ipa开头的请求


image-20230824115609551.png


直接新建一个plist文件,cv一下就好,我这里就选择保存它的plist.xml文件,接下来康康文件里到底是什么


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>items</key>
<array>
<dict>
<key>assets</key>
<array>
<dict>
<key>kind</key>
<string>software-package</string>
<key>url</key>
<string>https://bdpkg-aliyun.dcloud.net.cn/20230824/xxxxx/Pandora.ipa?xxxxxxxx</string>
</dict>
      <dict>
<key>kind</key>
<string>display-image</string>
<key>needs-shine</key>
<false/>
<key>url</key>
<string>https://qiniu-web-assets.dcloud.net.cn/unidoc/zh/uni.png</string>
</dict>
</array>
<key>metadata</key>
<dict>
<key>kind</key>
<string>software</string>
<key>bundle-identifier</key>
<string>xxxxx</string>
<key>title</key>
<string>HBuilder手机应用</string>
</dict>
</dict>
</array>
</dict>
</plist>

直接抓重点,这里存你存放ipa包的地址


image-20230824120013828.png


这里改你应用的昵称


image-20230824120453368.png


这里改图标


image-20230824120509797.png


因篇幅限制,想了解plist的自行问代码助手或者搜索引擎。




为我所用


分析完了,如何为我所用呢,首先按照分析上扒下来的plist文件修改下自身应用的信息,并且需要服务器存放ipa文件,这里我选择了unicloud,开发者可以申请一个免费的空间(想了解更多的自己去dcloud官网看看,说多了有打广告嫌疑),替换好大概如下:


image-20230824155040313.png


将plist文件放到服务器上后,拿到plist的下载地址,打开扒下来的html,将a标签上的url切换成plist文件的下载地址,如图:


image-20230824155306228.png


可以把页面上没用的信息都删掉,保存,再把html放到服务器上,用户访问这个地址,就可以直接下载描述文件安装ipa包应用了(记得需要添加用户的uuid到开发者账号上),其实至此需求已经算是落幕了,但转念想想还是有点麻烦,于是又优化了一下,将a标签中的href信息,直接加载到二维码上供用户扫描便可直接下载,相对来说更方便一点,于是我直接打开草料,生成了一个二维码,至

作者:廿一c
来源:juejin.cn/post/7270799565963149324
此,本次扒拉过程结束,需求落幕!

收起阅读 »

iOS - 上手AR

iOS
前言 随着 Apple Vision Pro 的发布,势必掀起新一波的Ar潮,简单了解一下来个小Demo 开始 要在iOS中创建一个的AR物体,你可以使用 ARKit 和 SceneKit 来实现 首先,确保你的项目已经导入了 ARKit 和 SceneKit...
继续阅读 »

前言


随着 Apple Vision Pro 的发布,势必掀起新一波的Ar潮,简单了解一下来个小Demo


开始


要在iOS中创建一个的AR物体,你可以使用 ARKitSceneKit 来实现


首先,确保你的项目已经导入了 ARKit 和 SceneKit 框架。你可以在 Xcode 中的项目设置中添加 ARKit.framework 和 SceneKit.framework 到 "Frameworks, Libraries, and Embedded Content" 部分




然后,在你的程序文件中,导入 ARKit 和 SceneKit

import UIKit
import ARKit

接下来,创建一个 ARSCNView,并将其添加到你的视图层次结构中:

// 创建 ARSCNView 实例
sceneView = ARSCNView(frame: view.bounds)
view.addSubview(sceneView)
sceneView.delegate = self

// 创建 SCNScene 实例,并设置为 sceneView 的场景
let scene = SCNScene()
sceneView.scene = scene

然后,在视图控制器的生命周期方法中,配置 ARSession 并启动 AR 会话:

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)

// 配置 AR 会话并启动
let configuration = ARWorldTrackingConfiguration()
sceneView.session.run(configuration)
}

override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)

// 暂停 AR 会话
sceneView.session.pause()
}

现在,已经设置好 ARKit 和 AR 会话已经开始运行。接下来,我们将创建3D 模型:

func createBallNode() {
// 创建球体几何体
let ballGeometry = SCNSphere(radius: 0.1)
let ballMaterial = SCNMaterial()
ballMaterial.diffuse.contents = UIImage(named: "cxkj.webp") // 使用纹理图片
ballGeometry.materials = [ballMaterial]

// 在屏幕范围内生成随机位置坐标
let randomX = Float.random(in: -1.0...1.0) // 在屏幕宽度范围内生成随机 X 坐标
let randomY = Float.random(in: -1.0...1.0) // 在屏幕高度范围内生成随机 Y 坐标
let randomZ = Float.random(in: -3.0...0.0) // 在屏幕深度范围内生成随机 Z 坐标
ballNode = SCNNode(geometry: ballGeometry)
ballNode.position = SCNVector3(randomX, randomY, randomZ)

// 将球体节点添加到场景的根节点上
sceneView.scene.rootNode.addChildNode(ballNode)
}

最后我们通过点击事件将3D模型添加到场中

@objc func handleTap(_ gesture: UITapGestureRecognizer) {
if gesture.state == .ended {
createBallNode() // 创建球体节点
}
}

效果




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

iOS 快速复习GCD

iOS
多线程-串行、并行队列,同步、异步任务 1、创建串行队列和并行队列 //并行队列 dispatch_queue_t queue = dispatch_queue_create("com.lg.cooci.cn", DISPATCH_QUEUE_C...
继续阅读 »

多线程-串行、并行队列,同步、异步任务


1、创建串行队列和并行队列

    //并行队列
dispatch_queue_t queue = dispatch_queue_create("com.lg.cooci.cn", DISPATCH_QUEUE_CONCURRENT);
//串行队列
dispatch_queue_t queue = dispatch_queue_create("com.lg.cooci.cn", DISPATCH_QUEUE_SERIAL);

  • 每次只有一个任务被执行。让任务一个接着一个地执行。(只开启一个线程,一个任务执行完毕后,再执行下一个任务)
  • 可以让多个任务并发(同时)执行。(可以开启多个线程,并且同时执行任务),并发队列 的并发功能只有在异步(dispatch_async)方法下才有效。

2、同步异步任务

//同步
dispatch_sync(queue, ^{
        NSLog(@"1");
    });
//异步
dispatch_async(queue, ^{
        NSLog(@"1");
    });

同步执行:

  • 同步添加任务到指定的队列中,在添加的任务执行结束之前,会一直等待,直到队列里面的任务完成之后再继续执行。
  • 只能在当前线程中执行任务,不具备开启新线程的能力。

异步执行:

  • 异步添加任务到指定的队列中,它不会做任何等待,可以继续执行任务。
  • 可以在新的线程中执行任务,具备开启新线程的能力。

异步执行(async) 虽然具有开启新线程的能力,但是并不一定开启新线程。这跟任务所指定的队列类型有关。

默认全局并发队列:dispatch_get_global_queue

第一个参数表示队列优先级,一般用 DISPATCH_QUEUE_PRIORITY_DEFAULT

第二个参数暂时没用,用 0 即可。


信号量 dispatch_semaphore_t



GCD中的信号量dispatch_semaphore_t中主要有三个函数:

  • dispatch_semaphore_create:创建信号
  • dispatch_semaphore_wait:等待信号
  • dispatch_semaphore_signal:释放信号

1、dispatch_semaphore_create
参数为int,表示信号量初始值,需大于等于0,否则创建失败,返回一个dispatch_semaphore_t


2、dispatch_semaphore_wait
参数1:

需传递一个 dispatch_semaphore_t 类型对象,对信号进行减1,然后判断信号量大小

参数2:

传递一个超时时间:dispatch_time_t 对象

  • 减1后信号量小于0,则阻塞当前线程,直到超时时间到达或者信号量大于等于0后继续执行后面代码
  • 减1后信号量大于等于0,对dispatch_semaphore_t 进行赋值,并返回dispatch_semaphore_t对象,继续执行后面代码

3、dispatch_semaphore_signal
参数:dispatch_semaphore_t

进行信号量加1操作,如果加1后结果大于等于0,则继续执行,否则继续等待。


用法:

- (void)startAsync{
//创建信号量 值为0
    self.sem = dispatch_semaphore_create(0);
//开启异步并发线程执行
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        NSLog(@"dispatch_semaphore 2\n");
        sleep(5);
//发送信号,信号量值+1
        dispatch_semaphore_signal(self.sem);
        NSLog(@"dispatch_semaphore 3\n");
    });
    NSLog(@"dispatch_semaphore 0\n");
//信号量 值-1 小于0 等待信号。。。
    dispatch_semaphore_wait(self.sem, DISPATCH_TIME_FOREVER);
    NSLog(@"dispatch_semaphore 1\n");

}
执行顺序0 2 1 3 1和3不确定顺序
如果初始化创建是信号量值为1
执行顺序0 1 2 3

常用总结:

1、异步并发线程顺序执行

2、异步并发线程控制最大并发数,比如下载功能控制最大下载数


调度组 dispatch_group_t


主要API:

  • dispatch_group_create:创建组

  • dispatch_group_async:进组任务

  • dispatch_group_notify:组任务执行完毕的通知

  • dispatch_group_enter:进组

  • dispatch_group_leave:出组

  • dispatch_group_wait:等待组任务时间


组合用法1:

- (void)dispatchGroupAsync{
//创建调度组
    dispatch_group_t group = dispatch_group_create();
//获取全局并发队列
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
//开启异步线程
    dispatch_group_async(group, queue, ^{
        sleep(2);
        NSLog(@"11");
    });
    dispatch_group_async(group, queue, ^{
        sleep(1);
        NSLog(@"12");
    });
    dispatch_group_async(group, queue, ^{
        sleep(3);
        NSLog(@"13");
    });
    NSLog(@"14");
    dispatch_group_notify(group, queue, ^{
//收到执行完成的通知后执行
        NSLog(@"15");
    });
//等待调度组执行完成
    dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
调度组执行完成后执行
    NSLog(@"16");
}

用法2:

- (void)dispatchSyncEnterGroup{
//创建调度组
    dispatch_group_t group = dispatch_group_create();
//获取全局并发队列
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
//进入调度组
    dispatch_group_enter(group);
//执行异步任务
    dispatch_async(queue, ^{
        sleep(2);
        NSLog(@"21");
//执行完成后立刻调度组
        dispatch_group_leave(group);
    });
    dispatch_group_enter(group);
    dispatch_async(queue, ^{
        sleep(1);
        NSLog(@"22");
        dispatch_group_leave(group);
    });
    dispatch_group_enter(group);
    dispatch_async(queue, ^{
        sleep(3);
        NSLog(@"23");
        dispatch_group_leave(group);
    });
    NSLog(@"24");
    dispatch_group_notify(group, queue, ^{
//执行完后回调
        NSLog(@"25");
    });
    NSLog(@"26");
//等待调度组执行完成
    dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
    NSLog(@"27");
}

总结:

1、dispatch_group_async 是对dispatch_group_enter和dispatch_group_leave的封装

2、dispatch_group_enter和dispatch_group_leave的须成双成对的出现


事件源 dispatch_source_t


主要API:

  • dispatch_source_create :创建源

  • dispatch_source_set_event_handler: 设置源的回调

  • dispatch_source_merge_data: 源事件设置数据

  • dispatch_source_get_data: 获取源事件的数据

  • dispatch_resume:恢复继续

  • dispatch_suspend:挂起

  • uintptr_t dispatch_source_get_handle(dispatch_source_t source) //得到dispatch源创建,即调用dispatch_source_create的第二个参数

  • unsignedlong dispatch_source_get_mask(dispatch_source_t source); //得到dispatch源创建,即调用dispatch_source_create的第三个参数


源的类型dispatch_source_type_t

1. DISPATCH_SOURCE_TYPE_DATA_ADD:用于ADD合并数据
2. DISPATCH_SOURCE_TYPE_DATA_OR:用于按位或合并数据
3.DISPATCH_SOURCE_TYPE_DATA_REPLACE:跟踪通过调用dispatch_source_merge_data获得的数据的分派源,新获得的数据值将替换 尚未交付给源处理程序 的现有数据值
4. DISPATCH_SOURCE_TYPE_MACH_SEND:用于监视Mach端口的无效名称通知的调度源,只能发送没有接收权限
5. DISPATCH_SOURCE_TYPE_MACH_RECV:用于监视Mach端口的挂起消息
6. DISPATCH_SOURCE_TYPE_MEMORYPRESSURE:用于监控系统内存压力变化
7.DISPATCH_SOURCE_TYPE_PROC:用于监视外部进程的事件
8. DISPATCH_SOURCE_TYPE_READ:监视文件描述符以获取可读取的挂起字节的分派源
9. DISPATCH_SOURCE_TYPE_SIGNAL:监控当前进程以获取信号的调度源
10. DISPATCH_SOURCE_TYPE_TIMER:基于计时器提交事件处理程序块的分派源
11. DISPATCH_SOURCE_TYPE_VNODE:用于监视文件描述符中定义的事件的分派源
12. DISPATCH_SOURCE_TYPE_WRITE:监视文件描述符以获取可写入字节的可用缓冲区空间的分派源。

1、dispatch_source_create 参数:

  • dispatch_source_type_t 要创建的源类型
  • uintptr_t 句柄 用于和其他事件并定,很少用,通常为0
  • uintptr_t mask 很少用,通常为0
  • dispatch_queue_t 事件处理的调度队列

用法:

self.sourceAdd = dispatch_source_create(DISPATCH_SOURCE_TYPE_DATA_ADD, 0, 0, dispatch_get_global_queue(0, 0));

2、dispatch_source_set_event_handler 设置回调函数,当触发源事件时执行

//需要注意循环引用
dispatch_source_set_event_handler(self.sourceAdd, ^{
需要执行的代码
});
//启动
dispatch_resume(self.sourceAdd);
//挂起,即暂停
dispatch_suspend(self.sourceAdd);
这两个API需要成对使用,不可多次挂起或者多次恢复

3、dispatch_source_cancel 取消事件源,取消后不可再恢复或挂起,需要再次创建
4、dispatch_source_set_timer 当事件源类型为定时器类型(DISPATCH_SOURCE_TYPE_TIMER)时,设置开始时间、重复时间、允许时间误差


定时器实现比较简单容易,网上教程也多,这里主要介绍一下:DISPATCH_SOURCE_TYPE_DATA_ADD、DISPATCH_SOURCE_TYPE_DATA_OR、DISPATCH_SOURCE_TYPE_DATA_REPLACE。


先说下结果:

  • DISPATCH_SOURCE_TYPE_DATA_ADD 会把事件源累加 可以记录总共发送多少次事件进行合并
  • DISPATCH_SOURCE_TYPE_DATA_OR 会把事件源合并,最终得到的数据源数为1
  • DISPATCH_SOURCE_TYPE_DATA_REPLACE 会用最新事件源替换旧有未处理事件,最终得到的数据源数为1
  • 循环10000次实际跑处理回调事件次数 add315 or275 replace 284

从结果上来看,当需要把快速频繁的重复事件进行合并,最好的选择是DISPATCH_SOURCE_TYPE_DATA_OR,使用场景,监听消息时,多消息频繁下发需要刷新UI,如果不进行合并处理,会导致UI太过频繁的刷新,影响最终效果,且对性能开销过大。


当然,类似的场景也可使用其他方式处理,比如建立消息池,接收消息后标记消息池状态及变化,然后定时从消息池中取消息。诸如此类的方法较多,如果只是简单的处理,上面的DISPATCH_SOURCE_TYPE_DATA_OR模式应该满足使用。


代码:

//创建源
self.sourceAdd = dispatch_source_create(DISPATCH_SOURCE_TYPE_DATA_ADD, 0, 0, dispatch_get_global_queue(0, 0));
//弱引用
__weak typeof(self) weakifySelf = self;
//设置回调事件
dispatch_source_set_event_handler(self.sourceAdd, ^{
//强引用
__strong typeof(self) strongSelf = weakifySelf;
//获取接收到的源数据
strongSelf.handleData = dispatch_source_get_data(strongSelf.sourceAdd);
NSLog(@"dispatch_source1 %ld\n",strongSelf.handleData);
//需要执行的代码
[strongSelf sourceHandle];

        });
//开启源
dispatch_resume(self.sourceAdd);
for (int i = 0; i<10000; i ++) {

[self dispatchSource];
}
- (void)dispatchSource{

    NSLog(@"dispatch_source2 %ld\n",self.handleData);
//发送源信号
    dispatch_source_merge_data(self.sourceAdd, 1);
}

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

App本地配置持久化方案

iOS
概述 在App开发过程中,会遇到很多简单配置项的持久化需求。比如App最近一次启动的时间,App最后一次登陆的用户ID,用户首次使用功能的判断条件。并且随着业务的扩展,零碎的配置还会不断增加。 UserDefaults Apple提供了UserDefault框...
继续阅读 »

概述


在App开发过程中,会遇到很多简单配置项的持久化需求。比如App最近一次启动的时间,App最后一次登陆的用户ID,用户首次使用功能的判断条件。并且随着业务的扩展,零碎的配置还会不断增加。


UserDefaults


Apple提供了UserDefault框架来帮助我们存储离散的配置,UserDefaults将以plist文件的形式存储在沙盒环境中。在不引入NoSql数据库的情况下,这是首推的方案。


注意事项


为了提升读取速度,App在启动时会将UserDefaults Standard对应的plist加载到内存中,如果文件过大就会增加App在启动时的加载时间,同时提高一定的内存消耗。


所以在Standard中,我们应该存放需要在App启动阶段立即获取的信息,比如用户最近登录的ID,App远程配置缓存的版本。


我们可以通过分表来缩减Standard的数据量。使用UserDefaults的suiteName模式创建不同的配置表,这样配置项将存储到各自的plist文件中,这些独立的plist不会在启动时被自动加载。


配置管理的常见问题

  1. 使用硬编码的String Key将配置存储到UserDefaults中,通过复制粘贴Key的字符串来存取数据。

  2. 零散的使用UserDefaults,缺少中心化管理方案。比如需要存储“开启通知功能”的配置,Key通常会直接被放在业务相关代码中维护。


方案 1.0


管理UserDefaults


创建一个UserDefault的管理类,主要用途是对UserDefault框架使用的收口,统一使用策略。

public class UserDefaultsManager {
public static let shared = UserDefaultsManager()
private init() {}
public var suiteName:String? {
didSet {
/**
根据传入的 suiteName的不同会产生四种情况:
传入 nil:跟使用UserDefaults.standard效果相同;
传入 bundle id:无效,返回 nil;
传入 App Groups 配置中 Group ID:会操作 APP 的共享目录中创建的以Group ID命名的 plist 文件,方便宿主应用与扩展应用之间共享数据;
传入其他值:操作的是沙箱中 Library/Preferences 目录下以 suiteName 命名的 plist 文件。
*/
userDefault = UserDefaults(suiteName: suiteName) ?? UserDefaults.standard
}
}
public var userDefault = UserDefaults.standard
}


创建常量表

  1. 对配置项的Key进行中心化的注册与维护
struct UserDefaultsKey {
static let appLanguageCode = "appLanguageCode"
static let lastLaunchSaleDate = "resetLastLaunchSaleDate"
static let lastSaleDate = "lastSaleDate"
static let lastSaveRateDate = "lastSaveRateDate"
static let lastVibrateTime = "lastVibrateTime"
static let exportedImageSaveCount = "exportedImageSaveCount"

static let onceFirstLaunchDate = "onceFirstLaunchDate"
static let onceServerUserIdStr = "onceServerUserIdStr"
static let onceDidClickCanvasButton = "onceDidClickCanvasButton"
static let onceDidClickCanvasTips = "onceDidClickCanvasTips"
static let onceDidClickEditBarGuide = "onceDidClickEditBarGuide"
static let onceDidClickEditFreestyleGuide = "onceDidClickEditFreestyleGuide"
static let onceDidClickManualCutoutGuide = "onceDidClickManualCutoutGuide"
static let onceDidClickBackgroundBlurGuide = "onceDidClickBackgroundBlurGuide"
static let onceDidTapCustomStickerBubble = "onceDidTapCustomStickerBubble"
static let onceDidRequestHomeTemplatesFromAPI = "onceDidRequestHomeTemplatesFromAPI"

static let firSaveExportTemplateKey = "firSaveExportTemplateKey"
static let firSaveTemplateDateKey = "firSaveTemplateDateKey"
static let firShareExportTemplateKey = "firShareExportTemplateKey"
static let firShareTemplateDateKey = "firShareTemplateDateKey"
}

2. 提供CURD API
private let appConfigUserDefaults = UserDefaultsManager(suiteName: "com.pe.config").userDefaults

var exportedImageSaveCount: Int {
return appConfigUserDefaults.integer(forKey: key)
}

func increaseExportedImageSaveCount() {
let key = UserDefaultsKey.exportedImageSaveCount
var count = appConfigUserDefaults.integer(forKey: key)
count += 1
appConfigUserDefaults.setValue(count, forKey: key)
}

我们对UserDefaults数据源进行了封装,String Key的注册也统一到常量文件中。当我们要查找或修改时,可以从配置表方便的查到String Key。


随着业务的膨胀,配置项会越来越多,我们会需要根据业务功能的分类,重新整理出多个分表。


随后我们会发现一些问题:

  1. String Key的注册虽然不麻烦,但Key中无法体现出Key归属与哪个UserDefaults。

  2. CURD API的数量会膨胀的更快,需要更多的维护成本。那么能不能将配置的管理更加面向对象,实现类似ORM的方式来管理呢?


方案2.0


根据上述的问题,来演化下方案2.0,我们来创建一个协议,用来规范UserDefaults的使用类。


它将包含CURD API的默认实现,初始化关联UserDefaults,自动生成String Key。

/// UserDefaults存储协议,建议用String类型的枚举去实现该协议
public protocol UserDefaultPreference {

var userDefaults: UserDefaults { get }
var key: String { get }

var bool: Bool { get }
var int: Int { get }
var float: Float { get }
var double: Double { get }

var string: String? { get }
var stringValue: String { get }

var dictionary: [String: Any]? { get }
var dictionaryValue: [String: Any] { get }

var array: [Any]? { get }
var arrayValue: [Any] { get }

var stringArray: [String]? { get }
var stringArrayValue: [String] { get }

var data: Data? { get }
var dataValue: Data { get }

var object: Any? { get }
var url: URL? { get }

func codableObject<T: Decodable>(_ as:T.Type) -> T?

func save<T: Encodable>(codableObject: T) -> Bool

func save(string: String)
func save(object: Any?)
func save(int: Int)
func save(float: Float)
func save(double: Double)
func save(bool: Bool)
func save(url: URL?)
func remove()
}

定义完协议后,我们再添加一些默认实现,降低使用成本。

// 生成默认的String Key
public extension UserDefaultPreference where Self: RawRepresentable, Self.RawValue == String {
var key: String { return "\(type(of: self)).\(rawValue)" }
}

public extension UserDefaultPreference {
// 默认使用 standard UserDefaults,可以在实现类中配置
var userDefaults: UserDefaults { return UserDefaultsManager.shared.userDefaults }

func codableObject<T: Decodable>(_ as:T.Type) -> T? {
return UserDefaultsManager.codableObject(`as`, key: key, userDefaults: userDefaults)
}

@discardableResult
func save<T: Encodable>(codableObject: T) -> Bool {
return UserDefaultsManager.save(codableObject: codableObject, key: key, userDefaults: userDefaults)
}

var object: Any? { return userDefaults.object(forKey: key) }

func hasKey() -> Bool { return userDefaults.object(forKey: key) != nil }

var url: URL? { return userDefaults.url(forKey: key) }

var string: String? { return userDefaults.string(forKey: key) }
var stringValue: String { return string ?? "" }

var dictionary: [String: Any]? { return userDefaults.dictionary(forKey: key) }
var dictionaryValue: [String: Any] { return dictionary ?? [String: Any]() }

var array: [Any]? { return userDefaults.array(forKey: key) }
var arrayValue: [Any] { return array ?? [Any]() }

var stringArray: [String]? { return userDefaults.stringArray(forKey: key) }
var stringArrayValue: [String] { return stringArray ?? [String]() }

var data: Data? { return userDefaults.data(forKey: key) }
var dataValue: Data { return userDefaults.data(forKey: key) ?? Data() }

var bool: Bool { return userDefaults.bool(forKey: key) }
var boolValue: Bool? {
guard hasKey() else { return nil }
return bool
}

var int: Int { return userDefaults.integer(forKey: key) }
var intValue: Int? {
guard hasKey() else { return nil }
return int
}

var float: Float { return userDefaults.float(forKey: key) }
var floatValue: Float? {
guard hasKey() else { return nil }
return float
}

var double: Double { return userDefaults.double(forKey: key) }
var doubleValue: Double? {
guard hasKey() else { return nil }
return double
}

func save(object: Any?) { userDefaults.set(object, forKey: key) }
func save(string: String) { userDefaults.set(string, forKey: key) }
func save(int: Int) { userDefaults.set(int, forKey: key) }
func save(float: Float) { userDefaults.set(float, forKey: key) }
func save(double: Double) { userDefaults.set(double, forKey: key) }
func save(bool: Bool) { userDefaults.set(bool, forKey: key) }
func save(url: URL?) { userDefaults.set(url, forKey: key) }

func remove() { userDefaults.removeObject(forKey: key) }
}

OK,我们来看下使用的案例

// MARK: - Launch
enum LaunchEventKey: String {
case didShowLaunchGuideOnThisLaunch
case launchGuideIsAlreadyShow
}
extension LaunchEventKey: UserDefaultPreference { }

func checkIfNeedLaunchGuide() -> Bool {
return !LaunchEventKey.launchGuideIsAlreadyShow.bool
}
func launchContentView() {
LaunchEventKey.launchGuideIsAlreadyShow.save(bool: true)
}

// MARK: - Language
enum LanguageEventKey: String {
case appLanguageCode
}
extension LanguageEventKey: UserDefaultPreference { }

static var appLanguageCode: String {
get {
let code = LanguageEventKey.appLanguageCode.string ?? ""
return code
}
set {
LanguageEventKey.appLanguageCode.save(codableObject: newValue)
}
}

// MARK: - Purchase
enum PurchaseStatusKey: String {
case iapSubscribeExpireDate
}
extension PurchaseStatusKey: UserDefaultPreference { }

func handle() {
let expirationDate: Date = Entitlement.expirationDate
PurchaseStatusKey.iapSubscribeExpireDate.save(object: expirationDate)
}

func getValues() {
let subscribeExpireDate = PurchaseStatusKey.iapSubscribeExpireDate.object as? Date
}

// MARK: - GlobalConfig
enum AppConfig: String {
case globalConfig
}

private let appConfigUserDefaults = UserDefaultsManager(suiteName: "com.pe.AppConfig").userDefaults

extension AppConfig: UserDefaultPreference {
var userDefaults: UserDefaults { return appConfigUserDefaults }
}

// 自定义类型
public class GlobalConfig: Codable {
/// 配置版本号
let configVersion: Int
/// 用户初始试用次数
let userInitialTrialCount: Int
/// 生成时间 如:2022-09-19T02:58:31Z
let createDate: String

enum CodingKeys: String, CodingKey {
case configVersion = "version"
case userInitialTrialCount = "user_initial_trial_count"
case createDate = "create_date"
}
...
}

lazy var globalConfig: GlobalConfig = {
guard let config = AppConfig.globalConfig.codableObject(GlobalConfig.self) else {
return GlobalConfig()
}
return config
}() {
didSet { AppConfig.globalConfig.save(codableObject: globalConfig) }
}

从上述案例可以看出,在配置项的注册和维护成本相对方案1.0有了大幅度的降低,对UserDefaults的使用进行了规范性的约束,提供了更方便的CURD API,使用方式也更加符合面向对象的习惯。


同时为了满足复杂结构体的存储需求,我们可以扩展实现Codable对象的存取逻辑。


总结


本方案的目的是解决乱象丛生的UserDefaults的使用情况,分析后向两个方向进行了优化:

  1. 提供中心化的配置方式,关联UserDefaults、维护String Key。
  2. 提供类ORM的管理方式,减少业务的接入成本。


针对更复杂的、类缓存集合的,或者有查询需求的配置项管理,请尽快用NoSQL替换,避免数据量上升带来的效率下降。


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

iOS 拖拽式控件:QiDragView

iOS
首先,我们先看一下QiDragView的效果图:  一、QiDragView整体架构设计 话不多说,上架构图~ QiDragView(QiDragSortView)是一种可选择可拖拽的自定义控件,可以满足一些拖拽排序的业务需求场景。 二、如何自定义...
继续阅读 »

首先,我们先看一下QiDragView的效果图: 


一、QiDragView整体架构设计


话不多说,上架构图~



QiDragView(QiDragSortView)是一种可选择可拖拽的自定义控件,可以满足一些拖拽排序的业务需求场景。


二、如何自定义使用QiDragView?


在上Demo之前,先介绍几个可以自定义的UI配置属性:



以及一些逻辑配置属性:



使用起来也很方便:

  • 直接设置titles即可创建出对应title的Buttons。
  • 通过dragSortEnded的block方法回调,处理拖拽后的业务逻辑:按钮的排序、按钮是否选择等属性

默认配置用法:

QiDragSortView *dragSortView = [[QiDragSortView alloc] initWithFrame:CGRectMake(.0, 100.0, self.view.bounds.size.width, .0)];
dragSortView.backgroundColor = [[UIColor lightGrayColor] colorWithAlphaComponent:.5];

dragSortView.titles = @[@"首页推荐", @"奇舞周刊", @"众成翻译", @"QiShare", @"HULK一线杂谈", @"Qtest之道"];//!< 初始的Buttons(必填)
[self.view addSubview:dragSortView];

//! 拖拽方法回调:能拿到Button数组的排序和选择状态
dragSortView.dragSortEnded = ^(NSArray<UIButton *> * _Nonnull buttons) {
for (UIButton *button in buttons) {
NSLog(@"title: %@, selected: %i", button.currentTitle, button.isSelected);
}
};

自定义配置用法:

QiDragSortView *dragSortView = [[QiDragSortView alloc] initWithFrame:CGRectMake(.0, 100.0, self.view.bounds.size.width, .0)];
dragSortView.backgroundColor = [[UIColor lightGrayColor] colorWithAlphaComponent:.5];
dragSortView.rowHeight = 50.0;
dragSortView.rowMargin = 30.0;
dragSortView.rowPadding = 20.0;
dragSortView.columnCount = 3;
dragSortView.columnMargin = 30.0;
dragSortView.columnPadding = 20.0;
dragSortView.normalColor = [UIColor redColor];
dragSortView.selectedColor = [UIColor purpleColor];
dragSortView.enabledTitles = @[@"奇舞周刊", @"众成翻译", @"QiShare", @"HULK一线杂谈", @"Qtest之道"];//!< 可以点击选择的Buttons(选填,默认全选)
dragSortView.selectedTitles = @[@"首页推荐", @"HULK一线杂谈", @"Qtest之道"];//!< 初始选择的Buttons(选填,默认全选)
dragSortView.titles = @[@"首页推荐", @"奇舞周刊", @"众成翻译", @"QiShare", @"HULK一线杂谈", @"Qtest之道"];//!< 初始的Buttons(必填)
[self.view addSubview:dragSortView];

//! 拖拽方法回调:能拿到Button数组的排序和选择状态
dragSortView.dragSortEnded = ^(NSArray<UIButton *> * _Nonnull buttons) {
for (UIButton *button in buttons) {
NSLog(@"title: %@, selected: %i", button.currentTitle, button.isSelected);
}
};

三、QiDragView的技术点


3.1 长按手势:


长按手势分别对应三种状态:UIGestureRecognizerStateBeganUIGestureRecognizerStateChangedUIGestureRecognizerStateEnded

//! 长按手势
- (void)longPress:(UILongPressGestureRecognizer *)gesture {

UIButton *currentButton = (UIButton *)gesture.view;

if (gesture.state == UIGestureRecognizerStateBegan) {

[self bringSubviewToFront:currentButton];

[UIView animateWithDuration:.25 animations:^{
self.originButtonCenter = currentButton.center;
self.originGesturePoint = [gesture locationInView:currentButton];
currentButton.transform = CGAffineTransformScale(currentButton.transform, 1.2, 1.2);
}];
}
else if (gesture.state == UIGestureRecognizerStateEnded) {

[UIView animateWithDuration:.25 animations:^{
currentButton.center = self.originButtonCenter;
currentButton.transform = CGAffineTransformIdentity;
} completion:^(BOOL finished) {
if (self.dragSortEnded) {
self.dragSortEnded(self.buttons);
}
}];
}
else if (gesture.state == UIGestureRecognizerStateChanged) {

CGPoint gesturePoint = [gesture locationInView:currentButton];
CGFloat deltaX = gesturePoint.x - _originGesturePoint.x;
CGFloat deltaY = gesturePoint.y - _originGesturePoint.y;
currentButton.center = CGPointMake(currentButton.center.x + deltaX, currentButton.center.y + deltaY);

NSInteger fromIndex = currentButton.tag;
NSInteger toIndex = [self toIndexWithCurrentButton:currentButton];

if (toIndex >= 0) {
currentButton.tag = toIndex;

if (toIndex > fromIndex) {
for (NSInteger i = fromIndex; i < toIndex; i++) {
UIButton *nextButton = _buttons[i + 1];
CGPoint tempPoint = nextButton.center;
[UIView animateWithDuration:.5 animations:^{
nextButton.center = self.originButtonCenter;
}];
_originButtonCenter = tempPoint;
nextButton.tag = i;
}
}
else if (toIndex < fromIndex) {
for (NSInteger i = fromIndex; i > toIndex; i--) {
UIButton *previousButton = self.buttons[i - 1];
CGPoint tempPoint = previousButton.center;
[UIView animateWithDuration:.5 animations:^{
previousButton.center = self.originButtonCenter;
}];
_originButtonCenter = tempPoint;
previousButton.tag = i;
}
}
[_buttons sortUsingComparator:^NSComparisonResult(UIButton *obj1, UIButton *obj2) {
return obj1.tag > obj2.tag;
}];
}
}
}

3.2 配置按钮:


设计思路:在属性titles的setter方法中,初始化并配置好各个Buttons。

- (void)setTitles:(NSArray<NSString *> *)titles {

_titles = titles;

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(.01 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSInteger differCount = titles.count - self.buttons.count;

if (differCount > 0) {
for (NSInteger i = self.buttons.count; i < titles.count; i++) {
[self.buttons addObject:[self buttonWithTag:i]];
}
}
else if (differCount < 0) {
NSArray *extraButtons = [self.buttons subarrayWithRange:(NSRange){titles.count, self.buttons.count - titles.count}];
[self.buttons removeObjectsInArray:extraButtons];
for (UIButton *button in extraButtons) {
[button removeFromSuperview];
}
}

self.enabledTitles = self.enabledTitles ?: titles;//!< 如果有,就传入,否则传入titles
self.selectedTitles = self.selectedTitles ?: titles;

for (NSInteger i = 0; i < self.buttons.count; i++) {
[self.buttons[i] setTitle:titles[i] forState:UIControlStateNormal];
[self.buttons[i] addGestureRecognizer:[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPress:)]];//!< 长按手势
[self selectButton:self.buttons[i] forStatus:[self.selectedTitles containsObject:titles[i]]];
if ([self.enabledTitles containsObject:titles[i]]) {
[self.buttons[i] addTarget:self action:@selector(buttonClicked:) forControlEvents:UIControlEventTouchUpInside];
}
}

for (NSInteger i = 0; i < self.buttons.count; i++) {
NSInteger rowIndex = i / self.columnCount;
NSInteger columnIndex = i % self.columnCount;
CGFloat buttonWidth = (self.bounds.size.width - self.columnMargin * 2 - self.columnPadding * (self.columnCount - 1)) / self.columnCount;
CGFloat buttonX = self.columnMargin + columnIndex * (buttonWidth + self.columnPadding);
CGFloat buttonY = self.rowMargin + rowIndex * (self.rowHeight + self.rowPadding);
self.buttons[i].frame = CGRectMake(buttonX, buttonY, buttonWidth, self.rowHeight);
}

CGRect frame = self.frame;
NSInteger rowCount = ceilf((CGFloat)self.buttons.count / (CGFloat)self.columnCount);
frame.size.height = self.rowMargin * 2 + self.rowHeight * rowCount + self.rowPadding * (rowCount - 1);
self.frame = frame;
});
}

源码地址:QiDragView


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

第三方库并不是必须的

iOS
前言 我在Lyft的八年间,很多产品经理以及工程师经常想往我们 app 里添加第三方库。有时候集成一个特定的库(比如 PayPal)是必须的,有时候是避免去开发一些非常复杂的功能,有时候仅仅只是避免c重复造轮子。 虽然这些都是合理的考量,但使用第三方库的风险和...
继续阅读 »

前言


我在Lyft的八年间,很多产品经理以及工程师经常想往我们 app 里添加第三方库。有时候集成一个特定的库(比如 PayPal)是必须的,有时候是避免去开发一些非常复杂的功能,有时候仅仅只是避免c重复造轮子。


虽然这些都是合理的考量,但使用第三方库的风险和相关成本往往被忽视或误解。在某些情况下,风险是值得的,但是在决定冒险之前,首先要能够明确的定义风险。为了使风险评估更加的透明和一致,我们制定了一个流程来衡量我们将其集成到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 而被拒绝的风险。


主要关注点


闭源。 访问源代码意味着我们可以选择我们想要包含的库的哪些部分,以及如何将该源代码与应用程序的其余部分捆绑在一起。 对于我们来说,一个封闭源代码的二进制发行版更难集成。


编译时有警告。 我们启用了“警告视为错误”,具有编译警告的库是库整体质量(下降)的良好指示。


糟糕的文档。 我们希望有高质量的内联文档,外部”如何使用“文档,以及有意义的更新日志。


二进制体积。 这个库有多大?一些库提供了很多功能,而我们只需要其中的一小部分。尤其是在没有访问源码权限的情况下,这通常是一个全有或全无的情况。


外部的网络流量。 与我们无法控制的上游服务器/端点通信的库可能会在服务器关闭、错误数据被发回等时关闭整个应用程序。这也与我上面提到的隐私问题相同。


技术支持。 当事情不能正常工作时,我们需要能够报告/上报问题,并在合理的时间内解决问题。开源项目通常由志愿者维护,也很难有一个时间线,但至少我们可以自己进行修改。这在闭源项目是不可能的。


无法禁用。 虽然大多数库特别要求我们初始化它,但有些库在实例化时更“主动”,并且在我们不调用它的情况下可以自己执行工作。这意味着当库导致问题时,我们无法通过功能变量或其他机制将其关闭。


我们为所有这些(和其他一些)标准分配了点数,并要求工程师为他们想要集成的库汇总这些点数。虽然默认情况下,低分数并不难被拒绝,但我们通常会要求更多的理由来继续前进。


最后


虽然这个过程看起来非常严格,在许多情况下,潜在风险是假设的,但我们有我在这篇博文中描述的每个场景的实际例子。将评估记录下来并公开,也有助于将相对风险传达给不熟悉移动平台工作方式的人,并证明我们没有随意评估风险。


此外,我不想声称每一个第三方库本质上都是坏的。事实上,我们在Lyft使用了很多:RxSwiftRxJavaBugsnagSDKGoogle MapsTensorflow,以及一些较小的用于非常特定的用例。但所有这些要么都经过了充分审查,要么我们已经决定风险值得收益,同时对这些风险和收益的真正含义有了清晰的认识。


最后,作为一个专业开发人员提示:始终在库的API之上创建自己的抽象,不要直接调用它们的API。这使得将来替换(或删除)底层库更加容易,再次减轻了与长期开发相关的一些风险。


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

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

iOS
这里每天分享一个 iOS 的新知识,快来关注我吧 访问敏感数据的 App 新规 苹果最近在 Apple Developer 上发了篇新闻公告,对需要访问用户敏感数据的 App 增加了审核要求。 这件事的缘由是苹果发现有一小部分 API 可能会被开发者滥用,通过...
继续阅读 »


这里每天分享一个 iOS 的新知识,快来关注我吧


访问敏感数据的 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…


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

iOS 陀螺仪技术的应用探究

iOS
本文源自本人的学习记录整理与理解,其中参考阅读了部分优秀的博客和书籍,尽量以通俗简单的语句转述。引用到的地方如有遗漏或未能一一列举原文出处还望见谅与指出,另文章内容如有不妥之处还望指教,万分感谢。 前言 陀螺仪是一种硬件传感器,能够感知设备的旋转和方向变化。...
继续阅读 »

本文源自本人的学习记录整理与理解,其中参考阅读了部分优秀的博客和书籍,尽量以通俗简单的语句转述。引用到的地方如有遗漏或未能一一列举原文出处还望见谅与指出,另文章内容如有不妥之处还望指教,万分感谢。



前言


陀螺仪是一种硬件传感器,能够感知设备的旋转和方向变化。它通常通过MEMS(微机电系统)技术来实现,内部包含了微小但高精度的陀螺仪器件、加速度计和磁力计等传感器,可以实时地感知设备在空间中的旋转角度和方向。


在iOS系统中,可以通过CoreMotion框架来访问陀螺仪的数据。在开发iOS应用程序时,可以使用CoreMotion框架提供了一个CMMotionManager类,该类可以用来获取设备的运动数据,包括陀螺仪数据、加速度计数据等。


iOS陀螺仪的精度和灵敏度通常比较高,可以实现比加速度计更加准确的姿态估计和方向识别,也可以帮助开发者实现更加真实的虚拟现实和增强现实应用。同时,iOS陀螺仪的实时响应和低功耗特性,也使得它在移动应用程序开发中得到了广泛的应用和认可。


基础知识


在开发前,有几个基础的知识点,我们需要事先了解,这对我们后期开发会有更好的帮助


三轴方向


在 iOS 中,陀螺仪传感器的三轴方向通常遵循右手系的规则。具体来说:

  • x 轴:表示设备绕着横轴旋转。当设备的屏幕朝上时,x 轴指向设备的右侧;当设备的屏幕朝下时,x 轴指向设备的左侧
  • y 轴:表示设备绕着纵轴旋转。当设备的屏幕朝上时,y 轴指向设备的顶部;当设备的屏幕朝下时,y 轴指向设备的底部
  • z 轴:表示设备绕着竖轴旋转。当设备的屏幕朝上时,z 轴指向设备的正面;当设备的屏幕朝下时,z 轴指向设备的背面



姿态信息


陀螺仪用于侦测设备沿三个轴为中线所旋转时的角速度,故有了三个姿态信息,分别为 pitch (纵倾), roll (横倾) 和 yaw (横摆)

  • pitch(俯仰角):表示设备绕着 x 轴旋转的角度,也称为纵倾角。当设备正面朝上时,俯仰角为 0°;当设备向上仰起时,俯仰角为正值;当设备向下倾斜时,俯仰角为负值
  • roll(横滚角):表示设备绕着 y 轴旋转的角度,也称为横倾角。当设备正面朝上时,横滚角为 0°;当设备向右侧倾斜时,横滚角为正值;当设备向左侧倾斜时,横滚角为负值
  • yaw(偏航角):表示设备绕着 z 轴旋转的角度,也称为横摆角。当设备正面朝北时,偏航角为 0°;当设备逆时针旋转时,偏航角为正值;当设备顺时针旋转时,偏航角为负值
  • CMRotationMatrix 结构体表示设备绕X、Y、Z轴的旋转矩阵,可用于描述设备在三维空间中的方向和旋转状态, 这里再细讲该结构体中9个元素所代表的含义


陀螺仪的使用

import CoreMotion

let motionManager = CMMotionManager()
if motionManager.isGyroAvailable {
motionManager.gyroUpdateInterval = 0.1
motionManager.startGyroUpdates(to: OperationQueue.main) { (data, error) in
if let gyroData = data {
let rotationRateX = gyroData.rotationRate.x
let rotationRateY = gyroData.rotationRate.y
let rotationRateZ = gyroData.rotationRate.z

print("Rotation Rate X: \(rotationRateX)")
print("Rotation Rate Y: \(rotationRateY)")
print("Rotation Rate Z: \(rotationRateZ)")
}
}
} else {
print("Gyroscope is not available.")
}

关键类解析


CMDeviceMotion


CMDeviceMotion 是一个 Core Motion 框架中的类,用于表示设备的运动和姿态信息。通过 CMDeviceMotion 类,可以获取到设备在三维空间中的加速度、旋转速度、重力加速度、旋转矩阵以及设备的姿态信息等,以便进一步进行处理和计算。


下面是 CMDeviceMotion 类中常用的属性和方法:

  • attitude 属性:表示设备的姿态信息,包括俯仰角(pitch)、横滚角(roll)和偏航角(yaw)等信息。
  • userAcceleration 属性:表示设备在三维空间中的加速度,即不包括重力加速度的加速度
  • rotationRate 属性:表示设备在三维空间中的旋转速度
  • gravity 属性:表示设备在三维空间中的重力加速度,即不包括设备加速度的重力加速度

需要注意的是,在使用 CMDeviceMotion 类时,需要首先创建一个 CMMotionManager 对象,并设置其属性和回调函数,以便获取设备的运动和姿态信息。此外,由于设备运动和姿态信息的获取涉及到多个传感器的协同工作,因此在使用时需要考虑传感器的准确性和稳定性,以避免误差和不良体验。


CMAttitude


CMAttitude 表示设备在三维空间中的姿态信息,包括设备的旋转、倾斜、方向等信息。在 iOS 开发中,可以通过 CMMotionManager 获取设备的姿态信息,然后将其保存为 CMAttitude 对象,并使用其中的各个属性来进行相应的处理和计算。


CMAttitude 类中的主要属性如下:

  • pitch:设备绕 x 轴的旋转角度,单位为弧度
  • roll:设备绕 y 轴的旋转角度,单位为弧度
  • yaw:设备绕 z 轴的旋转角度,单位为弧度
  • quaternion:设备的四元数表示,用于表示设备的旋转状态,包括旋转角度和旋转轴等信息
  • rotationMatrix:设备的旋转矩阵表示,用于表示设备在三维空间中的旋转状态

其中,pitchroll 和 yaw 属性是最基本的属性,用于表示设备绕 x、y、z 轴的旋转角度。一般来说,可以通过这三个属性来进行设备的姿态检测和相应的处理。其余的属性包括四元数、旋转矩阵,都可以用于更加精确和复杂的姿态检测和处理。


应用场景

  • 姿态估计和方向识别:通过陀螺仪获取设备旋转的角度和方向,可以实现设备的姿态估计和方向识别,广泛应用于游戏、导航、运动感知等领域
  • 图像校正和稳定:通过将陀螺仪中的旋转信息应用于图像处理,可以实现图像校正和稳定,提高图像质量和用户体验
  • 虚拟现实和增强现实:通过与其他传感器的结合和数据处理,可以实现更加真实的虚拟现实和增强现实应用,如3D游戏、AR导航、AR应用等
  • 运动检测和姿态跟踪:通过结合加速度计和地磁计等传感器的信息,可以实现设备的运动检测和姿态跟踪,如步数统计、运动轨迹记录、体感游戏等
  • 安全防护和权限控制:通过使用陀螺仪的数据,可以实现设备的安全防护和权限控制,如设备锁定、用户身份验证、数据加密等

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

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,安卓也有涉猎,并且微信小程序可以接私活,挣外快;我坚信,继续坚持自我的修行之路,不断的提高自己的技能,一定能成为更加优秀的程序员。

生活虽然平淡如水,但总能在不经意间有一些小收获,我想,这也算一种幸福的生活。

最后,环信真的是一款优秀的产品,文档通俗易懂,接口功能丰富,在这个环信十周年之际,我祝愿环信越办越好,发展壮大,奋勇向前。

收起阅读 »