普通视图

发现新文章,点击刷新页面。
昨天以前掘金 iOS

05-主题|事件响应者链@iOS-应用场景与进阶实践

2026年3月2日 14:16

本文在 01 总纲02 hitTest03 响应者链04 UIResponder 基础上,总结工程中的应用场景进阶用法:UIControl 的 target=nil 与响应者链、手势识别器与响应者的优先级、扩大点击区域与事件穿透、以及 SwiftUI 与 UIKit 的对比。文末附参考文献。


一、UIControl 与 target=nil 的响应者链

1.1 机制

UIControl(如 UIButton、UISlider)使用 addTarget(_:action:for:) 时,若将 target 设为 nil,系统不会在添加时绑定具体对象,而是在事件触发时第一响应者开始,沿 next 查找第一个能响应该 action 的响应者并调用,即 action 沿响应者链寻找 target [1]。编辑菜单(复制/粘贴/剪切)也使用同一机制在链上查找实现 copy(_:)paste(_:)cut(_:) 等的对象。

1.2 Action 方法签名

Action 方法通常为以下形式之一 [2]

  • @IBAction func doSomething()
  • @IBAction func doSomething(sender: UIButton)
  • @IBAction func doSomething(sender: UIButton, forEvent event: UIEvent)

1.3 使用注意

  • Cell 内按钮:按钮在 UITableViewCell/UICollectionViewCell 内时,链的路径是 Cell → 其他 view,不一定会经过 TableView 的 ViewController。若希望由 VC 处理,用 nil target 可能找不到 VC,此时更稳妥的做法是显式指定 target(如 VC)或通过 delegate/callback 把事件交给 VC [3]。Delegate、Block、闭包、函数封装、遍历传递等「传递方式」的对比与选型见 06-响应者链传递方式与编程模式详解
  • 非相邻 VC:通过 present 的 VC 与当前 VC 不一定在一条「相邻」的 next 链上,nil target 不一定能跨 present 边界找到目标,建议用显式 target 或业务层路由。

二、UIGestureRecognizer 与响应者链

2.1 优先级关系

手势识别器在触摸到达视图的 touchesBegan 等之前参与识别。若手势识别成功,可消费触摸,视图的 touches 方法可能不再被调用;若手势识别失败,触摸会交给视图并沿响应者链继续传递 [4]

控件(如 UIButton)可通过 gestureRecognizerShouldBegin(_:) 等让父视图的手势不干扰自己的点击,从而保证按钮的 target-action 优先。

2.2 泳道图:手势、响应者与控件的优先级

flowchart TB
    subgraph 触摸发生
        T1[手指按下]
    end
    subgraph 系统
        S1[hit-test 得到 view]
        S2[手势识别器优先]
    end
    subgraph 手势层
        G1[识别成功?]
        G2[消费事件]
        G3[识别失败]
    end
    subgraph 响应者层
        R1[视图 touches / UIControl]
        R2[沿 next 传递]
    end
    T1 --> S1
    S1 --> S2
    S2 --> G1
    G1 -->|是| G2
    G1 -->|否| G3
    G3 --> R1
    R1 --> R2

2.3 小结

层级 说明
手势识别 先于视图的 touches 参与识别,成功则可消费事件
视图 touches 手势未消费时,由 hit-test view 及其 next 链处理
UIControl 通过内部逻辑与 gesture 的配合,保证点击等行为优先

三、扩大点击区域与事件穿透

3.1 扩大点击区域

视觉较小的按钮或图标,可通过重写 point(inside:with:) 扩大可点击范围(如四周各扩展 20pt),提升可点性 [5]

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
    let margin: CGFloat = 20
    return bounds.insetBy(dx: -margin, dy: -margin).contains(point)
}

3.2 事件「穿透」到下层

若希望某视图不响应触摸、让触摸落到下层视图,可重写 hitTest(_:with:),在满足条件时返回 nil,则当前视图及其子视图不参与命中,系统会继续用其兄弟或父视图参与 hit-test [6]

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    let hit = super.hitTest(point, with: event)
    // 若希望本视图不拦截,可返回 nil;否则返回 hit
    return shouldPassThrough ? nil : hit
}

商用场景示例:视频播放页上的礼物动画、点赞动效浮层使用 PassThroughView 或重写 hitTest 返回 nil,使点击落到下层进度条、暂停按钮;或活动弹窗关闭后遮罩不拦截,点击空白处关闭。

3.3 手势与按钮共存的完整代码(商用:列表 Cell 内按钮由 VC 处理)

// Cell 内「加购」按钮希望由 ListViewController 处理,用 delegate 传递,避免 nil target 链不到 VC
protocol ProductCellDelegate: AnyObject {
    func productCell(_ cell: ProductCell, didTapAddCart productId: String)
}

class ProductCell: UITableViewCell {
    weak var delegate: ProductCellDelegate?
    private var productId: String = ""
    @objc private func addCartTapped() {
        delegate?.productCell(self, didTapAddCart: productId)
    }
}

class ListViewController: UIViewController, ProductCellDelegate {
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "ProductCell", for: indexPath) as! ProductCell
        cell.delegate = self
        cell.configure(productId: items[indexPath.row].id)
        return cell
    }
    func productCell(_ cell: ProductCell, didTapAddCart productId: String) {
        // 加购、埋点、弹 toast 等
        cartService.add(productId: productId)
    }
}

3.4 应用场景分类(思维导图)

