此系列为官方文档翻译,用于学习SceneKit

SCNPhysicsShape 物理体实体的抽象,用于调整碰撞检测。

概述

SceneKit对场景中的SCNPhysicsBody对象进行接触检测和其他模拟时,它使用物理形状而不是可见对象的渲染几何。这种方法既提高了模拟性能,也使您更容易设计基于玩家可以交互的场景元素的游戏玩法。

简单与复杂的形状

当您允许SceneKit自动创建物理形状时,它会使用尽可能简单的形状来匹配物理体所附加的节点的几何形状。这种方法可以最大程度地提高模拟性能,但对于某些对象可能导致不真实的物理行为。
您可以通过定义更接近场景可见几何形状的物理形状来使模拟的行为更加真实。但这种方法会降低性能,因此您需要限制物理形状的细节数量。仅在对应用程序的精确碰撞检测很重要的物体上使用最高级别的细节。

如果您使用基本几何类(SCNBoxSCNSphereSCNPyramidSCNConeSCNCylinderSCNCapsule)之一创建物理形状,则SceneKit使用该几何形状的理想形式来创建物理形状,而不是使用几何形状的顶点数据来模拟碰撞。例如,如果您从SCNSphere对象创建一个物理形状,则SceneKit将模拟射线通过球的半径时的相互碰撞。

由于简单几何的理想形式比用于显示它们所需的顶点数据在计算上简单得多,因此使用基本几何形状(或使用init(shapes:transforms :)方法创建的复合形状)通常提供了模拟精度和性能之间的最佳平衡。

更改物理体的形状

物理形状是不可变的,但您可以通过创建新的SCNPhysicsShape实例并将其分配给体的physicsShape属性来更改与物理体关联的形状。

相关API

创建物理形状

基于几何对象创建物理形状。

1
init(geometry: SCNGeometry, options: [SCNPhysicsShape.Option : Any]?)

从节点或节点层次结构创建物理形状。

1
init(node: SCNNode, options: [SCNPhysicsShape.Option : Any]?)

组合物理形状

通过组合其他物理形状创建新的物理形状。

1
init(shapes: [SCNPhysicsShape], transforms: [NSValue]?)

获取有关形状的信息

用于创建形状的对象。

1
var sourceObject: Any

用于创建形状的选项字典。

1
var options: [SCNPhysicsShape.Option : Any]?

用于创建复合形状的变换数组。

1
var transforms: [NSValue]?

形状选项

创建物理形状时使用的选项字典的键。

1
struct SCNPhysicsShape.Option

扩展

详细内容请看:SCNPhysicsShape

此系列为官方文档翻译,用于学习SceneKit

概述

SceneKit准备渲染新帧时,会对场景中附加在节点上的物理体执行物理计算。 这些计算包括重力、摩擦和与其他物体的碰撞。 您还可以将自己的力和冲量应用于物体。 在SceneKit完成这些计算之后,它会在渲染帧之前更新节点对象的位置和方向。

要向节点添加物理,创建和配置SCNPhysicsBody对象,然后将其分配给SCNNode对象的physicsBody属性。 在您应用力或冲量之前,必须将物理体与节点对象关联。

一个物体的物理特性

当场景模拟物体时,SCNPhysicsBody类定义物体的物理特性。对于物理模拟,有三个属性最重要:

  • type 属性,它确定物体如何与模拟中的力和其他物体交互。 静态物体不受力和碰撞影响且无法移动。动态物体受到力和与其他类型物体的碰撞的影响。 运动物体不受力或碰撞影响,但可以直接移动它们,从而可能会影响动态物体发生碰撞。
  • physicsShape 属性,它定义物体的三维形状以进行碰撞检测。 使用简单形状而不是节点可见几何的详细信息,物理模拟运行得更快。 通常,您将一个物体的物理形状设置为大致匹配节点可见内容的边界框、球体或基本形状。 有关创建物理形状的详细信息,请参阅SCNPhysicsShape
  • kinematic() 属性。 对动态物体施加力或扭矩会导致其加速度(或角加速度)与其质量成比例。

场景中的所有值在SceneKit的物理模拟中使用国际单位制(SI),mass (质量)单位为千克;force (力)、impulse (冲量)和torque (扭矩)的单位是牛顿、牛顿秒和牛顿米;节点位置和大小的距离单位是米。 请注意,您不必尝试为物理量提供逼真的值 - 使用产生您所需行为或游戏玩法的任何值即可。
对于动态物体,您可以控制物体受力或碰撞的影响方式。 请参阅定义力如何影响物理体。

定义物体的类别和碰撞

当您设计使用物理的游戏时,定义在场景中出现的不同物理对象的各种类别。 为您的应用程序的行为定义不同类别的物理体。 一个物体可以分配给您想要的这些类别中的任意数量。 除了声明自己的类别之外,物理体还声明了它与哪些物体类别交互。

使用categoryBitMaskcollisionBitMask属性定义对象的碰撞行为。 SCNPhysicsCollisionCategory 中列出的常量为这些属性提供了默认值。 此外,使用 contactTestBitMask 属性,您可以定义一对物体生成接触消息(请参见 SCNPhysicsContactDelegate 协议),而不受到碰撞影响的交互。

相关物理类

物理场创建影响区域内所有物体的力,例如涡旋和引力。 有关详细信息和可用字段类型的列表,请参阅 SCNPhysicsField

您可以添加更高级别的行为,以控制多个物体之间的交互,例如关节和车辆。 有关详细信息和可用行为的列表,请参阅 SCNPhysicsBehavior

场景的 physicsWorld 属性持有一个管理影响整个场景的物理特性的 SCNPhysicsWorld 对象。

物理和渲染循环

SceneKit 将其物理模拟作为 SCNSceneRendererDelegate 中描述的渲染循环的一部分进行评估。在通过此循环的每个步骤中,SceneKit 确定具有附加物理体的所有节点的状态,并为一个时间步长模拟物理对这些物体的影响。例如,通过根据其速度和角速度更新物体的位置或旋转来模拟物理。在模拟物理之后,SceneKit 将物理模拟的结果应用于场景以供显示。

因为您不仅可以通过物理,而且可以通过动作和隐式和显式定义的动画动画 SceneKit 内容,所以 SceneKit 将物理模拟的结果不应用于场景中 SCNNode 对象,而应用于每个节点的呈现对象,表示其当前显示状态。 因此,更改受物理影响的节点的属性需要特别考虑。

如果更改受物理影响的节点的变换值或变换的任何其他属性,如位置和旋转之一,SceneKit将重置该节点的物理模拟。如果您只想更改变换的一个组件,同时将其他组件保留为其物理模拟值,请在更改前复制呈现节点的变换,如下所示:

1
2
3
4
//将演示节点的变换复制到模型节点。
node.transform = node.presentationNode.transform
//更改新变换中的一个组件
node.eulerAngles.z = newRollValue

相关API

创建物理体

1
init(type: SCNPhysicsBodyType, shape: SCNPhysicsShape?)

使用指定的类型和形状创建物理体。

1
class func  `static` () -> Self

创建一个不受力或碰撞影响且不能移动的物理体。

1
class func dynamic() -> Self

创建一个可以受力和碰撞影响的物理体。

1
class func kinematic() -> Self

创建一个不受力或碰撞影响,但在移动时可以引起影响其他物体的碰撞的物理体。

定义力如何影响物理体

1
var physicsShape: SCNPhysicsShape?

定义用于碰撞检测的物理体的实体体积。

1
var type: SCNPhysicsBodyType

一个常量,用于确定物理体如何响应力和碰撞。

1
enum SCNPhysicsBodyType

常量,用于确定物理体如何与力和其他物体交互,用于类型属性和创建物理体时。

1
var velocityFactor: SCNVector3

一个乘数,影响SceneKit将物理模拟计算的平移应用于包含物理体的节点。

1
var angularVelocityFactor: SCNVector3

一个乘数,影响SceneKit将物理模拟计算的旋转应用于包含物理体的节点。

1
var isAffectedByGravity: Bool

一个布尔值,用于确定场景中的恒定重力是否加速物体。

定义物体的物理属性

1
var mass: CGFloat

物体的质量,以千克为单位。

1
var charge: CGFloat

物体的电荷,以库仑为单位。

1
var friction: CGFloat

物体的滑动运动阻力。

1
var rollingFriction: CGFloat

物体的滚动运动阻力。

1
var restitution: CGFloat

确定物体在碰撞中损失或获得多少动能的因子。

1
var damping: CGFloat

减少物体的线性速度的因子。

1
var angularDamping: CGFloat

