写点 SwiftUI: 不要再用 Gesture.onEnded(_:) 啦!

3,492 阅读8分钟

欢迎大家关注我的微信公众号:小林居酒屋

TL;DR

  1. SwiftUI Gesture 有 .possible, .active, .ended, .failed 四种内部状态。
  2. 依赖 onEnded(_:) 来清理数据是不可靠的,其无法探测到 Gesture .failed这种情景。
  3. 使用 GestureState 来更新 Gesture 所产生的数据,其能保证 Gesture 状态在重置时数据也被重置为初始值。

背景

前一阵子,同事遇到了有关 Gesture 的问题,碰巧我之前写过一些 Gesture 相关的代码,就一起研究了一下。

具体问题是,他使用 PartialSheet 做了一个半屏分享面板,这个分享面板向上拉动的时候,会有一个弹性拉扯的效果。

image_upimage_upload_load_5

但如果用户在用一个手指向上拉扯面板时,再使用第二个手指接力向上拉动,整个面板就会卡滞在当前拉伸的状态,松手后无法自动回弹到正常高度。

image_upimage_upload_load_5

也可以参考提交的 issue 中的视频

github.com/AndreaMiott…

问题最小 Demo

老规矩,第一步是将问题简化成一个最小 Demo.

由于 PartialSheet 是开源库,让我们得以以低成本的方式探究其内部形变是由什么组件提供的,clone 下来一顿翻,发现面板的弹性形变,由 DragGesture 提供,即通过监测用户手指向上滑动的长度,使用一个公式换算成面板拉伸的长度。

将其面板代码简化和抽象,我们能写出下面的 Demo:

struct ContentView: View {
  @State var offset: CGSize = .zero
  var gesture: some Gesture {
    DragGesture()
      .onChanged { value in
        offset = value.translation
      }
      .onEnded { _ in
        offset = .zero
      }
  }
  var body: some View {
    Color.blue
      .offset(offset)
      .gesture(gesture)
  }
}

这段代码的逻辑非常简单和好理解,如果拖拽蓝色的色块,拖拽的距离则被赋予到色块的 offset(_:) 中。当用户结束拖动,在 DragGestureonEnded(_:) 中将 offset 设置为零,此时色块回正。

按照 bug 复现路径,先用一根手指拖动色块,然后再加入第二根手指,此时色块停止移动,松手后色块无法正常回正,这和同事反馈的问题极其相似。

Debug 与修复

从现象上来看,第二根手指加入一起拖拽后出现问题,可能有两种情况

  1. offset 被设置,但 Binding 机制出现问题,没有正确通知到 View 进行更新;
  2. onChanged(:_)onEnded(:_) 的 action 没有被正确触发,offset 停留在只有一根手指时最后的值中。

对此我们通过 Log 来验证想法(在 Gesture 相关的 debug 中,我会更习惯使用 Log 来验证想法,因为 Gesture 是带状态和时间的,断点很有可能对整个点击事件产生较大影响):

var gesture: some Gesture {
  DragGesture()
  .onChanged { value in
      offset = value.translation
      print("[D] DragGesture.onChanged(_:) call")
  }
  .onEnded { _ in
      offset = .zero
      print("[D] DragGesture.onEnded(_:) call")
  }
}

跑起来,重现 bug 时发现,当第二根手指加入后,onChanged(:_) 的 action 立即停止触发,所有手指松开后, onEnded(:_) 的 action 没有被触发。似乎是 DragGesture在第二根手指加入后,进入了一种状态,这种状态既不属于 changed 也不属于 ended,所以两个 action 都无法唤起,Demo 的状态在此时出现无法回正的错乱。

业务的需求是,用户的手指从屏幕离开后,面板能正常回弹,对应到我们这个简单 Demo 中,即是色块能在手松开时能将 offset 设为 .zero. 所以我们需要在 DragGesture 进入不明状态后,仍能将 offset 重设为 .zero. 这种需求与 GestureState 所能提供的能力非常相似,它的文档描述如下:

A property wrapper type that updates a property while the user performs a gesture and resets the property back to its initial state when the gesture ends.

那么我们修改问题 Demo 为:

struct ContentView: View {
  @GestureState var offset: CGSize = .zero
  var gesture: some Gesture {
    DragGesture()
      .updating($offset) { value, offset, _ in
        offset = value.translation
      }
  }
  var body: some View {
    Color.blue
      .offset(offset)
      .gesture(gesture)
  }
}