mindmap
  root((应用场景))
    列表与 Cell
      Cell 内按钮 delegate
      扩大热区 小图标
    浮层与遮罩
      穿透 hitTest 返回 nil
      手势与按钮共存
    编辑与输入
      编辑菜单 copy paste
      自定义 inputView
    手势与链
      手势优先 再响应者链
      gestureRecognizerShouldBegin

四、SwiftUI 与 UIKit 的对比(简要)

SwiftUI 没有 UIKit 式的「响应者链」[7][8]

  • 使用 Gesture 修饰符在视图上声明手势,由系统做 hit-test 与手势竞争,不会把事件沿「next」链向上冒泡。
  • 可点击区域由 framecontentShape 决定;.allowsHitTesting(false) 相当于从 hit-test 中排除。
  • 多手势的优先级通过 highPriorityGesturesimultaneousGesture 等显式组合,而非依赖「链」传递。

UIKit 与 SwiftUI 混用时,需注意:SwiftUI 宿主视图内的交互由 SwiftUI 管理;嵌入的 UIKit 视图仍走 UIKit 的 hit-test 与响应者链。


五、应用场景小结

场景 涉及机制 建议
按钮/控件由上层 VC 统一处理 target-action + nil target → 响应者链 Cell 内或复杂层级下优先显式 target 或 delegate
编辑菜单(复制/粘贴) 链上查找 canPerformAction / target 在合适响应者上实现 copy/paste/cut 等方法
扩大按钮可点区域 point(inside:with:) 重写并扩大 bounds 的「有效」区域
浮层不拦截触摸 hitTest(_:with:) 返回 nil 指定条件下返回 nil 实现穿透
手势与按钮共存 手势识别 vs 响应者 用 gestureRecognizerShouldBegin 等保护控件
自定义键盘/输入条 First Responder + inputView 成为第一响应者并设置 inputView / inputAccessoryView
事件/回调传递方式选型 Delegate / Block / 闭包 / 函数封装 / 遍历 06-响应者链传递方式与编程模式详解

5.1 商用场景速查

场景 做法
电商列表加购/收藏 Cell 内小按钮用 delegate 交给 VC,或扩大热区 + target-action
视频/直播浮层不挡点击 浮层 view 重写 hitTest 在命中自己时返回 nil
活动弹窗遮罩点击关闭 遮罩用 PassThroughView 或 hitTest 返回 nil,按钮在遮罩上方单独处理
设置页开关/列表点击 系统 UITableView 的 didSelect + 响应者链;Cell 内控件用 delegate 更稳
安全输入/自定义键盘 自定义 UITextField 的 inputView,canBecomeFirstResponder = true

参考文献

[1] Using responders and the responder chain to handle events - Controls and the responder chain
[2] UIControl | Apple Developer Documentation
[3] UIControl Target Action event not flowing up the responder chain (Stack Overflow)
[4] Using responders and the responder chain - Gesture recognizers
[5] How to implement point(inside:with:) (Stack Overflow)
[6] Hacking Hit Tests (Khanlou)
[7] SwiftUI Gesture System Internals (DEV)
[8] SwiftUI Hit-Testing & Event Propagation Internals (DEV)

02-主题|事件响应者链@iOS-hitTest与事件传递详解

2026年3月2日 14:13

本文专门讲解 iOS 中事件传递的「确定目标」阶段:hitTest(_:with:)point(inside:with:) 的原理、算法、子视图遍历顺序,以及可响应条件、自定义命中区域和常见图示。与「响应者链」的传递阶段配合理解,可参见 03-响应者链与 nextResponder 详解


一、为什么需要 Hit-Testing

触摸发生时,系统需要确定「触摸点落在哪个视图上」,以便将事件交给该视图并进入响应者链。Hit-testing 即在这一阶段,从窗口根视图开始,沿视图层级向下查找最底层且包含该点的视图,该视图将作为该触摸事件的第一响应者(hit-test view)[1]


二、核心 API

方法 所属 作用
hitTest(_:with:) UIView 在视图树中查找包含指定点的最底层子视图;返回 nil 表示当前视图及其子视图均不接收该点
point(inside:with:) UIView 判断给定点是否在当前视图的 bounds 内(可被重写以扩展或缩小命中区域)

系统从 UIWindow 开始,对根视图调用 hitTest(_:with:),传入触摸点(已转换为该视图坐标系)。视图内部会先调用 point(inside:with:) 判断点是否在自己范围内,再递归对子视图调用 hitTest(_:with:)


三、hitTest 算法与伪代码

3.1 可响应前提

视图要参与 hit-test,通常需同时满足(否则当前分支会被剪掉,返回 nil)[[2]][[3]]:

  • isUserInteractionEnabled == true
  • isHidden == false
  • alpha > 0.01

不满足时,hitTest(_:with:) 直接返回 nil,该视图及其子视图都不会成为命中目标。

3.2 系统 hitTest 逻辑(伪代码)

以下为对系统行为的等价描述,便于理解顺序与剪枝逻辑;实际实现以 Apple 源码为准。

函数 hitTest(point, event) -> UIView?:
    若 当前视图 不满足可响应条件(userInteractionEnabled / hidden / alpha):
        返回 nil

    若 pointInside(point, event) 为 false:
        返回 nil   // 点不在当前视图内,整棵子树不再查找

    // 按子视图「从后往前」顺序遍历(逆序:最后加入的、Z 轴更靠前的先测)
    对 每个 subview 从 subviews.last 到 subviews.first:
        candidate = subview.hitTest( 将 point 转换到 subview 坐标系, event )
        若 candidate != nil:
            返回 candidate   // 找到第一个有返回值的子视图即停止

    若没有子视图命中:
        返回 self   // 点在自己范围内且没有更底层子视图命中,则自己就是 hit-test view