减少物体的角速度的因子。

1
var momentOfInertia: SCNVector3

物体的惯性矩,以包含物体的节点的本地坐标系表示。

1
var usesDefaultMomentOfInertia: Bool

一个布尔值,用于确定SceneKit是否自动计算物体的惯性矩,或允许设置自定义值。

1
var centerOfMassOffset: SCNVector3

物体质心相对于其本地坐标原点的位置。

处理接触和碰撞

1
var categoryBitMask: Int

定义该物理体属于的哪些类别的掩码。

1
var contactTestBitMask: Int

定义哪些类别的物体与此物理体发生交集通知。

1
var collisionBitMask: Int

定义哪些类别的物理体可以与此物理体碰撞。

1
struct SCNPhysicsCollisionCategory

用于物理体的categoryBitMaskcollisionBitMask属性的默认值。

1
var continuousCollisionDetectionThreshold: CGFloat

物体必须移动的最小距离,这样SceneKit才能应用更精确(但更昂贵)的算法来检测与其他物体的接触。

应用力、冲量和扭矩

1
func applyForce(SCNVector3, asImpulse: Bool)

将力或冲量应用于物体的质心。

1
func applyForce(SCNVector3, at: SCNVector3, asImpulse: Bool)

将力或冲量应用于物体的特定点。

1
func applyTorque(SCNVector4, asImpulse: Bool)

将净扭矩或角动量变化应用于物体。

1
func clearAllForces()

取消当前模拟步骤中作用于物理体上的所有连续力和扭矩。

在运动的物体中交互

1
var velocity: SCNVector3

描述物理体当前速度(以米/秒为单位)和运动方向的向量。

1
var angularVelocity: SCNVector4

描述物理体当前旋转轴和旋转速度(以弧度/秒为单位)的向量。

定义何时可以移动物体

1
var isResting: Bool

一个布尔值,指示物理体是否静止。

1
var allowsResting: Bool

一个布尔值,指定是否可以自动将物理体标记为静止。

1
func setResting(Bool)

告诉SceneKit是否将物体视为当前正在运动。

将物理体与其节点同步

1
func resetTransform()

更新物体在物理模拟中的位置和方向,以匹配其附加的节点。

实例属性

1
2
var angularRestingThreshold: CGFloat
var linearRestingThreshold: CGFloat

扩展

详细内容请看:SCNPhysicsBody

此系列为官方文档翻译,用于学习SceneKit

向场景元素添加动态行为;检测接触和碰撞;模拟重力、弹簧和车辆等逼真的效果。

相关内容

物理体

1
class SCNPhysicsBody

附加到场景图节点的物理模拟属性。

1
class SCNPhysicsShape

物理体实体的抽象,用于调整碰撞检测。

碰撞和接触检测

1
protocol SCNPhysicsContactDelegate

在场景中两个物理体之间发生接触或碰撞时可以实现的方法。

1
class SCNPhysicsContact

有关场景物理模拟中两个物理体之间接触的详细信息。

场景中的物理

1
class SCNPhysicsWorld

场景中碰撞、重力、关节和其他物理效果的全局模拟。

1
class SCNPhysicsField

应用力(例如引力、电磁力和湍流)于某个作用范围内的物理体的对象。

1
class SCNPhysicsBehavior

用于关节、车辆模拟和其他包含多个物理体的高级行为的抽象超类。

关节

1
class SCNPhysicsHingeJoint

连接两个物体并允许它们在单个轴上绕彼此旋转的物理行为。

1
class SCNPhysicsSliderJoint

连接两个物体并允许它们相互滑动并绕它们的连接点旋转的物理行为。

1
class SCNPhysicsBallSocketJoint

连接两个物理体并允许它们在任何方向上绕彼此旋转的物理行为。

车辆模拟

1
class SCNPhysicsVehicle

修改物理体以表现为汽车、摩托车或其他有轮车辆的物理行为。

1
class SCNPhysicsVehicleWheel

与物理车辆行为相关联的单个轮的外观和物理特性。

扩展

详细内容请看:Physics Simulation

RoomPlanWWDC22上发布的一开非常酷的一个库,虽然,第一天的Keynote中都没有任何介绍和相关的技术展示。但是,从开发者网站中我们窥探到了这一新库出现。这个库主要是干嘛的呢?从目前来看,可以使用它扫描房间,它会帮我们创建好房间的3D模型。

来看下官方的这个演示视频,可以直观的感受下这个库的魅力。

嗯,看上去貌似对房地产行业和家装行业比较友好。

下面主要从技术角度介绍下RoomPlan

RoomPlan其实是ARKit的一个再封装,其主要利用的技术是借助LiDAR扫描周围的环境,捕获到场景的深度信息。然后,根据场景几何建立我们的Room网格。RoomPlan还利用影像数据结合AI的能力,对现实物体进行了更加细致的分类、检测和分割。主要可以识别这几大类:

1
2
3
4
5
6
7
8
9
10
// 墙体
public var walls: [CapturedRoom.Surface] { get }
// 门
public var doors: [CapturedRoom.Surface] { get }
// 窗户
public var windows: [CapturedRoom.Surface] { get }
// 开口
public var openings: [CapturedRoom.Surface] { get }
// 物体
public var objects: [CapturedRoom.Object] { get }

其中,Object 下面还有细分的类别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public enum Category : Codable, Sendable {
// 未知
case unknown
// 储物柜
case storage
// 冰箱
case refrigerator
// 厨灶
case stove
// 床
case bed
// 水槽
case sink
// 洗衣机
case washer
// 马桶
case toilet
// 浴缸
case bathtub
// 烤箱
case oven
// 洗碟机
case dishwasher
// 桌子
case table
// 沙发
case sofa
// 椅子
case chair
// 壁炉
case fireplace
// 屏幕
case screen
// 楼梯
case stairs
}

可以看到,识别的物品还是比较多的,未来苹果应该会增加更多的识别项。

如何使用?

主要有两种使用方式,一种是直接使用封装好的RoomCaptureView,另一种是通过API接口来自定义实现。下面主要介绍使用RoomCaptureView实现房间的模型创建。

RoomCaptureViewUIView的子类,我们可以轻松地在自己的应用程序中使用它。RoomCaptureView主要负责三件事情:

  • 根据扫描到的物理环境空间实时在屏幕上进行反馈;
  • 实时生成当前扫描得到的房间模型;
  • 在一些特殊情况时候显示对用户的引导;

使用RoomCaptureView只需要通过四个简单的步骤。

  • 首先,我们需要在对应的 ViewController 中创建一个 RoomCaptureView 引用。
  • 其次,我们需要创建对 RoomCaptureSession 配置对象的引用。
  • 第三,创建开始扫描的函数,在run中置传递相关参数。
  • 最后,创建停止扫描函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import UIKit
import RoomPlan

class RoomCaptureViewController: UIViewController {
private var roomCaptureView: RoomCaptureView!
private var roomCaptureSessionConfig: RoomCaptureSession.Configuration

override func viewDidLoad() {
super.viewDidLoad()

// Set up after loading the view.
setupRoomCaptureView()
}

private func setupRoomCaptureView() {
roomCaptureView = RoomCaptureView(frame: view.bounds)
roomCaptureView.captureSession.delegate = self
roomCaptureView.delegate = self

view.insertSubview(roomCaptureView, at: 0)
}

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startSession()
}

override func viewWillDisappear(_ flag: Bool) {
super.viewWillDisappear(flag)
stopSession()
}

private func startSession() {
roomCaptureView?.captureSession.run(configuration: roomCaptureSessionConfig)
}

private func stopSession() {
roomCaptureView?.captureSession.stop()
}
}

我们可以通过遵循RoomCaptureViewDelegate协议,选择扫描结束后是否要进行后期优化处理,以及存储和导出USDZ文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class RoomCaptureViewController: UIViewController, RoomCaptureViewDelegate{
// ...
private var finalResults: CapturedRoom?

// 选择扫描结束后是否要进行后期处理
func captureView(shouldPresent roomDataForProcessing: CapturedRoomData, error: Error?) -> Bool {
return true
}

// 选择最终模型如何处理
func captureView(didPresent processedResult: CapturedRoom, error: Error?) {
// 存储以便后期使用
finalResults = processdResult
// 或者导出
try processdResult.export(to: destinationURL)
}
}

以上就是RoomCaptureView的完整使用,通过简短的代码就能创建房间空间的模型。

不足

对我来说RoomPlan最大的不足在于纹理上,扫描出来的模型文件没有纹理数据。当然这应该也是苹果对这个产品的定位问题吧,也是出于性能和当前实现的效果考量吧。另外,RoomPlan当前还存在一些测量尺寸不准确的bug,以及一些识别不对的bug