对这个 Demo 再次复现问题时,发现色块会在第二根手指接触屏幕的一瞬间立即回正,如果按照苹果的文档,那么说明 DragGesture 已经完成了当前手势的识别,重置其状态了。

正好,符合我们的需求,将 PartialSheet 中相关代码使用 GestureState 进行修复,修复代码详见:

github.com/AndreaMiott…

发版合回业务仓验证,没有问题。

收工下班。全文完。

(后面都是花絮了,没啥好看的)

花絮

image_upimage_upload_load_5.jpg

DragGesture 识别的过程中, onChanged(:_) 的 action 最开始能被执行,第二根手指加入后 GestureState 会被重置成初始状态,说明整个 DragGesture 的已经结束,但为什么 onEnded(:_) 的 action 并没有被执行,我们来一探究竟。

由于 iOS16 SDK 隐藏了 SwiftUI 符号的原因,本次 debug 环境是 Xcode 13.4.1 + iOS 15.3 模拟器,使用 x86 汇编代码

堆栈信息

我们首先将 Demo 还原成最初的问题代码,在 onEnded(:_) 处添加断点,操作 Demo 让其正常结束。获得断点堆栈

螢幕截圖-1.png

在这个堆栈中,#1 帧似乎有我们所需要的信息,长难句练习:

SwiftUI`partial apply forwarder for closure #1 () -> () in SwiftUI.EndedCallbacks.dispatch(phase: SwiftUI.GesturePhase<τ_0_0>, state: inout ()) -> Swift.Optional<() -> ()>:

这是一个在 EndedCallbacks.dispatch(phase: SwiftUI.GesturePhase<τ_0_0>, state: inout ()) -> Swift.Optional<() -> ()> 函数中定义的 closure.

开发者定义的 action,被保存在 EndedCallbacks 中,由系统在合适的时候拉起

但这只是 EndedCallbacks.dispatch(phase: SwiftUI.GesturePhase<τ_0_0>, state: inout ()) -> Swift.Optional<() -> ()> 的一个闭包,我们打开 Hopper 找到对应的函数 dispatch(phase:state:) 函数,整个函数不算复杂,翻译成伪代码大致如下:

func dispatch(phase: SwiftUI.GesturePhase<A>, state: inout ()) -> (() -> ())? {
  switch phase {
  case 0x2:
    return closure #1
  default:
    return nil
  }
}

当 phase 的 case 为 0x2 时,dispatch 函数会返回一个闭包,而其他状态则会返回空

GesturePhase

GesturePhase<A> 又是啥玩意呀。

翻阅 SwiftUI 官方文档和 interface 都没有这个符号,所以这是一个内部符号。(有一些 public 符号会被特意隐藏,但会暴露在 interface 文件中)

内部符号我们可以通过 metadata 逐步拔开其面纱,Swift 的 runtime 系统会依赖保存了类型基础信息的 metadata, 这些 metadata 会被存储在二进制中。关于 metadata 在二进制中的布局,可以见苹果的官方文档 Type Metadata, 对于内部的属性,可以着重关注 Nominal Type Descriptor. 但刚好:

螢幕截圖-2.png

行吧,自己动手丰衣足食, 我们自己去挖 Nominal Type Descriptor 实际的二进制布局,幸亏 Swift 是开源的。翻阅了一圈资料和源码,ContextDescriptorBuilder 这个角色。这个角色并不是具体的一个类,由基类通过继承的方式逐步具体到某种类型的,在函数中 dispatch(phase:state:) 的汇编代码中, 我们发现 swift 通过 swift_getEnumCaseMultiPayload 函数获取了 phase 0x2 的值:

mov        rdi, r14
mov        rsi, r15
call       imp___stubs__swift_getEnumCaseMultiPayload ; swift_getEnumCaseMultiPayload
cmp        eax, 0x2

所以 GesturePhase 大概率是一个 enum, 找到它的 ContextDescriptorBuilder, EnumContextDescriptorBuilder,其继承关系如下:

未命名文件-10.png

查看它的 layout 函数,把父类调用 inline 进来的代码:

void layout() {
  asImpl().computeIdentity();
  asImpl().addFlags();
  asImpl().addParent();
  asImpl().addName();
  asImpl().addAccessFunction();
  asImpl().addReflectionFieldDescriptor();
  asImpl().addLayoutInfo();
  asImpl().addGenericSignature();
  asImpl().maybeAddResilientSuperclass();
  asImpl().maybeAddMetadataInitialization();
  maybeAddCanonicalMetadataPrespecializations();
}

这里是在逐步添加 ContextDescriptor 的信息,从命名上观测,我们需要的信息在 addReflectionFieldDescriptor 中。

打开 Hopper,找到 GesturePhase 的 Nominal Type Descriptor,按照 layout 函数格式化

这里注意,通过

void addReflectionFieldDescriptor() {
  // ...
  B.addRelativeAddress(IGM.getAddrOfReflectionFieldDescriptor(
    getType()->getDeclaredType()->getCanonicalType()));
}

可得知,在二进制中 Reflection Field Descriptor 是相对地址存储的,真实地址等于当前存储的值加上值所在的地址,这样做的一个好处是存储指针长度可以从 8 字节降至 4 字节。

螢幕截圖-3.png

跳转到 reflection metadata field descriptor SwiftUI.GesturePhase 后,又是一片二进制数据,这次结构信息在 Swift 文档上提都没提,继续 Swift 源码作业,在 FieldDescriptor 中找到其定义:

class FieldDescriptor {
  const FieldRecord *getFieldRecordBuffer() const {
    return reinterpret_cast<const FieldRecord *>(this + 1);
  }
public:
  const RelativeDirectPointer<const char> MangledTypeName;
  const RelativeDirectPointer<const char> Superclass;
  const FieldDescriptorKind Kind;
  const uint16_t FieldRecordSize;
  const uint32_t NumFields;
}
​
class FieldRecord {
  const FieldRecordFlags Flags;
public:
  const RelativeDirectPointer<const char> MangledTypeName;
  const RelativeDirectPointer<const char> FieldName;
}

有一堆基础信息和一个存储了 FieldRecord 的数组, FieldRecordFieldName 正是我们所需要的信息,按照其结构格式化二进制数据

螢幕截圖-4.png

跳转到第一个 FieldName 指向的地址(这里也是相对地址)

螢幕截圖-5.png

那么GesturePhase 在 SwiftUI 中的定义大致是

enum GesturePhase {
  case possible
  case active
  case ended
  case failed
}

(这里其实少了一个泛型和 case 所带的 payload,但不影响此次 debug,暂且忽略)

所以,EndedCallbacks.dispatch(phase:state:) 对应的伪代码我们可以改为:

func dispatch(phase: SwiftUI.GesturePhase<A>, state: inout ()) -> (() -> ())? {
  switch phase {
  case .ended:
    return closure #1
  case .possible, .active, .failed:
    return nil
  }
}

看到 .failed, 结论似乎已经呼之欲出。

case .failed

回到问题 Demo, 我们找到通过 hooper 找到 EndedCallbacks.dispatch(phase:state:) 的符号,并添加断点,命中之后在 swift_getEnumCaseMultiPayload 添加断点,让其输出 eax,和打开自动继续选项,同时关掉 EndedCallbacks.dispatch(phase:state:) 符号断点

螢幕截圖-6.png

我们可以发现,在一根手指拖拽时,GesturePhase 都是 0x1 .active, 完成拖拽后手松开,GesturePhase 转换成 0x2 .ended, onEnded(_:) action 被正常拉起。

eax = 0x00000001
eax = 0x00000001
eax = 0x00000001
eax = 0x00000002

但如果第一根手指拖拽时,加入第二根手指,GesturePhase 会是 0x3 .failed

eax = 0x00000001
eax = 0x00000001
eax = 0x00000001
eax = 0x00000003

由于 EndedCallbacks.dispatch(phase:state:) 需求的是 0x2 .ended, 所以 0x3 .failed自然不会有响应 ,而此时 Gesture 的单次响应流程已经结束,依赖 onEnded(:_) 来重置状态的业务代码不会被执行,bug 因此而产生。

后记

虽然业务上的 bug 原因是 SwiftUI Gesture 的真实状态隐藏直接导致的,但 SwiftUI 是一个推崇数据驱动的框架, onChanged(:_)onEnded(:_) 多少仍有一些事件驱动的影子,通过事件的枚举来完成业务很可能就会因为事件的遗漏而导致业务上的问题。而使用 GestureState 这种纯数据流,不关注实际状态的方式更新页面,将会是我们更好的选择。