要点:

  1. 先判 pointInside:点不在当前视图内则直接返回 nil,整棵子树被剪枝。
  2. 子视图逆序:按 subviews 从后往前遍历,即** Z 轴靠前的子视图优先**,与视觉上的「最上层」一致。
  3. 第一个非 nil 即返回:找到第一个返回非 nil 的子视图就停止,该子视图即为 hit-test view。

3.3 point(inside:with:) 默认行为

默认实现等价于:判断点是否落在视图的 bounds 内(通常不考虑 subview 的超出部分;且若父视图 clipsToBounds == true,超出父视图 bounds 的子视图区域不会参与父视图的 hit-test,因为点不在父视图 bounds 内会先被剪枝)[[1]]。

函数 pointInside(point, event) -> Bool:
    返回 CGRectContainsPoint(self.bounds, 将 point 转换到当前视图的 bounds 坐标系)

可重写以扩大或缩小可点击区域(如圆形按钮、不规则形状、透明区域穿透等)。


四、事件传递流程(自上而下)

4.1 流程图

flowchart TB
    A[触摸发生] --> B[UIWindow 收到事件]
    B --> C[对根 view 调用 hitTest:withEvent:]
    C --> D{pointInside 为 true?}
    D -->|否| E[返回 nil,该分支结束]
    D -->|是| F[按逆序遍历子视图]
    F --> G[对子视图递归 hitTest]
    G --> H{有子视图返回非 nil?}
    H -->|是| I[返回该子视图 作为 hit-test view]
    H -->|否| J[返回 self]
    I --> K[该 view 成为触摸的 first responder]
    J --> K

4.2 泳道图:Hit-Test 各角色协作

flowchart TB
    subgraph 用户
        U1[手指触摸屏幕]
    end
    subgraph 系统_UIApplication
        S1[事件入队]
        S2[派发至 keyWindow]
    end
    subgraph 系统_UIWindow
        W1[hitTest 根 view]
        W2[得到 hit-test view]
    end
    subgraph 视图层级
        V1[pointInside 判断]
        V2[逆序遍历子视图]
        V3[递归 hitTest]
        V4[返回最终 view]
    end
    U1 --> S1
    S1 --> S2
    S2 --> W1
    W1 --> V1
    V1 --> V2
    V2 --> V3
    V3 --> V4
    V4 --> W2

4.3 Hit-Test 知识结构(思维导图)

mindmap
  root((Hit-Test))
    入口
      UIWindow 根视图
      hitTest:withEvent:
    条件
      userInteractionEnabled
      hidden / alpha
      pointInside
    遍历
      子视图逆序
      Z 轴优先
    结果
      hit-test view
      first responder
    自定义
      扩大热区
      穿透
      不规则区域

五、子视图顺序与 Z 轴

子视图在 subviews 数组中的索引越大,在 hit-test 时越被遍历,因此后加入的、索引更大的子视图会优先被命中,与它们在屏幕上的「盖在上面」一致。若两个子视图重叠,则上面那一层会先被 hitTest 到并成为 hit-test view。

flowchart LR
    subgraph 视图层级
        V[父视图]
        V --> A[子视图 A index 0]
        V --> B[子视图 B index 1]
        V --> C[子视图 C index 2]
    end
    subgraph hitTest 顺序
        C --> B
        B --> A
    end

六、clipsToBounds 与命中

  • pointInside 只判断点是否在当前视图的 bounds 内。
  • 若父视图设置了 clipsToBounds = true,子视图超出父视图 bounds 的部分会被裁剪掉显示,但 hit-test 仍按 bounds 判断:若触摸点落在父视图 bounds 外(即使落在子视图的 frame 内),父视图的 pointInside 会返回 false,整棵子树不会参与命中 [[1]]。
  • 因此:子视图若超出父视图 bounds 且父视图 clipsToBounds,超出部分在默认实现下无法被 hit-test 命中,除非在父视图层重写 point(inside:with:)hitTest(_:with:) 做特殊处理。

七、自定义 hitTest / pointInside 的常见用法

需求 做法
扩大点击区域 重写 point(inside:with:),对中心区域做扩展(如上下左右各扩展 44pt)
透明区域不响应 重写 point(inside:with:),根据像素透明度返回 false
让触摸「穿透」到下层 重写 hitTest(_:with:),在特定条件下返回 nil,使当前视图不参与命中
指定子视图优先 重写 hitTest(_:with:),自定义遍历顺序或强制返回某子视图

示例(扩大点击区域):

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
    let inset: CGFloat = -20
    return bounds.insetBy(dx: inset, dy: inset).contains(point)
}

商用场景示例:商品列表 Cell 内「加购」「收藏」等小图标,视觉约 24pt,为提升点击率将热区扩大到 44pt,重写该图标的容器 view 或子类的 point(inside:with:) 即可。

穿透示例(浮层不拦截、点击落到下层):

/// 用于半透明遮罩:触摸不消费,交给下层视图(如背后的列表、按钮)
class PassThroughView: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let hit = super.hitTest(point, with: event)
        return hit == self ? nil : hit  // 若命中自己则返回 nil,让下层接收
    }
}

商用场景示例:活动弹窗关闭后残留半透明遮罩,希望点击遮罩空白处能穿透到下层(如关闭按钮、跳过);或直播/视频上的礼物动画层不拦截点击,让下层进度条、点赞可点。

