阅读视图

发现新文章,点击刷新页面。

iOS疑难Crash-_dispatch_barrier_waiter_redirect_or_wake 崩溃治理

一. 背景

我们司机端App一直存在着_dispatch_barrier_waiter_redirect_or_wake相关的崩溃。

该崩溃具体崩溃堆栈如下:

// remark

Exception Type:  EXC_BREAKPOINT (SIGTRAP)
Exception Codes: KERN_INVALID_ADDRESS at 0x00000001b0604ae0
Crashed Thread:  34


// 崩溃线程
Thread 34 Crashed:
0      libdispatch.dylib                     __dispatch_barrier_waiter_redirect_or_wake + 256
1      libdispatch.dylib                     __dispatch_lane_invoke + 764
2      libdispatch.dylib                     __dispatch_workloop_worker_thread + 648
3      libsystem_pthread.dylib               __pthread_wqthread + 288


==========

而且该崩溃集中在14.0~16.2之间的系统。

二. 原因排查

因为崩溃类型是EXC_BREAKPOINT (SIGTRAP)类型, 并且发生在 libdispatch.dylib(即 GCD,Grand Central Dispatch)内部函数中。

也就是说这是GCD内部检测到严重错误时主动产生的崩溃。

SIGTRAP崩溃类型主要是由于系统或运行时(Runtime)为了阻止程序在一种不安全的错误状态下继续执行而主动触发的“陷阱”,目的是为了方便开发者调试,常见原因:

  1. Swift 运行时安全机制触发(最常见):

    1. 强制解包 nil 可选值(! 操作符):如 var value = nilOptional!
    2. 强制类型转换失败(as! 操作符):尝试将对象转换为不兼容类型。
    3. Swift 检测到数组越界、整数溢出等内存安全问题时会主动触发 EXC_BREAKPOINT/SIGTRAP
  2. 底层库的不可恢复错误:

  • GCDlibdispatch)等系统库在检测到内部状态异常(如队列死锁、资源竞争)时可能触发 SIGTRAP,比如dispatch_group_tenter/leave不匹配。

因为崩溃堆栈崩溃在__dispatch_barrier_waiter_redirect_or_wake + 256

0      libdispatch.dylib                     __dispatch_barrier_waiter_redirect_or_wake + 256
1      libdispatch.dylib                     __dispatch_lane_invoke + 764
2      libdispatch.dylib                     __dispatch_workloop_worker_thread + 648
3      libsystem_pthread.dylib               __pthread_wqthread + 288

因此我们找一台iOS14-iOS16系统的真机,然后运行在release环境,查看下256指令对应的代码。