前言

本文旨在解决,多模块、多 bundle 的图片资源的统一访问方法。

目前,我们开发的项目实现了模块化,不同模块使用的图片资源也都抽到了相应的模块内部,可以共用的图片资源和资源访问器,单独封装成一个基础模块。图片资源使用 xcassets 进行管理,模块通过 Cocoapods 进行管理。需要在每个模块中的 podspec 这样定义资源包:

1
2
3
s.resource_bundle = {
'ModuleName' => ['Resources/*']
}

这样定义的好处是,pod 会将资源打包成模块同名的 bundle 文件。不会将资源整合到 main bundle ,这样也可以避免命名冲突的问题。

当然,如果你喜欢的话,你也可以选择另一种方式,将所有的资源整合到 main bundle 。那么,你可能就没有从非 main bundle 取图片的烦恼,你也可以关闭这篇文章了。

在介绍这个访问器之前,有必要介绍下 Swift 的下标语法,我在 Swift 中实现的图片资源访问器是基于此语法进行设计。

Subscript-下标语法

下标语法可以定义在类、结构体和枚举中,是访问集合、列表或序列中元素的快捷方式。可以使用下标的索引,设置和获取值,而不需要再调用对应的存取方法。

在使用 Swift 进行开发时,下标语法几乎每天都会用到,比如我们从数组中取一个元素:

1
let item = array[i]

从字典中通过 key 来取值:

1
let value = dict["key"]

下标允许你通过在实例名称后面的方括号中传入一个或者多个索引值来对实例进行查询。它的语法类似于实例方法语法和计算型属性语法。定义下标使用 subscript 关键字,与定义实例方法类似,都是指定一个或多个输入参数和一个返回类型。与实例方法不同的是,下标可以设定为读写或只读。这种行为由 getter 和 setter 实现,类似计算型属性:

1
2
3
4
5
6
7
8
subscript(index: Int) -> Int {
get {
// 返回一个适当的 Int 类型的值
}
set(newValue) {
// 执行适当的赋值操作
}
}

newValue 的类型和下标操作的返回类型相同。如同计算型属性,可以不指定 setter 的参数(newValue)。如果不指定参数,setter 会提供一个名为 newValue 的默认参数。

如同只读计算型属性,对于只读下标的声明,你可以通过省略 get 关键字和对应的大括号组来进行简写:

1
2
3
subscript(index: Int) -> Int {
// 返回一个适当的 Int 类型的值
}

更多关于下标语法的信息,你可以通过这个链接了解更全面的信息。

使用下标语法实现通用的图片访问器

这里,其实是利用了下标的多维特性来实现。一个类型可以定义多个下标,通过不同索引类型进行对应的重载。

多维特性示例:

1
2
3
4
5
6
7
8
9
10
subscript(row: Int, column: Int) -> Double {
get {
assert(indexIsValid(row: row, column: column), "Index out of range")
return grid[(row * columns) + column]
}
set {
assert(indexIsValid(row: row, column: column), "Index out of range")
grid[(row * columns) + column] = newValue
}
}

最终实现大概如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public struct MyImage {
/// 通过图片名,获取一个 UIImage
public static subscript(named: String) -> UIImage? {
UIImage(named: named)
}

/// 通过 module 和图片名,获取一个 UIImage
public static subscript(module: Any.Type?, named: String) -> UIImage? {
var buldel: Bundle?
if let module = module {
buldel = Bundle.named(String(describing: module.self))
}
return UIImage(named: named, in: buldel, compatibleWith: nil)
}
}

用法:

1
2
3
4
5
// 从 ModuleName.bundle 中获取图片
MyImage[ModuleName.self, "image name"]

// 或者,从 main bundle 中获取图片
MyImage["image name"]

结语

以上就是一个通用的图片访问器的简单实现,这只是一个基础版本,可以支持 pngjpg 格式的图片访问了,基本满足我们的需求。

由于项目需要,我的项目中,对其进行了扩展,使其可以访问诸如 webpgif 等其他格式文件。

对于共用的图片资源,我们可以封装在 MyImage 模块下,通过脚本生成类似于下面这样的文件:

1
2
3
4
5
6
extension MyImage {

public static iconX = MyImage[MyImage.self, "icon name"]

...
}

访问这些共用的资源,只需要这样使用即可:

1
MyImage.iconX

另外,想要提的一点是,并不是所有的多个模块使用的图片,都适合放到这个基础组件中,这个需要各位自己权衡。比如导航上面的按钮,一些返回按钮或者箭头之类的就很适合放在这里面。


原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

前言

通常我们在适配异形屏的时候,我们可能会使用 safeAreaInsets。使用时机不对的话,safeAreaInsets 的值还会存在问题。或许你可以使用 key windowsafeAreaInsets ,亦或者你可以通过重写 func safeAreaInsetsDidChange() 方法,在合适的时候来修改布局,但这些操作总是比较麻烦,用起来并不舒服。

有没有更好的方式呢🤔?我们先来介绍两个属性。

layoutMargins

The default spacing to use when laying out content in the view.

iOS 8 新增,通过属性名,我们就了解他是什么了,简单来说就是布局中的边距。

A view's margins

layoutMarginsGuide

A layout guide representing the view’s margins.

iOS 9 新增,你可以通过链接查看更多相关信息。

如何使用

下面将用过三个用例来总结用法。

示例一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
let pinkView = UIView()
pinkView.backgroundColor = .systemPink
pinkView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pinkView)

view.addConstraints([
NSLayoutConstraint(
item: pinkView,
attribute: .leftMargin,
relatedBy: .equal,
toItem: view,
attribute: .leftMargin,
multiplier: 1,
constant: 0
),
NSLayoutConstraint(
item: pinkView,
attribute: .rightMargin,
relatedBy: .equal,
toItem: view,
attribute: .rightMargin,
multiplier: 1,
constant: 0
),
NSLayoutConstraint(
item: pinkView,
attribute: .topMargin,
relatedBy: .equal,
toItem: view,
attribute: .topMargin,
multiplier: 1,
constant: 0
),
NSLayoutConstraint(
item: pinkView,
attribute: .bottomMargin,
relatedBy: .equal,
toItem: view,
attribute: .bottomMargin,
multiplier: 1,
constant: 0
)
])

view.layoutMargins = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)

可以使用 SnapKit 来简化下代码:

1
2
3
4
5
6
7
8
9
10
let pinkView = UIView()
pinkView.backgroundColor = .systemBlue
pinkView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pinkView)

pinkView.snp.makeConstraints {
$0.edges.equalTo(self.view.layoutMarginsGuide)
}

layoutMargins = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)

self.view.layoutMarginsGuide 还可以替换成 self.view.snp.margins ,两种方式等价。

同时,SnapKit 也可以单独控制四个边距,使用 leftMarginrightMargintopMarginbottomMargin 单独控制。

用例一-竖屏
用例一-横屏

可以从上面的图片中看到,虽然我们设置四个边距都是20pt。但是,实际在不同的机型上面的显示,我们肉眼可见的边距是不一样的,横竖屏也是不一样的。

这里就有必要提一下安全区域了,我们可以看到pinkView的视图完全显示在安全区域内。事实上我们在设置布局的代码时,并没有考虑各种情况的安全区域,但是系统就是为我们加上了。我想,到这里,这种布局的好用之处就不言而喻了。

用例二

我们经常会遇到在页面底部添加一个工具条的需求,这个工具条需要做异形屏的适配。也就是在异形屏上,将其底部增加留白,使操作相关元素处在安全区域内。

我们可以这样来布局,达到适配的目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class BottomBar: UIView {

override init(frame: CGRect) {
super.init(frame: frame)

backgroundColor = .white

layoutMargins = UIEdgeInsets(top: 15, left: 15, bottom: 15, right: 15)

addSubview(button)

button.snp.makeConstraints {
$0.width.equalTo(90)
$0.height.equalTo(36)
$0.right.equalTo(self.snp.rightMargin)
$0.top.equalTo(self.snp.topMargin)
$0.bottom.equalTo(self.snp.bottomMargin)
}
}
...
}

class ViewController: UIViewController {

let bottomView = BottomBar()

override func viewDidLoad() {
super.viewDidLoad()

view.addSubview(bottomView)
bottomView.snp.makeConstraints {
$0.left.bottom.right.equalTo(0)
}
}
}

用例二-竖屏

用例二-横屏

可以看到底部工具条已经适配好了,不需要我们做其他的操作👏👏👏。