Swift 完整示例:可复用的「扩大热区」UIView 子类(适用于任意按钮/图标):

/// 将子视图的可点击区域向外扩展,不改变视觉 frame
final class ExpandHitAreaView: UIView {
    var hitAreaInset: UIEdgeInsets = .zero  // 负值表示扩大,如 (-10,-10,-10,-10)
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        bounds.inset(by: hitAreaInset).contains(point)
    }
}
// 使用:将按钮包在 ExpandHitAreaView 内,设置 hitAreaInset = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10)

八、与响应者链的衔接

hit-test 得到的是触摸事件的第一响应者(某个 UIView)。触摸事件会先发给该视图(及其上的手势识别器);若视图未处理或未实现 touchesBegan 等,事件会沿 nextResponder 向上传递。因此:

  • 阶段一(本文):hit-test,自顶向下,确定「谁被点中」。
  • 阶段二:响应者链,自底向上,确定「谁处理」。详见 03-响应者链与 nextResponder 详解

参考文献

[1] Using responders and the responder chain to handle events - Determine which responder contained a touch event
[2] Event handling for iOS - hitTest:withEvent: and pointInside:withEvent:
[3] HitTest and UIResponder in iOS (Medium)

01-研究系统框架@Web@iOS | JavaScriptCore 框架:从使用到原理解析

2026年3月1日 20:10

JavaScript 越来越多地出现在我们客户端开发的视野中,从 React Native 到 JSPatch,JavaScript 与客户端相结合的技术开始变得魅力无穷。本文主要讲解 iOS 中的 JavaScriptCore 框架,正是它为 iOS 提供了执行 JavaScript 代码的能力。未来的技术日新月异,JavaScript 与 iOS 正在碰撞出新的激情。

JavaScriptCoreJavaScript虚拟机,为 JavaScript 的执行提供底层资源。


📋 目录


一、JavaScript

在讨论JavaScriptCore之前,我们首先必须对JavaScript有所了解。

1. JavaScript干啥的?

  • 说的高大上一点:一门基于原型、函数先行的高级编程语言,通过解释执行,是动态类型的直译语言。是一门多范式的语言,它支持面向对象编程,命令式编程,以及函数式编程。
  • 说的通俗一点:主要用于网页,为其提供动态交互的能力。可嵌入动态文本于HTML页面,对浏览器事件作出响应,读写HTML元素,控制cookies等。
  • 再通俗一点:抢月饼,button.click()。(PS:请谨慎使用while循环)

img

2. JavaScript起源与历史

  • 1990年底,欧洲核能研究组织(CERN)科学家Tim Berners-Lee,在互联网的基础上,发明了万维网(World Wide Web),从此可以在网上浏览网页文件。
  • 1994年12月,Netscape 发布了一款面向普通用户的新一代的浏览器Navigator 1.0版,市场份额一举超过90%。
  • 1995年,Netscape公司雇佣了程序员Brendan Eich开发这种嵌入网页的脚本语言。最初名字叫做Mocha,1995年9月改为LiveScript。
  • 1995年12月,Netscape公司与Sun公司达成协议,后者允许将这种语言叫做JavaScript。

3. JavaScript与ECMAScript

  • “JavaScript”是Sun公司的注册商标,用来特制网景(现在的Mozilla)对于这门语言的实现。网景将这门语言作为标准提交给了ECMA——欧洲计算机制造协会。由于商标上的冲突,这门语言的标准版本改了一个丑陋的名字“ECMAScript”。同样由于商标的冲突,微软对这门语言的实现版本取了一个广为人知的名字“Jscript”。
  • ECMAScript作为JavaScript的标准,一般认为后者是前者的实现。

4. Java和JavaScript

img

《雷锋和雷峰塔》

Java 和 JavaScript 是两门不同的编程语言 一般认为,当时 Netscape 之所以将 LiveScript 命名为 JavaScript,是因为 Java 是当时最流行的编程语言,带有 “Java” 的名字有助于这门新生语言的传播。

二、 JavaScriptCore

1. 浏览器演进

  • 演进完整图

upload.wikimedia.org/wikipedia/c…

  • WebKit分支

现在使用WebKit的主要两个浏览器Sfari和Chromium(Chorme的开源项目)。WebKit起源于KDE的开源项目Konqueror的分支,由苹果公司用于Sfari浏览器。其一条分支发展成为Chorme的内核,2013年Google在此基础上开发了新的Blink内核。

img

2. WebKit排版引擎

webkit是sfari、chrome等浏览器的排版引擎,各部分架构图如下

img

  • webkit Embedding API是browser UI与webpage进行交互的api接口;
  • platformAPI提供与底层驱动的交互, 如网络, 字体渲染, 影音文件解码, 渲染引擎等;
  • WebCore它实现了对文档的模型化,包括了CSS, DOM, Render等的实现;
  • JSCore是专门处理JavaScript脚本的引擎;

3. JavaScript引擎

  • JavaScript引擎是专门处理JavaScript脚本的虚拟机,一般会附带在网页浏览器之中。第一个JavaScript引擎由布兰登·艾克在网景公司开发,用于Netscape Navigator网页浏览器中。JavaScriptCore就是一个JavaScript引擎。
  • 下图是当前主要的还在开发中的JavaScript引擎

img

4. JavaScriptCore组成