libdispatch.dylib`_dispatch_barrier_waiter_redirect_or_wake:
->  0x10c4f49f0 <+0>:   pacibsp 
    0x10c4f49f4 <+4>:   stp    x24, x23, [sp, #-0x40]!
    0x10c4f49f8 <+8>:   stp    x22, x21, [sp, #0x10]
    0x10c4f49fc <+12>:  stp    x20, x19, [sp, #0x20]
    0x10c4f4a00 <+16>:  stp    x29, x30, [sp, #0x30]
    0x10c4f4a04 <+20>:  add    x29, sp, #0x30
    0x10c4f4a08 <+24>:  mov    x22, x4
    0x10c4f4a0c <+28>:  mov    x20, x3
    0x10c4f4a10 <+32>:  mov    x19, x1
    0x10c4f4a14 <+36>:  mov    x21, x0
    0x10c4f4a18 <+40>:  ldr    x8, [x1, #0x30]
    0x10c4f4a1c <+44>:  cmn    x8, #0x4
    0x10c4f4a20 <+48>:  b.ne   0x10c4f4a38               ; <+72>
    0x10c4f4a24 <+52>:  ldrb   w9, [x19, #0x69]
    0x10c4f4a28 <+56>:  ubfx   x8, x20, #32, #3
    0x10c4f4a2c <+60>:  cmp    w8, w9
    0x10c4f4a30 <+64>:  b.ls   0x10c4f4a38               ; <+72>
    0x10c4f4a34 <+68>:  strb   w8, [x19, #0x69]
    0x10c4f4a38 <+72>:  tbnz   x20, #0x25, 0x10c4f4a78   ; <+136>
    0x10c4f4a3c <+76>:  mvn    x8, x20
    0x10c4f4a40 <+80>:  tst    x8, #0x1800000000
    0x10c4f4a44 <+84>:  b.ne   0x10c4f4a6c               ; <+124>
    0x10c4f4a48 <+88>:  ubfx   x9, x20, #32, #3
    0x10c4f4a4c <+92>:  mrs    x8, TPIDRRO_EL0
    0x10c4f4a50 <+96>:  ldr    w10, [x8, #0xc8]
    0x10c4f4a54 <+100>: ubfx   w11, w10, #16, #4
    0x10c4f4a58 <+104>: cmp    w11, w9
    0x10c4f4a5c <+108>: b.hs   0x10c4f4a6c               ; <+124>
    0x10c4f4a60 <+112>: and    w10, w10, #0xfff0ffff
    0x10c4f4a64 <+116>: bfi    w10, w9, #16, #3
    0x10c4f4a68 <+120>: str    x10, [x8, #0xc8]
    0x10c4f4a6c <+124>: mov    x23, #-0x4                ; =-4 
    0x10c4f4a70 <+128>: tbnz   w2, #0x0, 0x10c4f4ad8     ; <+232>
    0x10c4f4a74 <+132>: b      0x10c4f4b68               ; <+376>
    0x10c4f4a78 <+136>: tbnz   w2, #0x0, 0x10c4f4ad0     ; <+224>
    0x10c4f4a7c <+140>: tbz    w20, #0x0, 0x10c4f4b64    ; <+372>
    0x10c4f4a80 <+144>: mov    x23, x21
    0x10c4f4a84 <+148>: tbnz   w22, #0x0, 0x10c4f4b68    ; <+376>
    0x10c4f4a88 <+152>: ldr    w8, [x21, #0x8]
    0x10c4f4a8c <+156>: mov    w9, #0x7fffffff           ; =2147483647 
    0x10c4f4a90 <+160>: cmp    w8, w9
    0x10c4f4a94 <+164>: b.eq   0x10c4f4b64               ; <+372>
    0x10c4f4a98 <+168>: add    x8, x21, #0x8
    0x10c4f4a9c <+172>: mov    w9, #-0x1                 ; =-1 
    0x10c4f4aa0 <+176>: ldaddl w9, w8, [x8]
    0x10c4f4aa4 <+180>: mov    x23, x21
    0x10c4f4aa8 <+184>: cmp    w8, #0x0
    0x10c4f4aac <+188>: b.gt   0x10c4f4b68               ; <+376>
    0x10c4f4ab0 <+192>: stp    x20, x21, [sp, #-0x10]!
    0x10c4f4ab4 <+196>: adrp   x20, 47
    0x10c4f4ab8 <+200>: add    x20, x20, #0x95e          ; "API MISUSE: Over-release of an object"
    0x10c4f4abc <+204>: adrp   x21, 81
    0x10c4f4ac0 <+208>: add    x21, x21, #0x260          ; gCRAnnotations
    0x10c4f4ac4 <+212>: str    x20, [x21, #0x8]
    0x10c4f4ac8 <+216>: ldp    x20, x21, [sp], #0x10
    0x10c4f4acc <+220>: brk    #0x1
    0x10c4f4ad0 <+224>: mov    x23, x21
    0x10c4f4ad4 <+228>: tbnz   w22, #0x0, 0x10c4f4b1c    ; <+300>
    0x10c4f4ad8 <+232>: ldr    w8, [x21, #0x8]
    0x10c4f4adc <+236>: mov    w9, #0x7fffffff           ; =2147483647 
    0x10c4f4ae0 <+240>: cmp    w8, w9
    0x10c4f4ae4 <+244>: b.eq   0x10c4f4b68               ; <+376>
    0x10c4f4ae8 <+248>: add    x8, x21, #0x8
    0x10c4f4aec <+252>: mov    w9, #-0x2                 ; =-2 
    0x10c4f4af0 <+256>: ldaddl w9, w8, [x8]
    0x10c4f4af4 <+260>: cmp    w8, #0x1
    0x10c4f4af8 <+264>: b.gt   0x10c4f4b68               ; <+376>
    0x10c4f4afc <+268>: stp    x20, x21, [sp, #-0x10]!
    0x10c4f4b00 <+272>: adrp   x20, 47
    0x10c4f4b04 <+276>: add    x20, x20, #0x95e          ; "API MISUSE: Over-release of an object"
    0x10c4f4b08 <+280>: adrp   x21, 81
    0x10c4f4b0c <+284>: add    x21, x21, #0x260          ; gCRAnnotations
    0x10c4f4b10 <+288>: str    x20, [x21, #0x8]
    0x10c4f4b14 <+292>: ldp    x20, x21, [sp], #0x10

我们结合iOS14-iOS16对应的libdispatch源码里面_dispatch_barrier_waiter_redirect_or_wake函数

DISPATCH_NOINLINE
static void
_dispatch_barrier_waiter_redirect_or_wake(dispatch_queue_class_t dqu,
                dispatch_object_t dc, dispatch_wakeup_flags_t flags,
                uint64_t old_state, uint64_t new_state)
{
        dispatch_sync_context_t dsc = (dispatch_sync_context_t)dc._dc;
        dispatch_queue_t dq = dqu._dq;
        dispatch_wlh_t wlh = DISPATCH_WLH_ANON;

        if (dsc->dc_data == DISPATCH_WLH_ANON) {
                if (dsc->dsc_override_qos < _dq_state_max_qos(old_state)) {
                        dsc->dsc_override_qos = (uint8_t)_dq_state_max_qos(old_state);
                }
        }

        if (_dq_state_is_base_wlh(old_state)) {
                wlh = (dispatch_wlh_t)dq;
        } else if (_dq_state_received_override(old_state)) {
                // Ensure that the root queue sees that this thread was overridden.
                _dispatch_set_basepri_override_qos(_dq_state_max_qos(old_state));
        }

        if (flags & DISPATCH_WAKEUP_CONSUME_2) {
                if (_dq_state_is_base_wlh(old_state) &&
                                _dq_state_is_enqueued_on_target(new_state)) {
                        // If the thread request still exists, we need to leave it a +1
                        _dispatch_release_no_dispose(dq);
                } else {
                        _dispatch_release_2_no_dispose(dq);
                }
        } else if (_dq_state_is_base_wlh(old_state) &&
                        _dq_state_is_enqueued_on_target(old_state) &&
                        !_dq_state_is_enqueued_on_target(new_state)) {
                // If we cleared the enqueued bit, we're about to destroy the workloop
                // thread request, and we need to consume its +1.
                _dispatch_release_no_dispose(dq);
        }

        //
        // Past this point we are borrowing the reference of the sync waiter
        //
        if (unlikely(_dq_state_is_inner_queue(old_state))) {
                dispatch_queue_t tq = dq->do_targetq;
                if (dsc->dc_flags & DC_FLAG_ASYNC_AND_WAIT) {
                        _dispatch_async_waiter_update(dsc, dq);
                }
                if (likely(tq->dq_width == 1)) {
                        dsc->dc_flags |= DC_FLAG_BARRIER;
                } else {
                        dispatch_lane_t dl = upcast(tq)._dl;
                        dsc->dc_flags &= ~DC_FLAG_BARRIER;
                        if (_dispatch_queue_try_reserve_sync_width(dl)) {
                                return _dispatch_non_barrier_waiter_redirect_or_wake(dl, dc);
                        }
                }
                // passing the QoS of `dq` helps pushing on low priority waiters with
                // legacy workloops.
#if DISPATCH_INTROSPECTION
                dsc->dsc_from_async = false;
#endif
                return dx_push(tq, dsc, _dq_state_max_qos(old_state));
        }

        if (dsc->dc_flags & DC_FLAG_ASYNC_AND_WAIT) {
                // _dispatch_async_and_wait_f_slow() expects dc_other to be the
                // bottom queue of the graph
                dsc->dc_other = dq;
        }
#if DISPATCH_INTROSPECTION
        if (dsc->dsc_from_async) {
                _dispatch_trace_runtime_event(async_sync_handoff, dq, 0);
        } else {
                _dispatch_trace_runtime_event(sync_sync_handoff, dq, 0);
        }
#endif // DISPATCH_INTROSPECTION
        return _dispatch_waiter_wake(dsc, wlh, old_state, new_state);
}
static inline void
_dispatch_release_2_no_dispose(dispatch_object_t dou)
{
        _os_object_release_internal_n_no_dispose_inline(dou._os_obj, 2);
}
DISPATCH_ALWAYS_INLINE
static inline void
_os_object_release_internal_n_no_dispose_inline(_os_object_t obj, int n)
{
        int ref_cnt = _os_object_refcnt_sub(obj, n);
        if (likely(ref_cnt >= 0)) {
                return;
        }
        _OS_OBJECT_CLIENT_CRASH("Over-release of an object");
}
#define _os_object_refcnt_sub(o, n) \
                _os_atomic_refcnt_sub2o(o, os_obj_ref_cnt, n)
#define _os_atomic_refcnt_sub2o(o, m, n) \
                _os_atomic_refcnt_perform2o(o, m, sub, n, release)
#define _os_atomic_refcnt_perform2o(o, f, op, n, m)   ({ \
                typeof(o) _o = (o); \
                int _ref_cnt = _o->f; \
                if (likely(_ref_cnt != _OS_OBJECT_GLOBAL_REFCNT)) { \
                        _ref_cnt = os_atomic_##op##2o(_o, f, n, m); \
                } \
                _ref_cnt; \
        })

分析256汇编指令的前后的逻辑

0x10c4f4ad8 <+232>: ldr w8, [x21, #0x8] ; 从x21+0x8处加载一个32位值到w8(即读取引用计数值)

0x10c4f4adc <+236>: mov w9, #0x7fffffff ; 将最大值0x7FFFFFFF放入w9

0x10c4f4ae0 <+240>: cmp w8, w9 ; 比较引用计数值是否等于0x7FFFFFFF

0x10c4f4ae4 <+244>: b.eq 0x10c4f4b68 ; 如果相等,跳转到正常退出(说明是全局引用计数,不需要减)

0x10c4f4ae8 <+248>: add x8, x21, #0x8 ; 将x21+8的地址存入x8(引用计数字段地址)

0x10c4f4aec <+252>: mov w9, #-0x2 ; 将-2存入w9

0x10c4f4af0 <+256>: ldaddl w9, w8, [x8] ; 原子操作:将[x8]的值加上w9(即减2),并将操作前的值存入w8

0x10c4f4af4 <+260>: cmp w8, #0x1 ; 比较操作前的值(w8)是否大于1

0x10c4f4af8 <+264>: b.gt 0x10c4f4b68 ; 如果大于1,跳转到正常退出

0x10c4f4afc <+268>: ... 后续是处理引用计数不足的情况,会触发崩溃

注意,在 ldaddl 指令之前,有两条指令:

  • ldr w8, [x21, #0x8]:从 x21+8 处读取值。这里 x21 指向的是 obj(即队列对象),而 os_obj_ref_cnt 字段在对象结构体中的偏移量是8(因为对象结构体第一个字段是isa指针,占8字节,第二个字段就是引用计数)。所以这个读取是正常的。
  • 然后检查这个值是否为 0x7fffffff(全局引用计数),如果是,则跳过减操作。

如果引用计数不是全局引用计数,则计算引用计数字段地址(x21+8)x8,然后执行原子减操作。

崩溃发生在 ldaddl 指令,说明在访问 x8 指向的内存时出现了问题。而 x8 的值是 x21+8,所以问题可能在于 x21 指向的对象已经无效。

因此,最可能的情况是:x21 指向的队列对象(dq)已经被释放了,所以它指向的内存地址无效。

那为什么这个崩溃只出现在iOS14.0~16.2之间的系统?

这个问题我并没有找到确定的答案,在苹果开发者论坛以及相关资料也没有找到相关的讨论帖子

以下只是我针对我所掌握资料的一些原因推测。

因为iOS14.0~16.2系统发布的时间大概2020年 - 2022年之间,因此我找了下这期间libdispatch版本libdispatch-1271.100.5(2021年发布)

libdispatch版本: github.com/apple-oss-d…

来跟iOS16.2之后的版本libdispatch-1462.0.4,也是2023年发布的版本做对比。

通过分析__dispatch_barrier_waiter_redirect_or_wake函数实现以及256指令对应的汇编代码指向的是引用计数函数减2的函数,我们可以确定256指令对应函数代码实现是_dispatch_release_2_no_dispose

然后将_dispatch_release_2_no_dispose相关的上下游函数做对比,发现最有可能的根本原因:

iOS14.0~16.2之间引用计数器操作的方法,存在多线程操作情况,导致引用计数器提前为0,导致队列提前释放问题。具体表现为:

  • 引用技术的读取是非原子操作,不是线程安全的,只有操作(加减)是线程安全的,
  • 这样当多个线程同时操作同一个对象的引用计数时,可能出现数据竞争
  • 比如在多线程环境中,线程 A 正在更新引用计数,比如将引用计数加1,线程 B 同时读取,B 可能读到一个不完整的中间值,比如最开始引用计数是5,线程A更新后应该为6,线程B正确读取到的应该是6,但由于读操作的同时,线程A的更新操作还没结束,导致读取到的还是5.
  • 这样引用计数就会存在不准确问题,导致了有可能引用计数提前为0,导致队列被提前释放。
#define _os_atomic_refcnt_perform2o(o, f, op, n, m)   ({ \
                __typeof__(o) _o = (o); \
                int _ref_cnt = _o->f; \
                if (likely(_ref_cnt != _OS_OBJECT_GLOBAL_REFCNT)) { \
                        _ref_cnt = os_atomic_##op##2o(_o, f, n, m); \
                } \
                _ref_cnt; \
        })

我们看下测试的代码就很直观

而在libdispatch-1462.0.4版本里面,引用计数的操作就保证了读取和写入都是原子性,都是线程安全的。

#define _os_atomic_refcnt_perform(o, op, n, m)   ({ \
                int _ref_cnt = os_atomic_load(o, relaxed); \
                if (likely(_ref_cnt != _OS_OBJECT_GLOBAL_REFCNT)) { \
                        _ref_cnt = os_atomic_##op(o, n, m); \
                } \
                _ref_cnt; \
        })

因此也就保证了队列不会被提前释放。

我们比对了所有版本的libdispatch源码,发现引用计数操作的读取和写入都是原子性的,最早是在libdispatch-1462.0.4这个版本改的,但libdispatch-1462.0.4的发布时间已经是2023.09, 这时候对应的系统版本应该已经是iOS17,但考虑到libdispatch源码的公布,一般都是晚于iOS系统版本,等系统版本稳定后,才发布,因此可以合理的猜测其实苹果在iOS16.3系统版本里面就修复了这个引用计数操作问题。

以上我们推测了为什么这个崩溃会发生在iOS14.0~16.2系统,以及引起崩溃的可能原因是因为队列被提前释放了。

__dispatch_barrier_waiter_redirect_or_wake则表明这个崩溃发生在 GCDbarrier 同步机制 中,具体是系统在处理 dispatch_barrier_asyncdispatch_barrier_sync 时,尝试唤醒等待 barrier 的线程,但访问了已经释放的队列对象。

因此我们将目标锁定在 GCD 处理 barrier(栅栏)任务的方法调用上,也就是dispatch_barrier_syncqueue.sync(flags: .barrier)或者dispatch_barrier_asyncqueue.async(flags: .barrier)的调用上。

排查发现项目中有太多的地方调用到队列的栅栏函数,无论是二方、三方库、还是业务侧都有挺多地方调用到。

因为二方、三方库,除了我们业务线外,还有公司内其他业务线也在使用,因此咨询了其他业务线的iOS开发人员,发现他们并没有类似的崩溃。那也就是说二方、三方库引起的概率很小,很大概率是我们业务侧对barrier(栅栏)方法的使用引起的,因此我们也缩小了排查范围。

既然目标是业务侧对barrier(栅栏)方法的使用,经排查我们发现,业务侧定义了很多安全类比如安全字典、安全数组等,里面都是通过并行队列的同步读和栅栏函数写来实现读写锁的逻辑。

类似逻辑如下:

虽然我们观察代码的相关逻辑,发现这些安全类的读写锁逻辑,从代码结构来说确实没什么问题。但因为这些类在业务侧中有着非常广泛的使用,有作为对象的变量,也有作为局部变量,而且很多变量都是在多线程情况下生成和释放等操作,因此怀疑很有可能是这些安全类里面的并行队列的读写锁逻辑,导致系统内部由于引用技术操作的非原子性,致使并行队列引用计数为0,提前被释放,访问到无效内存地址崩溃。

三. 解决方案

既然原因锁定在这些安全类里面的通过并行队列来实现的读写锁逻辑,那最好的解决方案就是替换掉这些安全类里面的读写锁逻辑,使用pthread_rwlock_t来代替并行队列实现读写锁功能, 这样就避免了队列提前释放的风险。

读写锁pthread_rwlock_t其内部可能包含如下组件:

  • 互斥锁(Mutex) :用于保护读写锁的内部状态,如读计数器和写锁状态。
  • 读计数器(Read Counter) :记录当前持有读锁的线程数量。
  • 条件变量(Condition Variable) :用于实现线程的等待和通知机制。通常,会有两个条件变量,一个用于读线程,一个用于写线程。

当线程尝试获取读锁时,它会检查写锁状态和读计数器,如果当前没有写线程正在访问资源,则增加读计数器并允许读线程继续;如果存在写操作,则读线程将被阻塞,直到写操作完成。

类似地,当线程尝试获取写锁时,它会检查读计数器和写锁状态。如果当前没有读线程和写线程正在访问资源,则设置写锁状态并允许写线程继续;如果有读线程或写线程正在访问资源,则写线程将被阻塞,直到所有读线程和前一个写线程完成操作。

这个改动上线后,该崩溃得到了有效治理。

大家可以看到治理之前,该崩溃基本每天都有:

治理之后新版本没出现,只有旧版本偶现:

四. 总结

以上主要介绍了针对这个崩溃分析和治理过程的探索和思考,当然其中的原因并没有得到官方资料,只是自己排查的推测,如果大家有其他见解,欢迎留言讨论。

若本文有错误之处或者技术上关于其他类型Crash的讨论交流的,欢迎评论区留言。

鸿蒙激励的羊毛,你"薅"到了么?

背景 鸿蒙应用开发者激励计划2025,是由华为发起的开发者支持项目,旨在通过提供现金激励,鼓励开发者参与鸿蒙应用、游戏(含游戏App和小游戏,以下如无特指均使用“游戏”统一描述)、元服务的开发,以推动

DNS域名解析:从入门到优化必备基础

前言 在当今互联网世界,域名就像我们生活中的地址,而DNS(Domain Name System)就是那个将地址翻译成具体位置的神奇系统。无论你是前端开发者、移动端工程师还是运维人员,理解DNS的工作

iOS开发必备的HTTP网络基础概览

一、从一次HTTP请求说起 以下是一个大体过程,不包含DNS缓存等等细节: 上图展示了一个完整的HTTPS请求过程。对于iOS开发者,理解每个环节的工作原理至关重要,这有助于优化网络性能、解决连接问题

告别 GeometryReader:SwiftUI .visualEffect 实战解析

iOS 17 之后,SwiftUI 迎来了一个非常关键但又容易被低估的 API —— .visualEffect

如果你曾经在 ScrollView 里被 GeometryReader 折磨过,那这篇文章基本可以当作你的“解放指南”。


一、.visualEffect 是什么?

一句话概括:

.visualEffect 是一个“不参与布局、只在最终渲染阶段生效”的视觉修饰器

它允许你:

  • 拿到视图在屏幕中的真实几何位置
  • 根据滚动位置 / 距离中心点 / 是否即将离屏
  • 对视图做 缩放、模糊、位移、透明度 等视觉变化

👉 非常适合用来实现 系统级滚动动效


二、API 结构与核心签名

.visualEffect { content, geometryProxy in
    content
}

参数说明:

  • content:原始 View
  • geometryProxy:视图最终渲染后的几何信息
  • 返回值:一个新的 View(只影响视觉,不影响布局)

三、为什么它能“替代” GeometryReader?

1️⃣ 传统 GeometryReader 的问题

GeometryReader { geo in
    let y = geo.frame(in: .global).minY
    Text("Hello")
}
.frame(height: 100)

常见问题:

  • 参与布局,影响父视图尺寸
  • 在 ScrollView 中容易引发布局异常
  • 性能与可维护性都不理想

2️⃣ .visualEffect 的优势

对比项 GeometryReader visualEffect
是否参与布局 ✅ 是 ❌ 否
是否影响尺寸 不会
滚动性能 一般 很好
系统推荐

结论:能用 .visualEffect 的地方,基本不该再用 GeometryReader。


四、Demo 1:滚动缩放(最经典)

效果

  • Cell 越靠近屏幕中心 → 越大
  • 离中心越远 → 缩小

示例代码

ScrollView {
    VStack(spacing: 40) {
        ForEach(0..<20) { index in
            Text("Item (index)")
                .font(.largeTitle)
                .frame(maxWidth: .infinity)
                .padding()
                .background(.blue.opacity(0.2))
                .visualEffect { content, geo in
                    let minY = geo.frame(in: .global).minY
                    let scale = max(0.85, 1 - abs(minY) / 1000)

                    return content.scaleEffect(scale)
                }
        }
    }
}

五、Demo 2:滚动模糊(系统级质感)

使用场景

  • 卡片列表
  • 背景元素
  • 音乐 / 专辑封面
.visualEffect { content, geo in
    let y = geo.frame(in: .scrollView).minY
    let blur = min(abs(y) / 40, 12)

    return content.blur(radius: blur)
}

六、Demo 3:视差位移(Parallax)

.visualEffect { content, geo in
    let y = geo.frame(in: .global).minY

    content.offset(y: -y * 0.2)
}

常见于:

  • Banner
  • 大图 Header
  • Apple Music / App Store 首页

七、Demo 4:缩放 + 透明度(App Store 风格)

.visualEffect { content, geo in
    let frame = geo.frame(in: .scrollView)
    let distance = abs(frame.midY - 300)
    let scale = max(0.85, 1 - distance / 1000)

    return content
            .scaleEffect(scale)
            .opacity(scale)
}

八、.visualEffect vs .scrollTransition

.scrollTransition { content, phase in
    content.scaleEffect(phase.isIdentity ? 1 : 0.9)
}
对比项 visualEffect scrollTransition
几何自由度 ⭐⭐⭐⭐⭐ ⭐⭐
API 简洁度 ⭐⭐⭐ ⭐⭐⭐⭐⭐
是否仅限滚动

scrollTransition 是封装好的方案,visualEffect 是底层利器。


九、使用时的注意事项

1️⃣ 只做“视觉处理”

不要在 .visualEffect 中:

  • 修改状态
  • 触发网络请求
  • 写业务逻辑

2️⃣ 坐标空间要选对

.global        // 全局屏幕
.scrollView    // 推荐:滚动容器
.local         // 本地

👉 滚动相关效果,优先使用 .scrollView


十、总结

SwiftUI 布局完成 → .visualEffect 介入 → 最终视觉变换

你可以把它理解为:

“官方支持、不破坏布局的 GeometryReader + Transform”


如果你觉得这篇文章有帮助

欢迎 👍 / 收藏 / 评论交流

Swift 新并发框架之 async/await

1. 为什么需要 async/await 在移动开发里,“并发/异步”几乎无处不在:网络请求、图片下载、文件读写、数据库操作……它们都有一个共同特点: 耗时(如果你在主线程里死等,会卡 UI) 结果稍
❌