上面的代码,是通过一个尺寸固定的 button 将底部工具条撑满,我们将 button 的底部约束设置成 $0.bottom.equalTo(self.snp.bottomMargin) ,设置容器视图的 layoutMargins.bottom = 15 ,实际效果图上面,系统已经为我们自动加上了safeAreaInsets.bottom 。同时,横屏状态下,底部和右边都加上了安全距离🥳🥳🥳。

用例三

在用例二的基础上,我们再加上一个工具条。

1
2
3
4
5
6
7
8
9
10
11
view.addSubview(bottomView)
bottomView.snp.makeConstraints {
$0.left.bottom.right.equalTo(0)
}

let bottomView = BottomBar()
view.addSubview(bottomView)
bottomView.snp.makeConstraints {
$0.left.right.equalTo(0)
$0.bottom.equalTo(self.bottomView.snp.top).offset(-1)
}

用例三

明显,我们看到上面那个工具条的底部没有加上 safeAreaInsets.bottom ,但是右边加上了 safeAreaInsets.right

到这里,我们可以得出总结:

当视图的任意边跟屏幕的边缘相交时,使用 layoutMarginsGuide 布局,系统会给相应的边的边距加上安全区域的边距

另外,我们可以在后续的使用中来动态调整 layoutMargins 的值,调整后,视图会实时刷新相应边距,甚至你可以给这个变化加上动画。

是不是很Nice?😎

总结

这种布局方式,还是非常推荐使用的,通过上面的例子,我们就可以体会到它的妙处。在这个过程中,我们不需要考虑 safeAreaInsets ,仅仅只需要理解 layoutMarginslayoutMarginsGuide ,并正确的使用即可。

本文只是简单介绍了 layoutMarginslayoutMarginsGuide 的一部分使用,算是抛砖引玉。关于它的使用,我想只有你真正使用起来,你才会觉得这样的设计的好处。

值得注意的是,在 iOS 11 推出了 directionalLayoutMargins ,也就是 layoutMargins 的替代物,使用起来并没有大的差别,仅仅是换了个枚举而已,感兴趣的可以自己去试下。关于布局还有很多内容值得研究,正确的使用系统提供的方法,可以使我们写出更健壮的代码,同时可以让我们很好的适配不同的屏幕,和不同的设备。


原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

文章已发布至公账号:Sign in with Apple

通过本文,你将了解到是否需要集成Sign in with Apple功能,以及如何集成Sign in with Apple功能。

本文主要讲解以下内容

  • 概览
  • 集成

概览

在 WWDC 2019 上,苹果推出了自家的Sign in with Apple功能,这很Apple。可能苹果看到第三方登录百家争鸣,琢磨着自己也搞了个,这对很多第三方登录来说可能是个威胁。

苹果对Sign in with Apple的介绍:

Sign In with Apple makes it easy for users to sign in to your apps and websites using their Apple ID. Instead of filling out forms, verifying email addresses, and choosing new passwords, they can use Sign In with Apple to set up an account and start using your app right away. All accounts are protected with two-factor authentication for superior security, and Apple will not track users’ activity in your app or website.

使用Sign in with Apple会更加方便、快捷、安全,苹果不会追踪用户在应用中的行为。所以,对于用户来说使用Sign in with Apple会更加安全。

另外,Sign in with Apple支持跨平台

  • Native SDK 支持 iOS/MacOS/watchOS/tvOS
  • Javascript SDK 支持 Android, Windows, Web

话说这个 iOS 13 才支持的功能,我们有必要集成吗?🤔

看了下面这句话,你或许就有答案了。

Sign In with Apple will be available for beta testing this summer. It will be required as an option for users in apps that support third-party sign-in when it is commercially available later this year.

简单来说,如果你的App没有提供第三方登录,那就不用集成。如果用到了第三方登录,那么需要提供Sign in with Apple

集成

集成 需要以下几个步骤:

一、准备工作

开启Sign in with Apple功能

1.登录开发者网站,在需要添加Sign in with Apple功能的Identifier开启功能。

2.Xcode里面Signing & Capabilities开启Sign in with Apple功能。

二、代码集成

Talk is cheap. Show me the code!😌

1.创建登录按钮

官方提供了一个ASAuthorizationAppleIDButton(继承自UIControl),使用这个来创建一个登录按钮。

1
2
3
4
5
6
ASAuthorizationAppleIDButton *button = [ASAuthorizationAppleIDButton buttonWithType:ASAuthorizationAppleIDButtonTypeSignIn style:ASAuthorizationAppleIDButtonStyleWhite];
[button addTarget:self
action:@selector(signInWithApple)
forControlEvents:UIControlEventTouchUpInside];
button.center = self.view.center;
[self.view addSubview:button];

这个按钮具有两种文案类型和三个样式,分别是:

1
2
3
4
5
6
7
8
9
10
11
12
typedef NS_ENUM(NSInteger, ASAuthorizationAppleIDButtonType) {
ASAuthorizationAppleIDButtonTypeSignIn,
ASAuthorizationAppleIDButtonTypeContinue,

ASAuthorizationAppleIDButtonTypeDefault = ASAuthorizationAppleIDButtonTypeSignIn,
}

typedef NS_ENUM(NSInteger, ASAuthorizationAppleIDButtonStyle) {
ASAuthorizationAppleIDButtonStyleWhite,
ASAuthorizationAppleIDButtonStyleWhiteOutline,
ASAuthorizationAppleIDButtonStyleBlack,
}

通过图片可能更直观

从图上可以看出:

  • Apple 提供的登录按钮有三种外观:白色,带有黑色轮廓线的白色和黑色。
  • 文案有两种:Sign In with AppleContinue with Apple。(具体使用哪个文案,根据自身业务需求来定)
    另外,按钮宽高默认值为 {width:130, height:30}

对于ASAuthorizationAppleIDButton我们能够自定义的东西比较少,比如背景色不能更改,文案只有两种可选,并且值不能修改,可以调整的只有圆角cornerRadiussize

宽高也有一定限制:

Minimum width Minimum height Minimum margin
140pt (140px @1x, 280px @2x) 30pt (30px @1x, 60px @2x) 1/10 of the button’s height

具体的设计规范,请参考:Human Interface Guidelines

通过调整size发现还有个新花样。比如这样:

是不是发现文字没有了,当然有些童鞋可能会说,变成圆形的了。嗯,圆形还是比较简单设置的,主要的变化还是文字。同时,我们还可以发现,在这种情况下,调大尺寸,里面的苹果 logo 也会跟着变大,适应展示。具体怎么操作,看下面:

1
2
3
ASAuthorizationAppleIDButton *button = [ASAuthorizationAppleIDButton buttonWithType:ASAuthorizationAppleIDButtonTypeContinue style:ASAuthorizationAppleIDButtonStyleBlack];
button.bounds = CGRectMake(0, 0, 40, 40);
button.cornerRadius = 20.0f;

不过上面这种方式,不知道审核是否可以通过哈,大家还是慎用,仅作为一种参考。不过,苹果貌似没有强制使用默认的登录按钮。

To help people set up an account and sign in, it’s best to use the familiar buttons that Apple provides for Sign In with Apple.

再补充一点,通过上面这种修改size后,会收到布局警告⚠️,通过警告信息,大致可以看出来,按钮宽度的范围是:>= 130px,高度范围是:>=30px && <=64px

本地化:必要且重要的一点

不知道大家有没有发现,按钮显示的文字都是英文的,而且我们不可以修改文字,那我总不能在国内也展示个英文吧🙄。其实,这是个基础性的问题,知道的童鞋可以跳过了,不清楚的童鞋继续看。解决这个问题,很简单,要知道Apple已经为我们做了很多的工作,简单来说,我们只需要添加本地化支持就可以了,此方法适用于一切系统文案,比如我们创建的UIBarButtonSystemItemSave,添加简体中文支持后,他显示的文案就变成 保存 啦。

上图就是添加本地化语言,还不会的童鞋,自己网上查查吧,这里不在展开了。

下面是添加了本地语言之后的效果:

2.Authorization 发起授权登录请求

1
2
3
4
5
6
7
8
9
10
11
- (void)signInWithApple API_AVAILABLE(ios(13.0))
{
ASAuthorizationAppleIDProvider *provider = [[ASAuthorizationAppleIDProvider alloc] init];
ASAuthorizationAppleIDRequest *request = [provider createRequest];
request.requestedScopes = @[ASAuthorizationScopeFullName, ASAuthorizationScopeEmail];

ASAuthorizationController *vc = [[ASAuthorizationController alloc] initWithAuthorizationRequests:@[request]];
vc.delegate = self;
vc.presentationContextProvider = self;
[vc performRequests];
}