JavaScriptCore主要由以下模块组成:

  • Lexer 词法分析器,将脚本源码分解成一系列的Token
  • Parser 语法分析器,处理Token并生成相应的语法树
  • LLInt 低级解释器,执行Parser生成的二进制代码
  • Baseline JIT 基线JIT(just in time 实施编译)
  • DFG 低延迟优化的JIT
  • FTL 高通量优化的JIT

关于更多JavaScriptCore的实现细节,参考 trac.webkit.org/wiki/JavaSc…

5. JavaScriptCore 框架与历史

JavaScriptCore 是一个 C++ 实现的开源项目(WebKit 的一部分)。历史上,JSC 长期作为 Safari / WebKit 的内置 JS 引擎;自 iOS 7.0 / OS X 10.9 起,Apple 将 JavaScriptCore 以系统框架 JavaScriptCore.framework 的形式开放给开发者,使其可在 Objective-C 或基于 C 的程序中执行 JavaScript 代码,并向 JS 环境中插入自定义对象,而无需依赖 UIWebView。这为 Hybrid 应用、热更新、脚本引擎等场景提供了统一的底层能力。

JavaScriptCore.h 中,我们可以看到:

#ifndef JavaScriptCore_h
#define JavaScriptCore_h

#include <JavaScriptCore/JavaScript.h>
#include <JavaScriptCore/JSStringRefCF.h>

#if defined(__OBJC__) && JSC_OBJC_API_ENABLED

#import "JSContext.h"
#import "JSValue.h"
#import "JSManagedValue.h"
#import "JSVirtualMachine.h"
#import "JSExport.h"

#endif

#endif /* JavaScriptCore_h */

这里已经很清晰地列出了JavaScriptCore的主要几个类:

  • JSContext
  • JSValue
  • JSManagedValue
  • JSVirtualMachine
  • JSExport

接下来我们会依次讲解这几个类的用法。

6. Hello World!

这段代码展示了如何在 Objective-C 中执行一段 JavaScript 代码,并且获取返回值并转换成 OC 数据打印:

// 创建虚拟机
JSVirtualMachine *vm = [[JSVirtualMachine alloc] init];

//创建上下文
JSContext *context = [[JSContext alloc] initWithVirtualMachine:vm];

//执行JavaScript代码并获取返回值
JSValue *value = [context evaluateScript:@"1+2*3"];

// 转换成 OC 数据并打印
NSLog(@"value = %d", [value toInt32]);
// Output: value = 7

Swift 等价写法:

import JavaScriptCore

let vm = JSVirtualMachine()!
let context = JSContext(virtualMachine: vm)!
let value = context.evaluateScript("1 + 2 * 3")!
print("value =", value.toInt32())  // value = 7

三、 JSVirtualMachine

一个JSVirtualMachine的实例就是一个完整独立的JavaScript的执行环境,为JavaScript的执行提供底层资源。

这个类主要用来做两件事情:

  1. 实现并发的 JavaScript 执行
  2. JavaScript 和 Objective-C 桥接对象的内存管理

看下头文件 JSVirtualMachine.h 里有什么:

NS_CLASS_AVAILABLE(10_9, 7_0)
@interface JSVirtualMachine : NSObject

/* 创建一个新的完全独立的虚拟机 */
(instancetype)init;

/* 对桥接对象进行内存管理 */
- (void)addManagedReference:(id)object withOwner:(id)owner;

/* 取消对桥接对象的内存管理 */
- (void)removeManagedReference:(id)object withOwner:(id)owner;

@end

每一个JavaScript上下文(JSContext对象)都归属于一个虚拟机(JSVirtualMachine)。每个虚拟机可以包含多个不同的上下文,并允许在这些不同的上下文之间传值(JSValue对象)。

然而,每个虚拟机都是完整且独立的,有其独立的堆空间和垃圾回收器(garbage collector ),GC无法处理别的虚拟机堆中的对象,因此你不能把一个虚拟机中创建的值传给另一个虚拟机。

img

线程和JavaScript的并发执行

JavaScriptCore API都是线程安全的。你可以在任意线程创建JSValue或者执行JS代码,然而,所有其他想要使用该虚拟机的线程都要等待。

  • 如果想并发执行JS,需要使用多个不同的虚拟机来实现。
  • 可以在子线程中执行JS代码。

通过下面这个 demo 来理解这个并发机制:

JSContext *context = [[CustomJSContext alloc] init];
JSContext *context1 = [[CustomJSContext alloc] init];
JSContext *context2 = [[CustomJSContext alloc] initWithVirtualMachine:[context virtualMachine]];
NSLog(@"start");
dispatch_async(queue, ^{
    while (true) {
        sleep(1);
        [context evaluateScript:@"log('tick')"];
    }
});
dispatch_async(queue1, ^{
    while (true) {
        sleep(1);
        [context1 evaluateScript:@"log('tick_1')"];
    }
});
dispatch_async(queue2, ^{
    while (true) {
        sleep(1);
        [context2 evaluateScript:@"log('tick_2')"];
    }
});
[context evaluateScript:@"sleep(5)"];
NSLog(@"end");

context和context2属于同一个虚拟机。

context1属于另一个虚拟机。

三个线程分别异步执行每秒1次的js log,首先会休眠1秒。

在context上执行一个休眠5秒的JS函数。

首先执行的应该是休眠5秒的JS函数,在此期间,context所处的虚拟机上的其他调用都会处于等待状态,因此tick和tick_2在前5秒都不会有执行。

而context1所处的虚拟机仍然可以正常执行tick_1

休眠5秒结束后,tick和tick_2才会开始执行(不保证先后顺序)。

实际运行输出的 log 是:

start
tick_1
tick_1
tick_1
tick_1
end
tick
tick_2

四、 JSContext

一个JSContext对象代表一个JavaScript执行环境。在native代码中,使用JSContext去执行JS代码,访问JS中定义或者计算的值,并使JavaScript可以访问native的对象、方法、函数。

img

1. JSContext执行JS代码

  • 调用evaluateScript函数可以执行一段top-level 的JS代码,并可向global对象添加函数和对象定义
  • 其返回值是JavaScript代码中最后一个生成的值

API Reference

NS_CLASS_AVAILABLE(10_9, 7_0)
@interface JSContext : NSObject

/* 创建一个JSContext,同时会创建一个新的JSVirtualMachine */
(instancetype)init;

/* 在指定虚拟机上创建一个JSContext */
(instancetype)initWithVirtualMachine:
        (JSVirtualMachine*)virtualMachine;

/* 执行一段JS代码,返回最后生成的一个值 */
(JSValue *)evaluateScript:(NSString *)script;

/* 执行一段JS代码,并将sourceURL认作其源码URL(仅作标记用) */
- (JSValue *)evaluateScript:(NSString *)script withSourceURL:(NSURL*)sourceURL     NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的context */
+ (JSContext *)currentContext;

/* 获取当前执行的JavaScript function*/
+ (JSValue *)currentCallee NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的this */
+ (JSValue *)currentThis;

/* Returns the arguments to the current native callback from JavaScript code.*/
+ (NSArray *)currentArguments;

/* 获取当前context的全局对象。WebKit中的context返回的便是WindowProxy对象*/
@property (readonly, strong) JSValue *globalObject;

@property (strong) JSValue *exception;
@property (copy) void(^exceptionHandler)(JSContext *context, JSValue
    *exception);

@property (readonly, strong) JSVirtualMachine *virtualMachine;

@property (copy) NSString *name NS_AVAILABLE(10_10, 8_0);


@end

2. JSContext访问JS对象

一个JSContext对象对应了一个全局对象(global object)。例如web浏览器中中的JSContext,其全局对象就是window对象。在其他环境中,全局对象也承担了类似的角色,用来区分不同的JavaScript context的作用域。全局变量是全局对象的属性,可以通过JSValue对象或者context下标的方式来访问。

示例代码:

JSValue *value = [context evaluateScript:@"var a = 1+2*3;"];