解析:

  • ASAuthorizationAppleIDProvider这个类比较简单,头文件中可以看出,主要用于创建一个ASAuthorizationAppleIDRequest以及获取对应userID的用户授权状态。在上面的方法中我们主要是用于创建一个ASAuthorizationAppleIDRequest,用户授权状态的获取后面会提到。

  • 给创建的request设置requestedScopes,这是个ASAuthorizationScope数组,目前只有两个值,ASAuthorizationScopeFullNameASAuthorizationScopeEmail,根据需求去设置即可。

  • 然后,创建ASAuthorizationController,它是管理授权请求的控制器,给其设置 delegatepresentationContextProvider,最后启动授权performRequests

设置上下文

ASAuthorizationControllerPresentationContextProviding就一个方法,主要是告诉ASAuthorizationController展示在哪个window上。

1
2
3
4
5
6
#pragma mark - ASAuthorizationControllerPresentationContextProviding

- (ASPresentationAnchor)presentationAnchorForAuthorizationController:(ASAuthorizationController *)controller API_AVAILABLE(ios(13.0))
{
return self.view.window;
}

3.Verification 授权

用户发起授权请求后,系统就会弹出用户登录验证的页面。

在用户没有同意授权之前或者取消授权之后,点击登录的时候,都会弹出上面这个界面,在这个授权页面,我们可以修改自己的用户名,以及可以选择共享我的电子邮箱或者隐藏邮件地址。这样一来,就可以达到隐藏自己真实信息的目的。

授权一次后,再次点击登录按钮,则会直接弹出下面这个窗口:

授权回调处理

下面是ASAuthorizationControllerDelegate方法,一个是授权成功的回调,一个是失败的回调。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#pragma mark - ASAuthorizationControllerDelegate

- (void)authorizationController:(ASAuthorizationController *)controller didCompleteWithAuthorization:(ASAuthorization *)authorization API_AVAILABLE(ios(13.0))
{
if ([authorization.credential isKindOfClass:[ASAuthorizationAppleIDCredential class]]) {
ASAuthorizationAppleIDCredential *credential = authorization.credential;

NSString *state = credential.state;
NSString *userID = credential.user;
NSPersonNameComponents *fullName = credential.fullName;
NSString *email = credential.email;
NSString *authorizationCode = [[NSString alloc] initWithData:credential.authorizationCode encoding:NSUTF8StringEncoding]; // refresh token
NSString *identityToken = [[NSString alloc] initWithData:credential.identityToken encoding:NSUTF8StringEncoding]; // access token
ASUserDetectionStatus realUserStatus = credential.realUserStatus;

NSLog(@"state: %@", state);
NSLog(@"userID: %@", userID);
NSLog(@"fullName: %@", fullName);
NSLog(@"email: %@", email);
NSLog(@"authorizationCode: %@", authorizationCode);
NSLog(@"identityToken: %@", identityToken);
NSLog(@"realUserStatus: %@", @(realUserStatus));
}
}

- (void)authorizationController:(ASAuthorizationController *)controller didCompleteWithError:(NSError *)error API_AVAILABLE(ios(13.0))
{
NSString *errorMsg = nil;
switch (error.code) {
case ASAuthorizationErrorCanceled:
errorMsg = @"用户取消了授权请求";
break;
case ASAuthorizationErrorFailed:
errorMsg = @"授权请求失败";
break;
case ASAuthorizationErrorInvalidResponse:
errorMsg = @"授权请求响应无效";
break;
case ASAuthorizationErrorNotHandled:
errorMsg = @"未能处理授权请求";
break;
case ASAuthorizationErrorUnknown:
errorMsg = @"授权请求失败未知原因";
break;
}
NSLog(@"%@", errorMsg);
}

当我们授权成功后,我们可以在 authorizationController:didCompleteWithAuthorization:这个代理方法中获取到ASAuthorizationAppleIDCredential,通过这个可以拿到用户的userIDemailfullNameauthorizationCodeidentityToken以及 realUserStatus等信息。

这些信息具体含义和用途:

  • User ID: Unique, stable, team-scoped user ID,苹果用户唯一标识符,该值在同一个开发者账号下的所有 App 下是一样的,开发者可以用该唯一标识符与自己后台系统的账号体系绑定起来。

  • Verification data: Identity token, code,验证数据,用于传给开发者后台服务器,然后开发者服务器再向苹果的身份验证服务端验证本次授权登录请求数据的有效性和真实性,详见 Sign In with Apple REST API。如果验证成功,可以根据 userIdentifier 判断账号是否已存在,若存在,则返回自己账号系统的登录态,若不存在,则创建一个新的账号,并返回对应的登录态给 App。

  • Account information: Name, verified email,苹果用户信息,包括全名、邮箱等。

  • Real user indicator: High confidence indicator that likely real user,用于判断当前登录的苹果账号是否是一个真实用户,取值有:unsupported、unknown、likelyReal。

失败情况会走authorizationController:didCompleteWithError:这个方法,具体看代码吧。

5. Handling Changes

通过上面的步骤一个完整的授权,已经完成。BUT,我们还需要处理一些 Case。

  • 用户终止App中使用Sign in with Apple功能
  • 用户在设置里注销了AppleId

这些情况下,App需要获取到这些状态,然后做退出登录操作,或者重新登录。
我们需要在App启动的时候,通过getCredentialState:completion:来获取当前用户的授权状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

if (@available(iOS 13.0, *)) {
NSString *userIdentifier = 钥匙串中取出的 userIdentifier;
if (userIdentifier) {
ASAuthorizationAppleIDProvider *appleIDProvider = [ASAuthorizationAppleIDProvider new];
[appleIDProvider getCredentialStateForUserID:userIdentifier
completion:^(ASAuthorizationAppleIDProviderCredentialState credentialState,
NSError * _Nullable error)
{
switch (credentialState) {
case ASAuthorizationAppleIDProviderCredentialAuthorized:
// The Apple ID credential is valid
break;
case ASAuthorizationAppleIDProviderCredentialRevoked:
// Apple ID Credential revoked, handle unlink
break;
case ASAuthorizationAppleIDProviderCredentialNotFound:
// Credential not found, show login UI
break;
}
}];
}
}

return YES;
}

ASAuthorizationAppleIDProviderCredentialState解析如下:

  • ASAuthorizationAppleIDProviderCredentialAuthorized授权状态有效;

  • ASAuthorizationAppleIDProviderCredentialRevoked上次使用苹果账号登录的凭据已被移除,需解除绑定并重新引导用户使用苹果登录;

  • ASAuthorizationAppleIDProviderCredentialNotFound未登录授权,直接弹出登录页面,引导用户登录。

另外,在App使用过程中,你还可以通过通知方法来监听revoked状态,可以添加 ASAuthorizationAppleIDProviderCredentialRevokedNotification这个通知,收到这个通知的时候,我们可以:

  • Sign user out on this device
  • Guide to sign in again

具体怎么添加和处理,可以根据业务需求来决定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (void)observeAppleSignInState
{
if (@available(iOS 13.0, *)) {
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleSignInWithAppleStateChanged:)
name:ASAuthorizationAppleIDProviderCredentialRevokedNotification
object:nil];
}
}

- (void)handleSignInWithAppleStateChanged:(NSNotification *)notification
{
// Sign the user out, optionally guide them to sign in again
NSLog(@"%@", notification.userInfo);
}

One more thing

除此之外,苹果还把iCloud KeyChain password集成到了这套API里,我们在使用的时候,只需要在创建request的时候,多创建一个ASAuthorizationPasswordRequest,这样如果KeyChain里面也有登录信息的话,可以直接使用里面保存的用户名和密码进行登录。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
- (void)perfomExistingAccountSetupFlows API_AVAILABLE(ios(13.0))
{
ASAuthorizationAppleIDProvider *appleIDProvider = [ASAuthorizationAppleIDProvider new];
ASAuthorizationAppleIDRequest *authAppleIDRequest = [appleIDProvider createRequest];
ASAuthorizationPasswordRequest *passwordRequest = [[ASAuthorizationPasswordProvider new] createRequest];

NSMutableArray <ASAuthorizationRequest *>* array = [NSMutableArray arrayWithCapacity:2];
if (authAppleIDRequest) {
[array addObject:authAppleIDRequest];
}
if (passwordRequest) {
[array addObject:passwordRequest];
}
NSArray <ASAuthorizationRequest *>* requests = [array copy];

ASAuthorizationController *authorizationController = [[ASAuthorizationController alloc] initWithAuthorizationRequests:requests];
authorizationController.delegate = self;
authorizationController.presentationContextProvider = self;
[authorizationController performRequests];
}