NSLog(@"a = %@", [context objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", [context.globalObject objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", context[@"a"]);
// Output: a = 7, a = 7, a = 7

这里列出了三种访问JavaScript对象的方法

  • 通过context的实例方法objectForKeyedSubscript
  • 通过context.globalObject的objectForKeyedSubscript实例方法
  • 通过下标方式

设置属性也是对应的。

API Reference

/* 为 JSContext 提供下标访问元素的方式 */
@interface JSContext (SubscriptSupport)

/* 首先将key转为JSValue对象,然后使用这个值在JavaScript context的全局对象中查找这个名字的属性并返回 */
(JSValue *)objectForKeyedSubscript:(id)key;

/* 首先将key转为JSValue对象,然后用这个值在JavaScript context的全局对象中设置这个属性。
可使用这个方法将native中的对象或者方法桥接给JavaScript调用 */
(void)setObject:(id)object forKeyedSubscript:(NSObject <NSCopying>*)key;

@end



/* 例如:以下代码在JavaScript中创建了一个实现是Objective-C block的function */
context[@"makeNSColor"] = ^(NSDictionary *rgb){
    float r = [rgb[@"red"] floatValue];
    float g = [rgb[@"green"] floatValue];
    float b = [rgb[@"blue"] floatValue];
    return [NSColor colorWithRed:(r / 255.f) green:(g / 255.f) blue:(b / 255.f)         alpha:1.0];
};
JSValue *value = [context evaluateScript:@"makeNSColor({red:12, green:23, blue:67})"];

五、 JSValue

一个JSValue实例就是一个JavaScript值的引用。使用JSValue类在JavaScript和native代码之间转换一些基本类型的数据(比如数值和字符串)。你也可以使用这个类去创建包装了自定义类的native对象的JavaScript对象,或者创建由native方法或者block实现的JavaScript函数。

每个JSValue实例都来源于一个代表JavaScript执行环境的JSContext对象,这个执行环境就包含了这个JSValue对应的值。每个JSValue对象都持有其JSContext对象的强引用,只要有任何一个与特定JSContext关联的JSValue被持有(retain),这个JSContext就会一直存活。通过调用JSValue的实例方法返回的其他的JSValue对象都属于与最始的JSValue相同的JSContext。

img

每个JSValue都通过其JSContext间接关联了一个特定的代表执行资源基础的JSVirtualMachine对象。你只能将一个JSValue对象传给由相同虚拟机管理(host)的JSValue或者JSContext的实例方法。如果尝试把一个虚拟机的JSValue传给另一个虚拟机,将会触发一个Objective-C异常。

img

1. JSValue类型转换

JSValue提供了一系列的方法将native与JavaScript的数据类型进行相互转换:

img

2. NSDictionary与JS对象

NSDictionary 对象以及其包含的 keys 与 JavaScript 中的对应名称的属性相互转换。key 所对应的值也会递归地进行拷贝和转换。

[context evaluateScript:@"var color = {red:230, green:90, blue:100}"];

//js->native 给你看我的颜色
JSValue *colorValue = context[@"color"];
NSLog(@"r=%@, g=%@, b=%@", colorValue[@"red"], colorValue[@"green"], colorValue[@"blue"]);
NSDictionary *colorDic = [colorValue toDictionary];
NSLog(@"r=%@, g=%@, b=%@", colorDic[@"red"], colorDic[@"green"], colorDic[@"blue"]);

//native->js 给你点颜色看看
context[@"color"] = @{@"red":@(0), @"green":@(0), @"blue":@(0)};
[context evaluateScript:@"log('r:'+color.red+'g:'+color.green+' b:'+color.blue)"];
// Output:
// r=230, g=90, b=100
// r=230, g=90, b=100
// r:0 g:0 b:0

可见,JS中的对象可以直接转换成Objective-C中的NSDictionary,NSDictionary传入JavaScript也可以直接当作对象被使用。

3. NSArray与JS数组

NSArray 对象与 JavaScript 中的 array 相互转换。其子元素也会递归地进行拷贝和转换。

[context evaluateScript:@"var friends = ['Alice','Jenny','XiaoMing']"];

//js->native 你说哪个是真爱?
JSValue *friendsValue = context[@"friends"];
NSLog(@"%@, %@, %@", friendsValue[0], friendsValue[1], friendsValue[2]);
NSArray *friendsArray = [friendsValue toArray];
NSLog(@"%@, %@, %@", friendsArray[0], friendsArray[1], friendsArray[2]);

//native->js 我觉得 XiaoMing 不错,给你再推荐个 Jimmy
context[@"girlFriends"] = @[friendsArray[2], @"Jimmy"];
[context evaluateScript:@"log('girlFriends :'+girlFriends[0]+' '+girlFriends[1])"];
// Output: Alice, Jenny, XiaoMing / girlFriends : XiaoMing Jimmy

4. Block/函数和JS function

Objective-C中的block转换成JavaScript中的function对象。参数以及返回类型使用相同的规则转换。

将一个代表native的block或者方法的JavaScript function进行转换将会得到那个block或方法。

其他的JavaScript函数将会被转换为一个空的dictionary。因为JavaScript函数也是一个对象。

5. OC对象和JS对象

对于所有其他 native 的对象类型,JavaScriptCore 都会创建一个拥有 constructor 原型链的 wrapper 对象,用来反映 native 类型的继承关系。默认情况下,native 对象的属性和方法并不会导出给其对应的 JavaScript wrapper 对象。通过 JSExport 协议可选择性地导出属性和方法。下面第六节对 JSExport 与原生对象导出做详细讲解。


六、JSExport 与原生对象导出

JSExport 是 JavaScriptCore 框架中的协议,用于将 Objective-C/Swift 的类(属性与方法)选择性导出给 JavaScript,使 JS 代码可以像调用普通对象一样调用原生对象 [1][2]。

6.1 作用与机制

  • 遵循 JSExport 的协议中声明的属性和方法,会在将 native 对象注入到 JSContext(如 context[@"bridge"] = nativeObject)时,自动暴露为 JS 侧的属性和函数。
  • 若类未实现 JSExport 或未在协议中声明,则对应属性/方法不会出现在 JS 中;这样可控制「桥接面」,避免暴露内部实现 [1][2]。

6.2 使用示例(概念)

@protocol MyPointExport <JSExport>
@property (nonatomic, assign) double x;
@property (nonatomic, assign) double y;
- (NSString *)description;
@end

@interface MyPoint : NSObject <MyPointExport>
@property (nonatomic, assign) double x;
@property (nonatomic, assign) double y;
@end

MyPoint 实例赋给 context[@"point"] 后,在 JS 中可访问 point.xpoint.y 并调用 point.description()
注意:若在 Block 或导出方法中再次使用 JSValueJSContext,需注意线程与内存管理(见第七节 JSManagedValue)[1][2]。

Swift 中的等价写法(通过 JSContext 注入遵循 JSExport 的类):

import JavaScriptCore

@objc protocol PointExport: JSExport {
    var x: Double { get set }
    var y: Double { get set }
    func description() -> String
}

class Point: NSObject, PointExport {
    @objc var x: Double
    @objc var y: Double
    init(x: Double, y: Double) { self.x = x; self.y = y }
    func description() -> String { "Point(\(x), \(y))" }
}

// 注入到 context
let context = JSContext()!
context.setObject(Point(x: 1, y: 2), forKeyedSubscript: "point" as NSString)
context.evaluateScript("point.x; point.description()")

6.3 与 Block 注入的对比

方式 适用场景
context[@"fn"] = ^(id arg){ ... } 单次或简单逻辑,直接暴露为 JS 函数
JSExport 协议 + 原生对象 需要暴露多个方法/属性、保持对象身份与状态的「桥接对象」

七、JSManagedValue 与内存管理

7.1 为何需要 JSManagedValue

  • JSValueJSContext强引用JSContext 又挂在 JSVirtualMachine 上。
  • 若在 堆上的 OC 对象(如某 ViewController 的 property)中直接强引用 JSValue,而该 JSValue 通过某种方式(例如被注入到 context 的全局对象)又引用回该 OC 对象,会形成 OC ↔ JS 的循环引用,导致 Context 与 OC 对象均无法释放 [1][2]。

7.2 JSManagedValue 的职责

JSManagedValueJSValue 的包装类,用于在「被 OC 堆对象持有」的场景下,以条件保留的方式引用 JS 值,并可与 JSVirtualMachineaddManagedReference:withOwner: / removeManagedReference:withOwner: 配合,让虚拟机在合适的时机断开或保留对 native 对象的引用,从而打破循环、避免 JSContext 无法释放 [1][2]。

7.3 使用要点(概念)

  • 当需要把 JSValue(或从 JS 传回的函数/对象)存为 OC 对象的成员变量时,应使用 JSManagedValue 包装,并以 owner 注册到 JSVirtualMachine;在 owner 析构或不再需要时调用 removeManagedReference:withOwner: [1][2]。
  • 仅临时在栈上使用 JSValue(如 evaluateScript 的返回值在方法内使用后不再持有)时,一般无需 JSManagedValue。

八、关键概念图示与流程

8.1 VM、Context、Value 关系

flowchart TB
  subgraph VM1[JSVirtualMachine 1]
    C1[JSContext 1]
    C2[JSContext 2]
  end
  subgraph VM2[JSVirtualMachine 2]
    C3[JSContext 3]
  end
  C1 --> V1[JSValue]
  C2 --> V2[JSValue]
  C1 -.->|可传值| C2
  C1 -.->|不可跨 VM| C3

同一 JSVirtualMachine 下多个 JSContext 可共享、传递 JSValue;不同 VM 之间不能传递 JSValue [3]。

8.2 JavaScriptCore 引擎执行层级(概念)

源码经 Lexer → Parser 得到语法树并生成字节码后,由下至上的执行/编译层级可概括为:

flowchart LR
  A[源码] --> B[Lexer]
  B --> C[Parser / AST]
  C --> D[字节码]
  D --> E[LLInt 解释器]
  E --> F[Baseline JIT]
  F --> G[DFG JIT]
  G --> H[FTL JIT]
  • LLInt:低级解释器,低延迟启动。
  • Baseline JIT:首次 JIT,兼顾分析与回退。
  • DFG:基于数据流的优化 JIT。
  • FTL:更高优化层(历史上曾用 LLVM/B3 后端)[4][5]。

更多实现细节见 WebKit JavaScriptCore Wiki


九、应用场景与最佳实践

9.1 典型应用场景

场景 说明
Hybrid 应用 在 App 内执行 JS 脚本、调用原生能力(如弹窗、定位、支付),JavaScriptCore 提供 OC/Swift 与 JS 的双向桥接 [1][2]
React Native / 类 RN 方案 早期 RN 等方案在 iOS 上依赖 JSC 执行 JS bundle;JSC 提供 VM、Context、Value 等能力 [3]
JSPatch 等热修复 通过下发 JS 脚本并在 JSC 中执行,动态调用原生类与方法,实现热更新(需注意安全与审核政策)[3]
WKWebView 与 Web 页面 WKWebView 内部使用系统 WebKit,其 JS 引擎与 Safari 一致;独立使用 JSC 时无需 WebView 即可执行 JS [1][2]
规则引擎 / 脚本配置 将业务规则或配置写成 JS,由原生在 JSC 中执行并取结果,便于迭代与 A/B 测试

9.2 最佳实践要点

  • 线程:同一 VM 下多线程会串行等待;需并发执行 JS 时使用多个 JSVirtualMachine [3]。
  • 异常:设置 context.exceptionHandler,在 JS 抛错时记录或上报,避免静默失败 [3]。
  • 内存:在 OC 堆对象中持有 JS 值时使用 JSManagedValue + add/removeManagedReference,避免循环引用 [1][2]。
  • 安全:执行来自网络或不可信来源的 JS 时,需做沙箱与权限控制;避免将敏感 API 无限制暴露给 JS [3]。

十、伪代码与算法说明

10.1 执行脚本并取返回值(概念)

function evaluateScript(script: String) -> JSValue:
  parse script -> AST
  generate bytecode from AST
  execute bytecode (via LLInt or JIT tier)
  return last expression value as JSValue

10.2 将 Native 对象注入 Context(概念)

function setObject(object: Any, forKey key: String):
  if object is Block or conforms to JSExport:
    create JS wrapper (function or object with exported properties/methods)
  else:
    create generic wrapper preserving native type hierarchy
  set wrapper on context.globalObject[key]

10.3 JS 调用 Native Block 时(概念)

JavaScript 侧,调用通过 context[@"key"] 注入的 Block,与调用普通函数一致:

// 假设 Native 已注入:context["makeColor"] = ^(NSDictionary *rgb) { ... }
var color = makeColor({ red: 12, green: 23, blue: 67 });

底层流程(伪代码):

当 JS 调用 context 中注册的 Block 时:
  1. JSC 将 JS 参数按类型转换为 OC 对象(NSNumber/NSString/NSDictionary/NSArray 等)
  2. 调用 Block,传入转换后的参数
  3. 将 Block 返回值按类型转换为 JSValue 并返回给 JS

参考文献

[1] Apple. JavaScriptCore Framework. iOS / macOS Developer Documentation.
[2] 掘金 / 博客. iOS 与 JS 交互开发知识总结JavaScriptCore 初探 等.
[3] 本文原稿与常见 JSC 教程(JSVirtualMachine、JSContext、JSValue、并发与内存).
[4] WebKit. Introducing the WebKit FTL JIT. webkit.org/blog/3362/i…
[5] WebKit. JavaScriptCore - Deep Dive. docs.webkit.org/Deep%20Dive…
[6] trac.webkit.org. JavaScriptCore. trac.webkit.org/wiki/JavaSc…
[7] 美团技术团队. 深入理解 JSCore. blog.csdn.net/MeituanTech…

❌
❌