#pragma mark - ASAuthorizationControllerDelegate

- (void)authorizationController:(ASAuthorizationController *)controller didCompleteWithAuthorization:(ASAuthorization *)authorization API_AVAILABLE(ios(13.0))
{
if ([authorization.credential isKindOfClass:[ASPasswordCredential class]]) {
ASPasswordCredential *passwordCredential = authorization.credential;
NSString *userIdentifier = passwordCredential.user;
NSString *password = passwordCredential.password;

NSLog(@"userIdentifier: %@", userIdentifier);
NSLog(@"password: %@", password);
}
}

以上就是关于Sign in with Apple的相关内容和集成方法。

pp8Ydvn.png

iOS 13 如期而至,适配工作可以开展起来啦。在适配 iOS 13 过程中,遇到了如下一些问题。


1. UITextField 的私有属性 _placeholderLabel 被禁止访问了

遇到的第一个崩溃是修改UITextFieldplaceholder的颜色,历史遗留代码如下:

1
[_textField setValue:self.placeholderColor forKeyPath:@"_placeholderLabel.textColor"];

收到的错误信息⚠️

1
'Access to UITextField's _placeholderLabel ivar is prohibited. This is an application bug' 

那么这个问题如何处理呢?

其实,UITextField 有个attributedPlaceholder 的属性,我们可以自定义这个富文本来达到我们需要的结果。

修改如下:

1
2
NSMutableAttributedString *placeholderString = [[NSMutableAttributedString alloc] initWithString:placeholder attributes:@{NSForegroundColorAttributeName : self.placeholderColor}];
_textField.attributedPlaceholder = placeholderString;

注意⚠️,iOS 13 通过 KVC 方式修改私有属性,有 Crush 风险,谨慎使用!


2. 控制器的 modalPresentationStyle 默认值变了

对于这个变化,有点措手不及,直接修改了模态窗口的交互。
查阅了下 UIModalPresentationStyle枚举定义,赫然发现iOS 13新加了一个枚举值:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef NS_ENUM(NSInteger, UIModalPresentationStyle) {
UIModalPresentationFullScreen = 0,
UIModalPresentationPageSheet API_AVAILABLE(ios(3.2)) API_UNAVAILABLE(tvos),
UIModalPresentationFormSheet API_AVAILABLE(ios(3.2)) API_UNAVAILABLE(tvos),
UIModalPresentationCurrentContext API_AVAILABLE(ios(3.2)),
UIModalPresentationCustom API_AVAILABLE(ios(7.0)),
UIModalPresentationOverFullScreen API_AVAILABLE(ios(8.0)),
UIModalPresentationOverCurrentContext API_AVAILABLE(ios(8.0)),
UIModalPresentationPopover API_AVAILABLE(ios(8.0)) API_UNAVAILABLE(tvos),
UIModalPresentationBlurOverFullScreen API_AVAILABLE(tvos(11.0)) API_UNAVAILABLE(ios) API_UNAVAILABLE(watchos),
UIModalPresentationNone API_AVAILABLE(ios(7.0)) = -1,
UIModalPresentationAutomatic API_AVAILABLE(ios(13.0)) = -2,
};

是的,就是UIModalPresentationAutomatic ,苹果居然直接将modalPresentationStyle 默认值改成这个,有点不解,难道是怕我们不知道新加了这个交互?这个也完全违反了开闭原则吧😒。

如何修改:
如果你完全接受苹果的这个默认效果,那就不需要去修改任何代码。
如果,你原来就比较细心,已经设置了modalPresentationStyle的值,那你也不会有这个影响。
对于想要找回原来默认交互的同学,直接设置如下即可:

1
self.modalPresentationStyle = UIModalPresentationFullScreen;

值得注意的是,当 modalPresentationStyleUIModalPresentationAutomatic时,presentationController 是不会消失的。所以,关闭模态窗口的时候,presentationController 的生命周期方法 viewWillAppear:viewDidAppear: 都不会触发。


3. MPMoviePlayerController 在iOS 13已经不能用了

在使用到MPMoviePlayerController的地方,直接抛了异常:

1
'MPMoviePlayerController is no longer available. Use AVPlayerViewController in AVKit.' 

如何修改:
这个没啥好说的,既然不能再用了,那只能换掉了。替代方案就是AVKit里面的那套播放器。


4. iOS 13 DeviceToken有变化‼️

这个很重要⚠️
可能大多数使用第三方推送的童鞋都不会注意到这个问题,一般现在的第三方推送都是将DeviceToken原始数据丢进去,具体的解析都是第三方内部处理,所以,这些第三方解析DeviceToken的方式正确的话,那就毫无问题。如果你们是通过这种方式来获取DeviceToken,那你需要注意了。(这个坑也是多年前埋下的,很多文章介绍的也是下面这个方法,不规范的做法迟早要还的🤣),如下:

1
2
3
4
NSString *dt = [deviceToken description];
dt = [dt stringByReplacingOccurrencesOfString: @"<" withString: @""];
dt = [dt stringByReplacingOccurrencesOfString: @">" withString: @""];
dt = [dt stringByReplacingOccurrencesOfString: @" " withString: @""];

这段代码运行在 iOS 13 上已经无法获取到准确的DeviceToken字符串了,iOS 13 通过[deviceToken description]获取到的内容已经变了。

{length = 32, bytes = 0x778a7995 29f32fb6 74ba8167 b6bddb4e … b4d6b95f 65ac4587 }

可以看到,跟原来我们认识的那个已经完全不一样了。其实,造成这样的问题,主要还是没有使用正确的方式来操作,下面是解决办法:

1
2
3
4
5
6
NSMutableString *deviceTokenString = [NSMutableString string];
const char *bytes = deviceToken.bytes;
NSInteger count = deviceToken.length;
for (int i = 0; i < count; i++) {
[deviceTokenString appendFormat:@"%02x", bytes[i]&0x000000FF];
}

或者你也可以使用友盟提供的方法(2019年7月24日更新)

1
2
3
4
5
6
7
8
9
10
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
if (![deviceToken isKindOfClass:[NSData class]]) return;
const unsigned *tokenBytes = [deviceToken bytes];
NSString *hexToken = [NSString stringWithFormat:@"%08x%08x%08x%08x%08x%08x%08x%08x",
ntohl(tokenBytes[0]), ntohl(tokenBytes[1]), ntohl(tokenBytes[2]),
ntohl(tokenBytes[3]), ntohl(tokenBytes[4]), ntohl(tokenBytes[5]),
ntohl(tokenBytes[6]), ntohl(tokenBytes[7])];
NSLog(@"deviceToken:%@",hexToken);
}

5.Sign in with Apple (提供第三方登录的注意啦⚠️)

如果你的应用使用了第三方登录,那么你可能也需要加下 「Sign in with Apple」🤪

Sign In with Apple will be available for beta testing this summer. It will be required as an option for users in apps that support third-party sign-in when it is commercially available later this year.

关于如何集成,可以参考这篇文章:《Sign in with Apple》
附上官方Demo:点我下载

Sign in with Apple 适配时间苹果已经确认,具体更新到了公众号同名文章,可以在文末扫码关注公众号进行查看。


6.即将废弃的 LaunchImage

从 iOS 8 的时候,苹果就引入了 LaunchScreen,我们可以设置 LaunchScreen来作为启动页。当然,现在你还可以使用LaunchImage来设置启动图。不过使用LaunchImage的话,要求我们必须提供各种屏幕尺寸的启动图,来适配各种设备,随着苹果设备尺寸越来越多,这种方式显然不够 Flexible。而使用 LaunchScreen的话,情况会变的很简单, LaunchScreen是支持AutoLayout+SizeClass的,所以适配各种屏幕都不在话下。

注意⚠️: 从2020年4月开始,所有使⽤ iOS13 SDKApp 将必须提供 LaunchScreenLaunchImage即将退出历史舞台。

再补充一点,在使用 LaunchScreen的时候,里面用到的图片资源,最好别放在 xcassets 里面,不然在你修改图片后,你会发现真机上并不会生效。


7. Dark Mode

Apps on iOS 13 are expected to support dark mode
Use system colors and materials
Create your own dynamic colors and images Leverage flexible infrastructure


9月24更新:

关于Dark Mode,这里补充几点。

因为苹果目前还没有强制必须适配这个,相信大家的很多项目也没有开始是配这个模式。所以,前期可以强制Light模式。不然,你可能会遇到一些问题,比如UITableViewCell的背景色,如果你没有设置过背景色的话,它在Dark模式下就是黑色的,再比如UIDatePicker文字颜色等等。

那么怎么强制模式呢?

iOS 13UIViewUIViewController都添加了一个属性:

1
@property (nonatomic) UIUserInterfaceStyle overrideUserInterfaceStyle API_AVAILABLE(ios(13.0), tvos(13.0)) API_UNAVAILABLE(watchos);

给这个属性设置成某一种模式,即可强制显示模式。
如果你想修改一处,应用所有地方,那么你只需要设置widow的显示模式即可,这会影响widow下面的所有视图显示模式,这也可以看出显示模式是向下传递的。

1
2
3
if (@available(iOS 13, *)) {
[self.window setOverrideUserInterfaceStyle:UIUserInterfaceStyleLight];
}

8.iOS 13 UITabBar顶部分割线隐藏

这个问题源自网友的提问,我的项目中并没有这样的需求,所以之前没有处理。
看到网上的一些解决办法,如下:

1
2
[UITabBar appearance].layer.borderWidth = 0.0f;
[UITabBar appearance].clipsToBounds = YES;

看了下,实际起作用的代码是这条代码:

1
[UITabBar appearance].clipsToBounds = YES;

也就是裁剪掉了多余的部分(多余的部分正好就是分割线,后面会提到为什么是分割线),达到隐藏分割线的目的。但是这样设置,TabBar会裁剪子视图,这样我们有大按钮的TabBar,按钮就会被裁剪,造成显示不完整。

那么在iOS 13上有没有别的办法来隐藏分割线呢?

肯定是有的,其实解决这样的问题,只要我们能找到这个视图,就可以解决问题。从这个角度出发,我们来看下iOS 13上面TabBar子视图都有哪些。直接打印下subviews即可。

1
2
3
4
(
"<_UIBarBackground: 0x7fbb34007920; frame = (0 0; 414 83);",
"<UITabBarButton: 0x7fbb2ed032d0; frame = (2 1; 410 48);"
)

我们可以看到内部有个_UIBarBackground 私有的东西,分割线肯定跟他有关(总不可能跟UITabBarButton有关吧😝)。然后,我们在看下这个控件的子视图。

1
2
3
4
(
"<UIImageView: 0x7fbb2ec09b90; frame = (0 -0.333333; 414 0.333333);",
"<UIVisualEffectView: 0x7fbb2ec05a20; frame = (0 0; 414 83); "
)

emmmm🤔,我们看到了一个越界的UIImageView ,这个UIImageView 就是分割线视图。怎么证明呢?我们加下这个代码:

1
2
3
4
5
6
7
[self.tabBar.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
[obj.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull obj1, NSUInteger idx, BOOL * _Nonnull stop) {
if ([obj1 isKindOfClass:[UIImageView class]]) {
obj1.hidden = YES;
}
}];
}];

运行后,发现分割线隐藏了,也就证明了我们的猜想。当然,上面这个代码不严谨哈,这里主要是提供一种思路,仅作为演示,具体怎么做相信大家都可以的。

总结下怎么解决:找到它》隐藏它。千万别通过KVC的方式去处理,理由看第一点。

9.UIWebView被废弃

“No longer supported; please adopt WKWebView.”, ios(2.0, 12.0)

这个大家也尽快适配吧,看到有提到审核被拒的问题。

10.iOS 13 需增加蓝牙权限描述

如果你的应用需要使用蓝牙权限,需要在Info.plist里面加上NSBluetoothAlwaysUsageDescription这个key,对应的描述value根据权限的用途来描述即可。


原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

Run Loop在每个事件循环结束后会去自动释放池将所有自动释放对象的引用计数减一,若引用计数变成了0,则会将对象真正销毁掉,回收内存。

所以,Autorelease对象是在每个事件循环结束后,自动释放池才会对所有自动释放的对象的引用计数减一,若引用计数变成了0,则释放对象,回收内存。因此,若想要早一点释放掉Autorelease对象,那么我们可以在对象外加一个自动释放池。比如,在循环处理数据时,临时变量要快速释放,就应该采用这种方式:

1
2
3
4
5
6
7
for (int i = 0; i < 10000000; ++i) {
@autoreleasepool {
TestModel *tempModel = [[TestModel alloc] init];
// 临时处理
// ...
} // 出了这里,就会去遍历该自动释放池了
}

1. 内存区域分布

堆操作:

操作系统中有一个存放堆内空闲存储块地址和大小的链表,当程序员申请空间的时候,系统就会遍历整个链表,找到第一个比申请空间大的空闲块节点,系统会将该空闲块从空闲链表中删除,分配给程序,由于申请的空间不一定与找到的空闲块大小相同,多出来剩余的空闲区会被系统重新添加到空闲链表中。当我需要删除对象时,便会根据指针纪录的地址,将这一块区域重新加入到链表中

栈操作:

栈区的内存是系统自动申请的而且是有序的。我们在申请栈空间时就只能在栈的顶部进行申请,当程序执行某个方法(或者函数)时,会从内存中栈(stack)的区域分配出一块内存空间,这个内存空间被称之为帧(frame)用来储存程序在这个方法内声明的变量的值。当应用启动并运行 main 函数时,它的帧会被存在栈的底部。当 main 继续调用另外一个方法时,这个方法的帧又会继续被压入栈的顶部。被调用的方法还可以再调用其他方法,以此类推,会有帧继续被压入栈顶,在被调用的方法结束后,程序会将其帧从栈顶释放。

2. iOS 引用计数内存管理策略

引用计数是一种内存管理技术,是指将资源(可以是对象、内存或磁盘空间等等)的被引用次数保存起来,当被引用次数变为零时就将其释放的过程。从而实现资源自动管理的目的。它的做法是:当创建一个对象的实例并在堆上申请内存时,对象的引用计数就为1,在其他对象中需要持有这个对象时,就需要把该对象的引用计数加1,需要释放一个对象时,就将该对象的引用计数减1,对象的引用计数为0时对象的内存会被立刻释放。

1
2
3
4
5
6
7
8
9
10
* 当程序调用方法名以alloc、new、copy、mutableCopy开头的方法来创建对象时,该对象的引用计数加1,这种情况我们将拥有所创建的这个对象。
* 当有一个新的指针指向这个对象时(或者调用retain方法时),我们将其引用计数加 1,接收到此调用的对象通常保证在他接收到retain所在的方法中保持有效。
* 除了以alloc、new、copy、mutableCopy 开头的方式创建对象外,其他方式创建的对象都是会被添加到AutoReleasePool,该对象的引用计数不会+1,这种情况下我们不用负责释放对象。
* 当某个指针不再指向这个对象时(或者调用release方法时),我们将其引用计数减 1
* 当对象的引用计数变为 0 时,说明这个对象不再被任何指针指向了,这个时候我们就可以将对象销毁,回收内存。这时候会调用该对象的dealloc方法。

* 对于数组类型其引用计数是会自动的相应变化的:
1. 当一个对象被添加进数组时 ,对象的引用计数也会相应的增加。
2. 数组移除指定的对象或者时所有对象,其被移除的对象会 release
3. 当数组销毁时,所有对象均会 release。

3. iOS开发中的内存管理四个黄金法则

1
2
3
4
* 自己生成的对象,自己持有
* 非自己生成的对象,自己也能持有
* 不再需要自己持有的对象的时候,释放
* 非自己持有的对象无法释放

4. 有关引用计数的方法:

1
2
3
4
5
* —retain:将该对象的引用计数器加1,从而持有该对象,但是并不拥有对象的释放权利。
* —release:将该对象的引用计数器减1,注意只有在计数为0的时候才会释放,而不是说一旦release就释放。
* —autorelease:调用 autorelease 后,对象不会被立即释放,而是注册到 autoreleasepool 中,经过一段时间后 pool结束,此时调用release方法,引用计数减1。
* —retainCount:返回该对象的引用计数的值。
* dealloc: 当一个对象一个拥有者都没有的话,dealloc就会被自动调用,dealloc方法的角色是释放对象自己的内存,并且销毁他所拥有的资源,包括所有对象变量的拥有权。

5. iOS中的变量标识符 & 属性标识符

变量标识符

1
2
3
4
5
6
7
8
9
10
11
12
__strong                持有强引用的变量在超出其作用域时被废弃,随着强引用的失效,引用的对象会随之释放,从另一个角度讲只要还有一个强指针指向某个对象,这个对象就会一直存活

__weak 弱引用虽然持有对象,但是并不增加引用计数,这样就避免了循环引用的产生,如果对象没有被其他对象强引用,弱引用会被置为 nil,弱引用的实现原理是这样:
系统对于每一个有弱引用的对象,都维护一个表来记录它所有的弱引用的指针地址。这样,当一个对象的引用计数为 0 时,系统就通过这张表,找到所有的弱引用指针,
继而把它们都置成 nil。

__unsafe_unretained 它和__weak有点类似,只不过在没有被其他对象强引用的时候它不会被置为 nil。如果它引用的对象被回收掉了,该指针就变成了野指针。
__unsafe_unretained修饰符的变量不属于编译器的内存管理对象,赋值时即不获得强引用也不获得弱引用。


__autoreleasing 替代autorelease方法

属性标识符

1
2
3
4
5
6
7
8
@property (assign/retain/strong/weak/unsafe_unretained/copy) PropertyType* propertyType

* assign 表明 setter 仅仅是一个简单的赋值操作,没有持有不持有这一说,通常用于基本的数值类型
* strong 表明属性定义一个持有者关系。当给属性设定一个新值的时候,首先对旧值进行 release ,对新值进行retain 然后进行赋值操作。
* weak 表明属性定义了一个持有者关系。当给属性设定一个新值的时候,这个值不会进行 retain,旧值也不会进行 release, 而是进行类似 assign 的操作。
不过当属性指向的对象被销毁时,该属性会被置为nil。
* unsafe_unretained 的语义和 assign 类似,不过是用于对象类型的,表示一个非拥有(unretained)的,同时也不会在对象被销毁时置为nil的(unsafe)关系。
* copy 类似于 strong,不过在赋值时进行 copy 操作而不是 retain 操作。通常在需要保留某个不可变对象,并且防止它被意外改变时使用。

概括得讲:

strong 和 copy都会持有对象,一个是持有对象的本身,一个是持有对象的副本。
weak,unsafe_unretained 更像一个旁观者,它们不会对数据的引用计数起到任何的改变,它看着对象被持有,被销毁却无能为力,只不过weak会在对象被销毁的时候会将其置为nil。而unsafe_unretained不会,unsafe_unretained 在开发中用得比较少, 如果对性能有极高的要求方可以考虑使用 unsafe_unretained 替换 weak,因为weak 其实对性能还是有影响的,只不过少量使用的时候是不会察觉到的,但是在类似YYModel这种序列化,反序列化库如果大量使用weak,肯定会对性能有较大的影响,weak的最主要作用就是解决循环引用的问题。这个会在后面做介绍,其实这个已经在Block总结的时候已经介绍过了。

6. ARC规则

与Java 中 GC 不同,ARC 是编译器特性,而不是基于运行时的,ARC 背后的原理是依赖编译器的静态分析能力,通过在编译时找出合理的插入引用计数管理代码,而不是实时监控与回收内存。
需要注意的是ARC 所做的事情并不仅仅局限于在编译期找到合适的位置帮你插入合适的 release 等等这样的内存管理方法,其在运行时期也做了一些优化,比如:

  • 合并对称的引用计数操作。比如将 +1/-1/+1/-1 直接置为 0.
  • 巧妙地跳过某些情况下 autorelease 机制的调用。

当返回值被返回之后,紧接着就需要被 retain 的时候,没有必要进行 autorelease + retain,直接什么都不要做就好了。

ARC 打开的情况下有如下限制:

1
2
3
4
5
6
7
* 不能使用retain/release/retainCount/autorelease
* 不能使用NSAllocateObject/NSDeallocateObject
* 须遵守内存管理的方法命名规则
* 不要显式调用dealloc
* 使用@autoreleasepool块替代NSAutoreleasePool
* 不能使用NSZone
* 对象型变量不能作为C语言结构体(struct、union)的成员: 要把对象类型添加到结构体成员中,可以强制转换为void *或是附加__unsafe_unretained修饰符。

7. 内存相关常见问题

内存问题有两种:

释放得太早,还在使用中就释放:

1
如果某个对象有至少一个拥有者,那么就必须保留不能释放,否则的话其他对象或者方法仍然有指向这个对象的指针沦为野指针(空指针)。这称之为过早释放,这是十分危险的,因为当野指针指向的内存区域再次被某个新的对象使用时,野指针上的操作便会破坏这个新对象造成文件丢失或者崩溃。

释放得太晚,已经不用了但是还没释放:

1
如果某个对象失去了拥有者(变成没有拥有者)那么应该将其释放掉,否则没有拥有者的对象会被孤立而程序找不到,并且始终占用着一块内存,导致内存泄漏

7.1 内存泄漏

ARC内存泄露常见场景:

  • 对象型变量作为C语言结构体,或者联合体(struct、union)的成员
    1
    2
    3
    struct Data {
    NSMutableArray __unsafe_unretained *array;
    };

__unsafe_unretained修饰符的变量不属于编译器的内存管理对象。如果管理时不注意赋值对象的所有者,便可能遭遇内存泄露或者程序崩溃。

  • 循环引用

循环引用常见有如下几种情况:

1.两个对象互相持有对象,这个可以设置弱引用解决,最常见的是block,但是需要注意并非所有的block都需要使用weak来打破循环引用,如果self没有持有block就不会造成循环引用。而有些地方之所以使用了__weak,是为了在[self dealloc]之后就不再执行了。

解决方案 1:在block外部对弱化self,在block内部强化已经弱化的weakSelf

1
2
3
4
5
6
7
8
9
10
11
12
@interface Test: NSObject {
id __weak obj_;
}

- (void)setObject:(id __strong)obj;
block持有self对象,这个要在block块外面和里面设置弱引用和强引用。

__weak __typeof(self) wself = self;
obj.block = ^{
__strong __typeof(wself) sself = wself;
[sself updateSomeThing];
}

解决方法 2: 通过将对象在block中设置为nil,但是这种需要注意的是block一定要被执行

1
2
3
4
5
__block TestObject *object = [[TestObject alloc] init…];
object.completionHandler = ^(NSInteger result) {
[object testMethod];
object = nil;
};

2.NSTimer的target持有self

1
2
3
4
5
self.timmer = [NSTimer scheduledTimerWithTimeInterval:1.0 
target:self
selector:@selector(updateTime:)
userInfo:nil
repeats:YES];

NSTimer会造成循环引用,timer会强引用target即self,一般self又会持有timer作为属性,这样就造成了循环引用。
如果timer只作为局部变量,不把timer作为属性同样释放不了,因为在加入runloop的操作中,timer被强引用。而timer作为局部变量,是无法执行invalidate的,所以在timer被invalidate之前,self也就不会被释放。

解决方案:在恰当时机调用[timer invalidate]即可,这个需要根据业务来自己决定,但是放在dealloc中调用是无效的,因为循环引用的情况下dealloc是不会被调用的,所以[timer invalidate]也就不会被调用。

还有下面几种定时相关的情形也需要注意:

1
2
3
4
__weak __typeof(self) wself = self;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[wself commentAnimation];
});
1
2
3
4
5
6
7
__weak __typeof(self) wself = self;
timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
dispatch_source_set_timer(timer, DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC, 1 * NSEC_PER_SEC);
dispatch_source_set_event_handler(timer, ^{
[wself commentAnimation];
});
dispatch_resume(timer);

3.代理delegate

代理在一般情况下,需要使用weak修饰,我们常见的delegate 一般会是VC的属性,被VC持有,同时我们会将VC相关的属性作为delegate从而导致循环引用。

解决方案:delegate属性使用weak修饰

4.NSNotification

使用block的方式增加notification,引用了self,在删除notification之前,self不会被释放

解决方案:在block内部使用弱引用解决

5.对象被单例持有

我们在单例里面设置一个对象的属性,因为单例是不会释放的,所以单例会有一直持有这个对象的引用。

1
[Instanse shared].obj = self;

6.CF类型内存

注意以creat,copy作为关键字的函数都是需要释放内存的.

8. 内存泄漏的排查方法

9. AutoreleasePool 与 RunLoop的关系

主线程的AutoreleasePool会在RunLoop进入的时候重新建立一个,在RunLoop退出休眠状态的时候也会进行释放后重新建立一个。在退出RunLoop的时候释放AutoreleasePool,具体见RunLoop总结

10. weak-strong dance

在7.1 介绍内存泄漏类型时候提到循环引用的一种解决方案是在block外部对弱化self,在block内部强化已经弱化的weakSelf,这也就是这里所说的 weak-strong dance,block外部对弱化self是为了避免循环引用,而在block内部强化已经弱化的weakSelf是为了避免外部_weak导致在运行block的时候self被释放。

0%