阅读视图

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

HarfBuzz 实战:五大核心API 实例详解【附iOS/Swift实战示例】

本文概述

本文是 HarfBuzz 系列的完结篇。

本文主要结合示例来讲解HarfBuzz中的核心API,不会面面俱到,只会介绍常用和重要的。

本文是HarfBuzz系列的第三篇,在阅读本文前,推荐先阅读以下两篇文章:

1)第一篇:HarfBuzz概览

2)第二篇:HarfBuzz核心概念

更多内容在公众号「非专业程序员Ping」,此外你可能还感兴趣:

一、hb-blob

1)定义

blob 是一个抽象概念,是对一段二进制数据的封装,一般用来承载字体数据,在HarfBuzz中用 hb_blob_t 结构体表示。

2)hb_blob_create

hb_blob_t 的构造方法,签名如下:表示从一段二进制数据(u8序列)中创建

hb_blob_t *
hb_blob_create (const char *data,
                unsigned int length,
                hb_memory_mode_t mode,
                void *user_data,
                hb_destroy_func_t destroy);
  • data:原始二进制数据,比如字体文件内容
  • length:二进制长度
  • mode:内存管理策略,即如何管理二进制数据,一般使用 HB_MEMORY_MODE_DUPLICATE 最安全,类型如下
模式 含义 优缺点
HB_MEMORY_MODE_DUPLICATE 复制模式,HarfBuzz会将传入的数据完整复制一份到私有内存 优点是不受传入的 data 生命周期影响缺点是多一次内存分配
HB_MEMORY_MODE_READONLY 只读模式,HarfBuzz会直接使用传入的数据,数据不会被修改 优点是无额外性能开销缺点是外部需要保证在 hb_blob_t 及其衍生的所有对象(如 hb_face_t)被销毁之前,始终保持有效且内容不变
HB_MEMORY_MODE_WRITABLE 可写模式,HarfBuzz会直接使用传入的指针,同时修改这块内存数据, 优点同READONLY缺点同READONLY,同时还可能修改数据
HB_MEMORY_MODE_READONLY_MAY_MAKE_WRITABLE 写时复制,HarfBuzz会直接使用传入的指针,在需要修改这块内存时才复制一份到私有内存 优点同READONLY缺点同READONLY,同时还可能修改数据
  • user_data:可以通过 user_data 携带一些上下文
  • destroy:blob释放时的回调

使用示例:

// 准备字体文件
let ctFont = UIFont.systemFont(ofSize: 18) as CTFont
let url = CTFontCopyAttribute(ctFont, kCTFontURLAttribute) as! URL
guard let fontData = try? Data(contentsOf: url) else {
    return
}
// 创建 HarfBuzz Blob 和 Face
// 'withUnsafeBytes' 确保指针在 'hb_blob_create' 调用期间是有效的。
// 'HB_MEMORY_MODE_DUPLICATE' 告诉 HarfBuzz 复制数据,这是在 Swift 中管理内存最安全的方式。
let blob = fontData.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> OpaquePointer? in
    let charPtr = ptr.baseAddress?.assumingMemoryBound(to: CChar.self)
    return hb_blob_create(charPtr, UInt32(fontData.count), HB_MEMORY_MODE_DUPLICATE, nil, nil)
}

3)hb_blob_create_from_file

hb_blob_t 的构造方法,签名如下:表示从文件路径创建

hb_blob_t *
hb_blob_create_from_file (const char *file_name);
  • file_name:文件绝对路径,注意非文件名

使用示例:

let ctFont = UIFont.systemFont(ofSize: 18) as CTFont
let url = CTFontCopyAttribute(ctFont, kCTFontURLAttribute) as! URL
let blob = url.path.withCString { ptr in
    hb_blob_create_from_file(ptr)
}

查看 hb_blob_create_from_file 函数实现,会通过 mmap 的方式来映射字体文件,可以共享系统的字体内存缓存,相比自己读取二进制数据来创建blob来说,这种方式会少一次IO,且内存占用也可能更小(复用系统内存缓存)。

二、hb-face

1)定义

face 表示一个单独的字体,它会解析blob中的二进制字体数据,通过face可以访问字体中的各种table,如GSUB、GPOS、cmap表等,在HarfBuzz中用 hb_face_t 结构体表示。

2)hb_face_create

hb_face_t的构造方法,签名如下:表示从一段字体二进制数据中构造face

hb_face_t *
hb_face_create (hb_blob_t *blob,
                unsigned int index);
  • blob:字体数据
  • index:有的字体文件是一个字体集合(ttc),index表示使用第几个字体数据来创建face;对于单字体文件(ttf)来说,index传0即可

关于字体更多知识可以参考:一文读懂字体文件

3)hb_face_reference

hb_face_t的引用计数 +1

hb_face_t *
hb_face_reference (hb_face_t *face);

3)hb_face_destroy

hb_face_t的引用计数 -1,注意不是直接销毁对象,在HarfBuzz中,所有对象类型都提供了特定的生命周期管理API(create、reference、destroy),对象采用引用计数方式管理生命周期,当引用计数为0时才会释放内存

void
hb_face_destroy (hb_face_t *face);

在实际使用时,需要注意调用顺序,需要保证所有从face创建出的对象销毁之后,再调用hb_face_destroy。

4)hb_face_get_upem

获取字体的upem。

unsigned int
hb_face_get_upem (const hb_face_t *face);

upem 即 unitsPerEm,在字体文件中一般存储在 head 表中,字体的upem通常很大(一般是1000或2048),其单位并不是像素值,而是 em unit,<unitsPerEm value="2048"/> 表示 2048 units = 1 em = 设计的字高,比如当字体在屏幕上以 16px 渲染时,1 em = 16px,其他数值可按比例换算。

5)hb_face_reference_table

从字体中获取原始的table数据,这个函数返回的是table数据的引用,而不是拷贝,所以这个函数几乎没有性能开销;如果对应 tag 的table不存在,会返回一个空的blob,可以通过 hb_blob_get_length 来检查获取是否成功。

hb_blob_t *
hb_face_reference_table (const hb_face_t *face,
                         hb_tag_t tag);

使用示例:

// 构造tag,这里是获取head表
let headTag = "head".withCString { ptr in
    hb_tag_from_string(ptr, -1)
}
let headBlob = hb_face_reference_table(face, headTag);
// 检查是否成功
if (hb_blob_get_length(headBlob) > 0) {
    // 获取原始数据指针并解析
    var length: UInt32 = 0
    let ptr = hb_blob_get_data(headBlob, &length);
    // ... 在这里执行自定义解析 ...
}
// 必须销毁返回的 blob!
hb_blob_destroy(headBlob);

6)hb_face_collect_unicodes

获取字体文件支持的所有Unicode,这个函数会遍历cmap表,收集cmap中定义的所有code point。

void
hb_face_collect_unicodes (hb_face_t *face,
                          hb_set_t *out);

可以用收集好的结果来判断字体文件是否支持某个字符,这在做字体回退时非常有用。

使用示例:

let set = hb_set_create()
hb_face_collect_unicodes(face, set)
var cp: UInt32 = 0
while hb_set_next(set, &cp) == 1 {
    print("code point: ", cp)
}
hb_set_destroy(set)

三、hb-font

1)定义

font 表示字体实例,可以在face的基础上,设置字号、缩放等feature来创建一个font,在HarfBuzz中用 hb_font_t 结构体表示。

2)hb_font_create & hb_font_reference & hb_font_destroy

hb_font_t 的创建、引用、销毁函数,整体同face对象一样,采用引用计数的方式管理生命周期。

3)hb_font_get_glyph_advance_for_direction

获取一个字形在指定方向上的默认前进量(advance)

void
hb_font_get_glyph_advance_for_direction
                               (hb_font_t *font,
                                hb_codepoint_t glyph,
                                hb_direction_t direction,
                                hb_position_t *x,
                                hb_position_t *y);
  • font:指定字体
  • glyph:目标字形
  • direction:指定方向,HB_DIRECTION_LTR/HB_DIRECTION_LTR/HB_DIRECTION_TTB/HB_DIRECTION_BTT
  • x:返回值,advance.x
  • y:返回值,advance.y

这个函数会从 hmtx(横向)或vmtx(纵向)表中读取advance。

一般情况下,我们不需要直接使用这个函数,这个函数是直接查表返回静态的默认前进量,但实际塑形时,一般还涉及kerning等调整,所以一般常用hb_shape()的返回值,hb_shape()返回的是包含字形上下文调整(如kerning)等的结果。

使用示例:

let glyph_A: hb_codepoint_t = 65
var x_adv: hb_position_t = 0
var y_adv: hb_position_t = 0
// 1. 获取 'A' 在水平方向上的前进位移
hb_font_get_glyph_advance_for_direction(font,
                                        glyph_A,
                                        HB_DIRECTION_LTR, // 水平方向
                                        &x_adv,
                                        &y_adv)

4)hb_font_set_ptem & hb_font_get_ptem

设置和获取字体大小(point size),ptem 即 points per Em,也就是 iOS 中的 point size

void
hb_font_set_ptem (hb_font_t *font,
                  float ptem);

这个函数是 hb_font_set_scale() 简易封装,在HarfBuzz内部,字体大小不是用 points 来存储的,而是用一个称为 scale 的 26.6 的整数格式来存储的。

使用示例:

// 设置字体大小为 18 pt
hb_font_set_ptem(myFont, 18.0f);

// 等价于
// 手动计算 scale
int32_t scale = (int32_t)(18.0f * 64); // scale = 1152
// 手动设置 scale
hb_font_set_scale(myFont, scale, scale);

Q:什么是 26.6 整数格式?

"26.6" 格式是一种定点数(Fixed-Point Number)表示法,用于将浮点数转换成整数存储和运算;在 HarfBuzz 中,这个格式用于 hb_position_t 类型(int32_t),用来表示所有的坐标和度量值(如字形位置、前进量等)。

26.6 表示将一个 32 位整数划分为:高26位用于存储整数部分(一个有符号的 25 位整数 + 1 个符号位)+ 低6位用于存储小数部分。

换算规则:2^6 = 64

  • 从「浮点数」转为「26.6 格式」:hb_position_t = (float_value * 64)
  • 从「26.6 格式」转回「浮点数」:float_value = hb_position_t / 64.0

那为什么不直接用整数呢,因为文本布局需要极高的精度,如果只用整数,那任何小于1的误差都会被忽略,在一行文本中累计下来,误差就很大了。

那为什么不直接用浮点数呢,因为整数比浮点数的运算快,且浮点数在不同平台上存储和计算产生的误差还确定。

因此为了兼顾性能和精确,将浮点数「放大」成整数参与计算。

5)hb_font_get_glyph

用于查询指定 unicode 在字体中的有效字形(glyph),这在做字体回退时非常有用。

hb_bool_t
hb_font_get_glyph (hb_font_t *font,
                   hb_codepoint_t unicode,
                   hb_codepoint_t variation_selector,
                   hb_codepoint_t *glyph);
  • 返回值 hb_bool_t:true 表示成功,glyph 被设置有效字形,false 表示失败,即字体不支持该 unicode
  • font:字体
  • unicode:待查询 unicode
  • variation_selector:变体选择符的code point,比如在 CJK 中日韩表意文字中,一个汉字可能有不同的字形(如下图),一个字体可能包含这些所有的变体,那我们可以通过 variation_selector 指定要查询哪个变体;如果只想获取默认字形,那该参数可传 0

在这里插入图片描述

  • glyph:返回值,用于存储 unicode 对应字形

当然,还有与之对应的批量查询的函数:hb_font_get_nominal_glyphs

四、hb-buffer

1)定义

buffer 在HarfBuzz中表示输入输出的缓冲区,用 hb_buffer_t 结构体表示,一般用于存储塑形函数的输入和塑形结束的输出。

2)hb_buffer_create & hb_buffer_reference & hb_buffer_destroy

hb_buffer_t 的创建、引用、销毁函数,整体同face对象一样,采用引用计数的方式管理生命周期。

3)hb_buffer_add_utf8 & hb_buffer_add_utf16 & hb_buffer_add_utf32

将字符串添加到buffer,使用哪个函数取决于字符串编码方式。

void
hb_buffer_add_utf8 (hb_buffer_t *buffer,
                    const char *text,
                    int text_length,
                    unsigned int item_offset,
                    int item_length);
  • buffer:目标buffer
  • text:文本
  • text_length:文本长度,传 -1 会自动查找到字符串末尾的 \0
  • item_offset:偏移量,0 表示从头开始
  • item_length:添加长度,-1 表示全部长度

使用示例:

let buffer = hb_buffer_create()
let text = "Hello World!"
let cText = text.cString(using: .utf8)!
hb_buffer_add_utf8(buffer, cText, -1, 0, -1)

4)hb_buffer_guess_segment_properties

猜测并设置buffer的塑形属性(script、language、direction等)。

void
hb_buffer_guess_segment_properties (hb_buffer_t *buffer);

这个函数一般取第一个字符的属性作为整体buffer的属性,所以如果要使用这个函数来猜测属性的话,需要保证字符串已经被提前分段。

当然也可以手动调用hb_buffer_set_script、hb_buffer_set_language 等来手动设置。

五、hb-shape

hb_shape是HarfBuzz的核心塑形函数,签名如下:

void
hb_shape (hb_font_t *font,
          hb_buffer_t *buffer,
          const hb_feature_t *features,
          unsigned int num_features);
  • font:用于塑形的字体实例,需要提前设置好字体大小等属性
  • buffer:既是输入,待塑形的字符串会通过buffer传入;也是输出,塑形完成后,塑形结果会通过buffer返回
  • features:feature数组,用于启用或禁用字体中的某些特性,不需要的话可以传nil
  • num_features:上一参数features数组的数量

hb_shape 会执行一系列复杂操作,比如:

  • 字符到字形映射:查询cmap表,将字符转换为字形
  • 字形替换:查询 GSUB 表,进行连字替换、上下文替换等
  • 字形定位:查询 GPOS 表,微调每个字形的位置,比如kerning,标记定位,草书连接等

详细的塑形操作可以参考HarfBuzz核心概念

下面重点介绍塑形结果,可以通过 hb_buffer_get_glyph_infos 和 hb_buffer_get_glyph_positions 从buffer中获取塑形结果。

hb_buffer_get_glyph_infos 签名如下:

// hb_buffer_get_glyph_infos
hb_glyph_info_t *
hb_buffer_get_glyph_infos (hb_buffer_t *buffer,
                           unsigned int *length);

typedef struct {
  hb_codepoint_t codepoint;
  uint32_t       cluster;
} hb_glyph_info_t;

hb_buffer_get_glyph_infos 返回一个 hb_glyph_info_t 数组,用于获取字形信息,hb_glyph_info_t 中有两个重要参数:

  • codepoint:glyphID,注意这里不是 unicode 码点
  • cluster:映射回原始字符串的字节索引

这里需要展开介绍下cluster:

  • 在连字 (多对一)情况下:比如 "f" 和 "i" (假设在索引 0 和 1) 被塑形为一个 "fi" 字形。这个 "fi" 字形的 cluster 值会是 0(即它所代表的第一个字符的索引)
  • 拆分 (一对多)情况下:在某些语言中,一个字符可能被拆分为两个字形,这两个字形都会有相同的 cluster 值,都指向那个原始字符
  • 高亮与光标:当我们需要高亮显示原始文本的第 3 到第 5 个字符时,就是通过 cluster 值来查找所有 cluster 在 3 和 5 之间的字形,然后绘制它们的选区

hb_buffer_get_glyph_positions 的签名如下:

hb_glyph_position_t *
hb_buffer_get_glyph_positions (hb_buffer_t *buffer,
                               unsigned int *length);
                               
typedef struct {
  hb_position_t  x_advance;
  hb_position_t  y_advance;
  hb_position_t  x_offset;
  hb_position_t  y_offset;
} hb_glyph_position_t;

hb_buffer_get_glyph_positions 返回一个 hb_glyph_position_t 的数组,用于获取字形的位置信息,hb_glyph_position_t 参数有:

  • x_advance / y_advance:x / y 方向的前进量;前进量指的是绘制完一个字形后,光标应该移动多远继续绘制下一个字形;对于横向排版而言,y_advance 一般是0;需要注意的是 advance 值中已经包含了 kernig 的计算结果
  • x_offset / y_offset:x / y 方向的绘制偏移,对于带重音符的字符如 é 来说,塑形时可能拆分成 e + ´,重音符 ´ 塑形结果往往会带 offset,以保证绘制在 e 的上方

position主要在排版/绘制时使用,以绘制为例,通常用法如下:

// (x, y) 是“笔尖”或“光标”位置
var current_x: Double = 0.0 
var current_y: Double = 0.0

// 获取塑形结果
var glyphCount: UInt32 = 0
let infos = hb_buffer_get_glyph_infos(buffer, &glyphCount)
let positions = hb_buffer_get_glyph_positions(buffer, &glyphCount)

// 遍历所有输出的字形
for i in 0..<Int(glyphCount) {
    let info = infos[i]
    let pos = positions[i]

    // 1. 计算这个字形的绘制位置 (Draw Position)
    //    = 当前光标位置 + 本字形的偏移
    let draw_x = current_x + (Double(pos.x_offset) / 64.0)
    let draw_y = current_y + (Double(pos.y_offset) / 64.0)

    // 2. 在该位置绘制字形
    //    (info.codepoint 就是字形 ID)
    drawGlyph(glyphID: info.codepoint, x: draw_x, y: draw_y)

    // 3. 将光标移动到下一个字形的起点
    //    = 当前光标位置 + 本字形的前进位移
    current_x += (Double(pos.x_advance) / 64.0)
    current_y += (Double(pos.y_advance) / 64.0)
}

六、完整示例

下面我们以 Swift 中调用 HarfBuzz 塑形一段文本为例:

func shapeTextExample() {
    // 1. 准备字体
    let ctFont = UIFont.systemFont(ofSize: 18) as CTFont
    let url = CTFontCopyAttribute(ctFont, kCTFontURLAttribute) as! URL

    // 2. 从字体文件路径创建blob
    let blob = url.path.withCString { ptr in
        hb_blob_create_from_file(ptr)
    }

    guard let face = hb_face_create(blob, 0) else { // 0 是字体索引 (TTC/OTF collections)
        print("无法创建 HarfBuzz face。")
        hb_blob_destroy(blob) // 即使失败也要清理
        return
    }

    // Blob 已经被 face 引用,现在可以安全销毁
    hb_blob_destroy(blob)

    // --- 3. 创建 HarfBuzz 字体对象 ---
    guard let font = hb_font_create(face) else {
        print("无法创建 HarfBuzz font。")
        hb_face_destroy(face)
        return
    }

    // 告诉 HarfBuzz 使用其内置的 OpenType 函数来获取字形等信息
    // 这对于 OpenType 字体(.otf, .ttf)是必需的
    hb_ot_font_set_funcs(font)

    hb_font_set_synthetic_slant(font, 1.0)

    // 设置字体大小 (例如 100pt)。
    // HarfBuzz 内部使用 26.6 整数坐标系,即 1 单位 = 1/64 像素。
    let points: Int32 = 100
    let scale = points * 64
    hb_font_set_scale(font, scale, scale)

    // --- 4. 创建 HarfBuzz 缓冲区 ---
    guard let buffer = hb_buffer_create() else {
        print("无法创建 HarfBuzz buffer。")
        hb_font_destroy(font)
        hb_face_destroy(face)
        return
    }

    // --- 5. 添加文本到缓冲区 ---
    let text = "Hello World!"
    let cText = text.cString(using: .utf8)!

    // hb_buffer_add_utf8:
    // - buffer: 缓冲区
    // - cText: UTF-8 字符串指针
    // - -1: 字符串长度 (传 -1 表示自动计算直到 null 终止符)
    // - 0: item_offset (从字符串开头)
    // - -1: item_length (处理整个字符串)
    hb_buffer_add_utf8(buffer, cText, -1, 0, -1)

    // 猜测文本属性 (语言、文字方向、脚本)
    // 这对于阿拉伯语 (RTL - 从右到左) 至关重要!
    hb_buffer_guess_segment_properties(buffer)

    // --- 6. 执行塑形 (Shape!) ---
    // 使用 nil 特征 (features),表示使用字体的默认 OpenType 特征
    hb_shape(font, buffer, nil, 0)

    // --- 7. 获取塑形结果 ---
    var glyphCount: UInt32 = 0
    // 获取字形信息 (glyph_info)
    let glyphInfoPtr = hb_buffer_get_glyph_infos(buffer, &glyphCount)
    // 获取字形位置 (glyph_position)
    let glyphPosPtr = hb_buffer_get_glyph_positions(buffer, &glyphCount)

    guard glyphCount > 0, let glyphInfo = glyphInfoPtr, let glyphPos = glyphPosPtr else {
        print("塑形失败或没有返回字形。")
        // 清理
        hb_buffer_destroy(buffer)
        hb_font_destroy(font)
        hb_face_destroy(face)
        return
    }

    print("\n--- 塑形结果 for '\(text)' (\(glyphCount) glyphs) ---")

    // --- 8. 遍历并打印结果 ---
    // 'cluster' 字段将字形映射回原始 UTF-8 字符串中的字节索引。
    // 这对于高亮显示、光标定位等非常重要。
    var currentX: Int32 = 0
    var currentY: Int32 = 0

    // 注意:阿拉伯语是从右到左 (RTL) 的。
    // hb_buffer_get_direction(buffer) 会返回 HB_DIRECTION_RTL。
    // HarfBuzz 会自动处理布局,所以我们只需按顺序迭代字形。

    for i in 0..<Int(glyphCount) {
        let info = glyphInfo[i]
        let pos = glyphPos[i]

        let glyphID = info.codepoint // 这是字形 ID (不是 Unicode 码点!)
        let cluster = info.cluster  // 映射回原始字符串的字节索引

        let x_adv = pos.x_advance   // X 轴前进
        let y_adv = pos.y_advance   // Y 轴前进
        let x_off = pos.x_offset    // X 轴偏移 (绘制位置)
        let y_off = pos.y_offset    // Y 轴偏移 (绘制位置)

        print("Glyph[\(i)]: ID=\(glyphID)")
        print("  Cluster (string index): \(cluster)")
        print("  Advance: (x=\(Double(x_adv) / 64.0), y=\(Double(y_adv) / 64.0)) pt") // 除以 64 转回 pt
        print("  Offset:  (x=\(Double(x_off) / 64.0), y=\(Double(y_off) / 64.0)) pt")
        print("  Cursor pos before draw: (x=\(Double((currentX + x_off)) / 64.0), y=\(Double((currentY + y_off)) / 64.0)) pt")

        // 累加光标位置
        currentX += x_adv
        currentY += y_adv
    }

    print("------------------------------------------")
    print("Total Advance: (x=\(currentX / 64), y=\(currentY / 64)) pt")

    // --- 9. 清理所有 HarfBuzz 对象 ---
    // 按照创建的相反顺序销毁
    hb_buffer_destroy(buffer)
    hb_font_destroy(font)
    hb_face_destroy(face)

    print("✅ 塑形和清理完成。")
}

输出结果如下: 在这里插入图片描述

​​探索 Xcode String Catalog:现代化 iOS 应用国际化指南​​

概述

随着应用程序面向全球用户,本地化支持已成为必不可少的基础能力。一款好的全球化应用应当能够在不同的语言和地区无缝运行,这不仅能让您的产品覆盖更广泛的受众,还能为用户提供更原生、更友好的体验,从而全面提升客户满意度。

包含多种语言的“你好”一词的横幅。

本地化(Localization)过程简单来说就是

  1. 提取应用中对应的面向用户的字符串文本或图片资源等内容
  2. 交给翻译人员,进行不同语言和地区的适配
  3. 最后将翻译好的内容重新导入应用

但如果你曾经在项目中维护过十几种语言的 .strings 文件,你一定知道那种传统模式下的「痛苦」:文件分散、条目重复、翻译遗漏、协作混乱等等等等……

有幸,苹果官方在 Xcode 15 推出的 String Catalog ,其目的就是为了解决传统模式下的这些痛点。

本文介绍的重点就是 Apple 推出的全新国际化管理机制——String Catalog

了解 String Catalog

String Catalog(字符串目录)是 Apple 在 Xcode 15 中引入的一种全新的、集中化的本地化资源管理方式,用于简化项目汇总翻译管理的过程实现轻松本地化。它旨在取代传统的 .strings和 .stringsdict文件,并且最大的好处在于 Xcode 中提供一个统一的可视化界面来管理所有本地化字符串(包括复数形式和设备特定变体)

核心概念

String Catalog 对应的文件后缀是 .xcstrings。其本质是一个 JSON 文件,使其在版本控制系统(如 Git)中进行差异比较时,比旧的 .strings 文件更友好
.xcstrings 文件中包含的内容有:

  • 所有语言的翻译:无需再为每种语言维护多个独立的 .strings或 .stringsdict文件
  • 多种字符串类型:支持常规字符串、复数变体 (如英语中 "1 apple" 和 "2 apples" 的区别)、以及设备特定变体 (如为 iPhone、iPad、Mac 提供不同的翻译)
  • 上下文注释:支持为每个字符串键(Key)添加注释,帮助翻译者理解上下文,从而提供更准确的翻译。

优势与特性

  • Xcode 为xcstrings文件提供的可视化界面
    • 翻译进度显示:以百分比的形式展示每种语言的翻译完成度,用于快速识别未完成或需要更新的语种
    • 智能筛选与排序:支持根据状态、key、翻译、注释等条件进行快速或组合筛选与排序,用于快速定位所需条目
    • 精细化内容管理:可以直接修改各语言对应的翻译内容,并支持为每个条目补充注释(Commnet),为翻译人员提供关键上下文,翻译人员能理解具体含义并确保翻译准确性
    • 状态控制:提供各语言各条目当前状态(STALE、NEW、NEEDS REVIEW、Translated)的查看,并且支持手动设置每个变量的状态(当前仅支持 Don’t Translate、NEEDS REVIEW、REVIEWED)
    • 工程双向联动:支持从可视化编辑界面跳转到变量对应代码位置,也支持从代码快捷跳转到可视化界面中,方便代码的查阅与修改
  • 智能自动管理
    • 编译时自动提取:在编译过程中( syncing localizations ) Xcode 会扫描代关键字,将对应类型的字符串自动提取到 String Catalog 中,无需手动维护条目(设置为 Manually 的条目除外)
    • 无缝语言扩展:为项目新增一种语言时,Xcode 会自动在 Catalog 中为该语言创建列(也支持在编辑界面中新增),并将其所有条目初始标记为 “需要翻译”
    • 变量的自动转换:在代码中使用 String(localized: "Welcome, \(userName)")等包含变量的字符串时,变量(如 (userName))对应的 C 类型的占位符(%@、%lld 等)会自动提取到 String Catalog 中并正确显示,翻译人员只需按目标语言的语序组织字符串即可。
  • 高效协作与集成
    • 集中式管理:将所有需要国际化的字符串都集中在一个可视化的文件中进行管理,告别了过往分散在多个 .strings.stringsdict 文件中的繁琐
    • 设备异化支持:支持按照设备(iPhoen\iPad\iPod\Mac\Apple Watch\Apple TV\Apple Vision)定义不同的翻译版本,保证在各种设备中均能提供合适的显示文本
    • 复数规则支持:内置对复数形式的处理支持,能够根据不同语言的复数规则(各国家规则不同,需要提前确认规则后再做处理,如英语的 "1 apple" 和 "2 apples")自动选择正确的字符串变体,无需开发者手动实现复杂逻辑
    • 标准化支持:无缝支持 XLIFF 标准格式,方便与专业的本地化服务或翻译工具进行导入导出
    • 快捷迁移:支持从旧的 .strings和 .stringsdict文件一键迁移至新的 String Catalog 格式

工作原理与编译机制

flowchart TD
    A[开发者编辑.xcstrings文件] --> B[Xcode编译项目]
    B --> C{编译时处理}
    
    subgraph C [编译时处理]
        C1[String Catalog处理器]
        C2[提取本地化字符串]
        C3[转换为传统格式]
    end
    
    C --> D[生成对应语言的<br>.strings和.stringsdict文件]
    D --> E[打包到App Bundle中]
    E --> F[用户运行应用]
    
    F --> G{运行时处理}
    
    subgraph G [运行时处理]
        G1[系统根据设备语言设置]
        G2[加载对应的.strings文件]
        G3[提供本地化文本]
    end
    
    G --> H[界面显示本地化内容]

具体来说:

  1. 字符串提取机制:在项目的编译过程中存在 syncing localizations 步骤,这个步骤的作用是扫描代码,自动提取以下内容

    • OC 中:使用指定的宏定义 NSLocalizedSrting,(自定义实际内容为 xxxBundle localizedStringForKey: value: table:
    • Interface Builder或storyboard中,文本默认进行提取,Document 部分有一个localizer Hint,通过这个进行注释,会存在在一个Main(Strings) 的 catalog 中
    • swiftUI,文本相关的默认都会进行提取,例如Text("Hello")
    • swiftLocalizedStringKey或 String.LocalizationValue类型的字符串,例如String(localized: "Welcome")
  2. 编译时处理: 编译时,会扫描工程中所有的 .xstrings 文件,并根据文件里面的内容生成对应语言的 .stringdict.strings 文件并引入到工程中(所以本质工程还是使用 .strings 实现的多语言)

image.png


创建与使用指南

由于本质是.strings 实现的多语言,所以实际使用的无最低部署目标要求,仅对 Xcode 存在要求
在新项目中创建 String Catalog

  1. 在 Xcode 项目中,通过 File > New > File...(或快捷键 Cmd+N)打开新建文件对话框
  2. 选择 Strings Catalog 模板
  3. 输入文件名(默认使用 Localizable),然后点击创建

迁移现有项目

如果已有的项目已经在使用传统的 .strings和 .stringsdict文件,那也可以机器轻松迁移到 Strings Catalog (毕竟还是那句话,String Catalog 的本质还是 .strings和 .stringsdict)

  1. 在 Xcode 项目导航器中, 右键单击 现有的 .strings或 .stringsdict文件
  2. 从上下文菜单中选择 Migrate to String Catalog
  3. Xcode 会自动开始迁移过程。为了完成此过程,您可能需要 构建项目 (Cmd+B),Xcode 会在构建过程中收集项目中的字符串资源到 .xcstrings文件中。

添加新语言支持

在 String Catalog 编辑器中:

  1. 点击编辑器窗口底部的 + 按钮
  2. 从下拉列表中选择您要添加的语言
  3. 添加后,Xcode 会自动为所有字符串创建条目,并标记为"需要翻译"(NEW)。

关于文件命名

  • 如果是应用内使用的文本需要国际化:则默认的命名为 Localizable.xcstrings,如需其他自定义名称,则需要在调用时候传递对应的table

  • 如果是需要实现 info.plist 文件的多语言,则固定命名为 InfoPlist.xcstrings

  • 如果是 .storyboard ,则可以直接右击显示 Migrate to String Catalog 即可自动生成

参考文献

探索字符串目录

苹果官方文档-localizing-and-varying-text-with-a-string-catalog

Combine 基本使用指南

Combine 基本使用指南

Combine 是 Apple 在 2019 年推出的响应式编程框架,用于处理随时间变化的值流。下面是 Combine 的基本概念和使用方法。

核心概念

1. Publisher(发布者)

  • 产生值的源头
  • 可以发送 0 个或多个值
  • 可能以完成或错误结束

2. Subscriber(订阅者)

  • 接收来自 Publisher 的值
  • 可以控制数据流的需求

3. Operator(操作符)

  • 转换、过滤、组合来自 Publisher 的值

基本使用示例

创建简单的 Publisher

import Combine

// 1. Just - 发送单个值然后完成
let justPublisher = Just("Hello, World!")

// 2. Sequence - 发送序列中的值
let sequencePublisher = [1, 2, 3, 4, 5].publisher

// 3. Future - 异步操作的结果
func fetchData() -> Future<String, Error> {
    return Future { promise in
        // 模拟异步操作
        DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
            promise(.success("Data fetched"))
        }
    }
}

// 4. @Published 属性包装器
class DataModel {
    @Published var name: String = "Initial"
}

订阅 Publisher

// 使用 sink 订阅
var cancellables = Set<AnyCancellable>()

// 订阅 Just
justPublisher
    .sink { value in
        print("Received value: \(value)")
    }
    .store(in: &cancellables)

// 订阅 Sequence
sequencePublisher
    .sink(
        receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("Finished successfully")
            case .failure(let error):
                print("Failed with error: \(error)")
            }
        },
        receiveValue: { value in
            print("Received: \(value)")
        }
    )
    .store(in: &cancellables)

常用操作符

// 转换操作符
sequencePublisher
    .map { $0 * 2 }                    // 转换每个值
    .filter { $0 > 5 }                 // 过滤值
    .reduce(0, +)                      // 聚合值
    .sink { print("Result: \($0)") }
    .store(in: &cancellables)

// 组合操作符
let publisher1 = [1, 2, 3].publisher
let publisher2 = ["A", "B", "C"].publisher

Publishers.Zip(publisher1, publisher2)
    .sink { print("Zipped: \($0), \($1)") }
    .store(in: &cancellables)

// 错误处理
enum MyError: Error {
    case testError
}

Fail(outputType: String.self, failure: MyError.testError)
    .catch { error in
        Just("Recovered from error")
    }
    .sink { print($0) }
    .store(in: &cancellables)

处理 UI 更新

import UIKit
import Combine

class ViewController: UIViewController {
    @IBOutlet weak var label: UILabel!
    @IBOutlet weak var textField: UITextField!
    @IBOutlet weak var button: UIButton!
    
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupBindings()
    }
    
    private func setupBindings() {
        // 监听文本框变化
        NotificationCenter.default
            .publisher(for: UITextField.textDidChangeNotification, object: textField)
            .compactMap { ($0.object as? UITextField)?.text }
            .sink { [weak self] text in
                self?.label.text = "You typed: \(text)"
            }
            .store(in: &cancellables)
        
        // 按钮点击事件
        button.publisher(for: .touchUpInside)
            .sink { [weak self] _ in
                self?.handleButtonTap()
            }
            .store(in: &cancellables)
    }
    
    private func handleButtonTap() {
        print("Button tapped!")
    }
}

网络请求示例

struct User: Codable {
    let id: Int
    let name: String
    let email: String
}

class UserService {
    private var cancellables = Set<AnyCancellable>()
    
    func fetchUsers() -> AnyPublisher<[User], Error> {
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/users") else {
            return Fail(error: URLError(.badURL))
                .eraseToAnyPublisher()
        }
        
        return URLSession.shared.dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: [User].self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
    
    func loadUsers() {
        fetchUsers()
            .receive(on: DispatchQueue.main) // 切换到主线程
            .sink(
                receiveCompletion: { completion in
                    switch completion {
                    case .finished:
                        print("Request completed")
                    case .failure(let error):
                        print("Error: \(error)")
                    }
                },
                receiveValue: { users in
                    print("Received users: \(users)")
                }
            )
            .store(in: &cancellables)
    }
}

定时器示例

class TimerExample {
    private var cancellables = Set<AnyCancellable>()
    
    func startTimer() {
        Timer.publish(every: 1.0, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] date in
                print("Timer fired at: \(date)")
                self?.handleTimerTick()
            }
            .store(in: &cancellables)
    }
    
    private func handleTimerTick() {
        // 处理定时器触发
    }
}

内存管理

class MyViewController: UIViewController {
    private var cancellables = Set<AnyCancellable>()
    
    deinit {
        // 自动取消所有订阅
        cancellables.forEach { $0.cancel() }
    }
}

最佳实践

  1. 及时取消订阅:使用 store(in:) 管理订阅生命周期
  2. 线程切换:使用 receive(on:) 在合适的线程处理数据
  3. 错误处理:合理使用 catchreplaceError 等操作符
  4. 避免强引用循环:在闭包中使用 [weak self]

这些是 Combine 的基本使用方法。Combine 提供了强大的响应式编程能力,特别适合处理异步事件流和数据绑定。

iOS语音转换SDK相关记录

前言:

在开发iOS ASR 语音转文字SDK中遇到一系列问题,途中尝试解决方案及技术要点进行记录和学习积累 AVAudioSession 相关文章可参考

一、基础:

基础实现部分不再详细堆叠(网上文章较多),以下是主要技术要点和知识点

  • websocket(如果需要通过后台网络进行TTS相关语音转换)
  • 语音录制相关基础知识 采样率、通道(声到)、声音位数(采样精度)、编码格式(wav,mp3等)
  • 录音基础设置相关 AVAudioSession, 系统声音相关设置,包括硬件(话筒、耳机)之间的切换和优化
  • 录音设备单元相关 AudioComponent,AudioUnit等相关输入输出设置
  • 无限录制转换注意对内存进行控制
  • 容易引发崩溃的点

二、出现的问题和对应分析解决:

  • 如果APP中集成了其他语音类SDK,在使用的时候会影响我方SDK,主要影响点:

    • AVAudioSession 相关设置,这个是全局设置。设置后也可能会影响app内其他语音类SDK(要想简单完美解决这种最好的方式当然是整个语音模块都自己实现,不过复杂平台app显然不可能)

              [session setCategory:AVAudioSessionCategoryPlayAndRecord
                           withOptions:AVAudioSessionCategoryOptionDefaultToSpeaker |
                                        AVAudioSessionCategoryOptionAllowBluetooth |
                                        AVAudioSessionCategoryOptionOverrideMutedMicrophoneInterruption
                                 error:&error];
      

      上面代码设置后,在使用activate 就会全局改变AVAudioSession的设置。可能对app内其他使用同样AVAudioSession的SDK造成影响。这里只记录其他语音SDK设置这个对我方造成的影响

      1.情景一:前面有个语音SDK执行语音播报,播报完成后立马呼起我方语音SDK。此时无论对方AVAudioSession,只要调起我方SDK,我们直接按上面重新设置session。此时如果前面没有戴耳机是通过设备mic呼出,这时候启动我方语音SDK效果正常。这时对方一定也是设置了 AVAudioSessionCategoryOptionDefaultToSpeaker,我们这个也是defaultToSpeaker,系统没有发生设备切换。但是如果此时呼出后我们戴的是耳机就会出现另一种状况,会发现我方SDK启动较慢,而且1秒多后才能正常录音,这个原因就是我们这里发生了设备切换。而且代码中监控设备切换做了重新停止和再开启的原因。

      2.情景二: 当前有个语音SDK正在进行长播报,此时我方要启动我方语音SDK。产品要求,不能影响当前播报,我方语音可以正常呼起对话框,然后正常说话讲语音转为文字。这个情况再调我方语音SDK上面同样操作也会出现问题,开启后因为重新设置和activate 会直接中断播报。

      以上2种情况可以合并解决:1、首先判断AVAudioSession是否是激活状态。(并不能直接调用isActive,实际项目中根本没有这个方法)或者使用以下判断。

      // 初始化时注册通知
      - (void)setupAudioSessionObserver {
          [[NSNotificationCenter defaultCenter] addObserver:self
                                                   selector:@selector(handleAudioInterruption:)
                                                       name:AVAudioSessionInterruptionNotification
                                                     object:nil];
      }
      
      // 记录当前激活状态的变量
      @property (nonatomic, assign) BOOL isAudioSessionActive;
      
      // 通知回调:处理激活/中断事件
      - (void)handleAudioInterruption:(NSNotification *)notification {
          NSDictionary *userInfo = notification.userInfo;
          AVAudioSessionInterruptionType type = [userInfo[AVAudioSessionInterruptionTypeKey] integerValue];
      
          if (type == AVAudioSessionInterruptionTypeBegan) {
              // 会话被中断(变为未激活)
              self.isAudioSessionActive = NO;
          } else if (type == AVAudioSessionInterruptionTypeEnded) {
              // 中断结束(可能恢复激活)
              AVAudioSessionInterruptionOptions options = [userInfo[AVAudioSessionInterruptionOptionKey] integerValue];
              if (options & AVAudioSessionInterruptionOptionShouldResume) {
                  // 允许恢复激活
                  self.isAudioSessionActive = YES;
              }
          }
      }
      

      2、存储当前 session 的category 和 options。根据已有的的category,option 添加设置自己需要的 category,option(注意不要改原始的,自己重新定义一个,这个无论active和不是active 都会生效),如果要去不打断播报就设置mode为AVAudioSessionModeVoiceChat,同时注意 options 中要有mix。3、这里很重要,如果是active 就不要再设置 active 为YES。如果这样会直接中断当前其他语音SDK。

    • setMode 这个方法如果设置,会影响其他SDK,categoryOptions 会随之更改。实测如果设置 mode = VoiceChat/Measurement,categoryOptions 会变成1 。

      解决这个问题就是结束自己语音的时候,把开始存储的对应session category和categoryOptions 重新设置为原始值以防止影响其他语音SDK。

  • 容易引起崩溃的点:

    • 快速点击/连续启动引起的崩溃:CMBAudioUnitRecorder *recorder = (__bridge CMBAudioUnitRecorder *)(inRefCon); 类似录音单元这里 inRefCon 有可能是空指针引起崩溃,特别是如果没有控制用户行为连续快速点击启动的时候

    解决这个问题的方法:在你开始设置callback时进行强引用,然后在结束的时间进行释放

        AURenderCallbackStruct inputCallBackStruce;
        inputCallBackStruce.inputProc = inputCallBackFun;
        self.inputProcRefCon = (__bridge_retained void *)self;
        inputCallBackStruce.inputProcRefCon = self.inputProcRefCon;
    
       // 释放 retained 的 self
       if (self.inputProcRefCon) {
           CFRelease(self.inputProcRefCon);
           self.inputProcRefCon = NULL;
       }
    
    • 无网飞行模式下引起的崩溃:这个主要原因和上面 inRefCon 空指针类似。AudioUnit(特别是 RemoteIO)的输入回调是系统底层音频线程(AURemoteIO::IOThread)触发的。即使你在主线程调用 [recorder stopRecord] 或释放对象,只要没有正确 停止 AudioUnit 并移除回调,系统仍然会在底层线程调用 inputCallBackFun(), 这时 inRefCon 就成了一个悬空指针(dangling pointer) ,转成 (__bridge CMBAudioUnitRecorder *) 时自然就是 nil 或无效内存。

    解决方案:结束记得回收资源

            CheckError(AudioOutputUnitStop(self->audioUnit),"AudioOutputUnitStop failed");
        AURenderCallbackStruct emptyCallback = {0};
        AudioUnitSetProperty(self->audioUnit, kAudioOutputUnitProperty_SetInputCallback, kAudioUnitScope_Global, kInputBus, &emptyCallback, sizeof(emptyCallback));
        // 释放 retained 的 self
        if (self.inputProcRefCon) {
            CFRelease(self.inputProcRefCon);
            self.inputProcRefCon = NULL;
        }
        
        self->_isRecording = NO;
        AudioUnitUninitialize(self->audioUnit);
        AudioComponentInstanceDispose(self->audioUnit);
        self->audioUnit = NULL;
  • 无限录制时造成内存泄露:inputCallBackFun设置回调方法时,会有持续数据流进入,这时要注意对内存进行管理

截屏2025-10-30 20.53.52.png

三、优化相关:

  • 加快整个SDK启动速度及效率:
    • 使用多线程,使用队列,单独维持一条线程进行语音SDK的整个录音启动流程
    • [session setActive:YES withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:nil];AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation opt 可以告诉系统不用等前面的 session 状态马上启动自己的session
  • 语音SDK进行中,此时有来电打断语音功能如何恢复:
    • 监控设备之间的切换,根据不同状态进行SDK重启相关操作
        //注册通知
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleRouteChange:) name:AVAudioSessionRouteChangeNotification object:nil];

        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleInterruption:) name:AVAudioSessionInterruptionNotification object:nil];
- (void)handleInterruption:(NSNotification *)notification {
    AVAudioSessionInterruptionType type = [notification.userInfo[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue];
    if (type == AVAudioSessionInterruptionTypeBegan) {
        [self stop];
    }else if (type == AVAudioSessionInterruptionTypeEnded) {
        [self start];
    }
}
  • 注意整个工程的内存管理,特别是自己管理内存的相关地方(例如自己实现的 C/C++方法相关,及其他CF需要内存管理的地方)

请及时同意苹果开发者协议,避免影响迭代工作。

背景

最近后台有咨询反馈,添加测试设备异常,以及提交ipa报错等问题。

追溯其本质原因是因为AppStore最近苹果更新了开发者协议&付费协议

简单来说:

此次版本和以往不同,历史版本不会影响到ipa打包后上传AppStore。

同时最近AppStore又出现了发布会前后的锁词行为,关键词波动不大效果微弱。社交App卡审依旧为普遍行为,相关类目的开发也不必大惊小怪,还是那句话 只要代码干净不用慌,要有骚操作慌也没啥用!

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# Pingpong和连连的平替,让AppStore收款无需新增持有人。

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

在Flutter中使用信号量解决异步冲突

问题

最近一个项目是IM的项目,使用的是悟空IM SDK,在会话列表中,会话列表的数组conversationList会被多次改变:

  • 读取本地数据库设置会话列表
  • 根据服务器接口返回更新会话列表
  • 频道刷新回调(包括频道新增、频道信息修改、删除频道),在会话列表中主要处理频道信息修改时要将对应的频道名与频道头像更新,在频道被删除时应将频道对应的会话删除
  • 会话列表刷新回调(会话新增、更新),在会话列表刷新中,处理会话的新增、更新,已读未读
  • 会话删除回调,处理会话的删除
  • 频道消息列表回调,主要处理更新会话的最新一条消息,设置为未读
  • cmd消息,这里主要是处理服务器的已读回复,将对应会话设置为已读,更新已读扩展(WKMsgExtra extra
  • 置顶会话,这里主要处理会话列表排序

在以上的多次改变中,由于它们的改变都是不定时的,也许会出现不同的几个操作同时修改conversationList,这样会造成数据源的更新冲突,在界面上产生未知错误。

思路

如果在iOS或者安卓中,一般可以采取锁或者队列的方式来解决,但是在Flutter中,由于Dart单线程,异步采取Future事件队列和微队列的方式,它们之间其实并没有一个顺序控制。

在iOS中,有一个信号量可以控制线程任务的执行顺序(也可以用于控制最大并发、锁),所以我基于信号量的定义,在Flutter中也实现了一个信号量的控制方法,用于解决这些问题,代码如下:

class SemaphoreTask {
  final int _maxCount;
  int _currentCount = 0;
  final _waiting = <Completer<void>>[];

  SemaphoreTask(this._maxCount);

  Future<void> acquire() async {
    if (_currentCount < _maxCount) {
      _currentCount++;
      return;
    }
    final completer = Completer<void>();
    _waiting.add(completer);
    await completer.future;
  }

  void release() {
    if (_waiting.isNotEmpty) {
      _waiting.removeAt(0).complete();
    } else {
      _currentCount--;
    }
  }

  static Future<void> runTasksWithSemaphore(
    List<Future Function()> tasks, {
    int maxConcurrent = 3,
    void Function()? callback, // 全部完成的回调
  }) async {
    final semaphore = SemaphoreTask(maxConcurrent);
    final futures = <Future>[];

    for (final task in tasks) {
      futures.add(() async {
        await semaphore.acquire();
        try {
          await task();
        } finally {
          semaphore.release();
        }
      }());
    }

    await Future.wait(futures);
    if (callback != null) {
      callback();
    }
  }

  dispose() {
    for (var completer in _waiting) {
      completer.complete();
    }
    _waiting.clear();
  }
}

如上所示,我们创建了一个基于Dart的信号量,其中_maxCount表示最大并发数,_currentCount表示信号量的初始值(最大并发数),使用Completer来阻塞Future

  • acquire()方法用于信号量+1
  • release()方法用于信号-1
  • runTasksWithSemaphore是一个便捷方法,用于传入多个异步任务,进行最大并发数控制
  • dispose()用于页面退出释放资源

解决

这下,我们可以顺利使用信号量了,我们的需求是需要制造一个异步队列,所以,首先,我们定义一个宽度为1的信号量:

final semaphore = SemaphoreTask(1);

然后,在每一个需要改变数据源conversationList的地方,进行顺序控制:

Future(() async {
  await semaphore.acquire();

  ///根据是否置顶和置顶时间排序
  conversationList = WkConversationUtil.instance.sortListByConversation(conversationList);

  update();// 这里是使用GetX进行状态管理
  semaphore.release();
});

最后,在释放的地方,GetX中是在onClose()方法中:

@override
void onClose() {
  super.onClose();
  semaphore.dispose();
}

结果

经过多轮测试,会话列表正常展示,未再有出现多个相同会话、会话列表信息展示错误这些问题,并且,会话列表消息流畅,并未有卡顿或者遗漏,问题顺利得到解决。

并发控制

附上写信号量时的测试代码:

void main(List<String> args) {
  print('开始了');
  final tasks = List.generate(
      10,
      (i) => () async {
            print('Task $i started');
            await Future.delayed(Duration(seconds: 1));
            print('Task $i completed');
            return i;
          });

  SemaphoreTask.runTasksWithSemaphore(
    tasks,
    maxConcurrent: 3,
    callback: () {
      print('全部完成了');
    },
  );
}

iOS 进阶6-Voip通信

iOS VoIP 开发全指南:框架、实现、优化与合规

iOS 平台的 VoIP(Voice over Internet Protocol,互联网语音协议)是基于网络传输语音数据的通信方案,凭借低成本、跨设备等优势,广泛应用于即时通讯、网络电话、视频会议等场景。苹果通过 CallKit(系统级通话管理)和 PushKit(高优先级推送)两大核心框架,为 VoIP 应用提供原生级体验支持,同时明确了严格的开发规范与审核要求。本文将从核心框架、实现流程、优化策略、合规要点四个维度,全面解析 iOS VoIP 开发。

一、核心框架:CallKit 与 PushKit 协同工作

iOS VoIP 应用的核心能力依赖 CallKit 与 PushKit 的配合,二者分别解决 “通话体验” 和 “后台唤醒” 两大关键问题,缺一不可。

1. CallKit:系统级通话体验赋能(iOS 10+)

CallKit 是苹果在 iOS 10 中推出的 VoIP 专属框架,其核心价值是将第三方 VoIP 通话提升至 “运营商通话” 同级别的系统待遇,解决了 iOS 10 前 VoIP 应用的体验局限(如通知易遗漏、通话易被打断等)。

核心功能
  • 原生通话界面:来电时触发系统级接听界面(支持锁屏 / 前台 / 后台状态),无需依赖应用内页面或普通通知。
  • 系统级权限保障:通话过程中占用系统音频通道,不会被其他音频应用(如音乐、视频)打断;同时支持与运营商通话的切换(用户可选择挂起 / 挂断当前 VoIP 通话)。
  • 系统集成能力:VoIP 通话记录自动同步至系统 “电话” 应用,支持从通讯录、最近通话列表直接发起 VoIP 呼叫,甚至通过 Siri 触发通话。
核心类与工作流程

CallKit 的核心逻辑围绕 CXProvider(通话状态管理)和 CXCallController(通话操作执行)展开:

  • CXProvider:负责向系统注册通话、更新通话状态(如来电、接通、挂断),通过 CXProviderDelegate 接收用户在系统界面的操作(接听 / 挂断 / 静音等)。关键 API 包括:

    • reportNewIncomingCall(with:update:completion):注册来电,触发系统接听界面。
    • reportCall(with:endedAt:reason):通知系统通话结束。
  • CXCallController:负责发起通话操作(如拨打电话、挂断),通过 CXTransaction 封装具体动作(如 CXAnswerCallAction 接听、CXEndCallAction 挂断)。

  • CXCallUpdate:存储通话属性(如呼叫方名称、号码、是否支持视频),用于向系统传递通话信息。

2. PushKit:后台唤醒与来电推送(iOS 8+)

PushKit 是苹果专为 VoIP 设计的高优先级推送框架,相比普通 APNs 推送,它能在应用终止(杀死)/ 后台状态下唤醒应用,确保来电通知不丢失,是 VoIP 保活的核心依赖。

核心特性
  • 高优先级唤醒:即使应用被用户手动关闭,仍能触发 pushRegistry(_:didReceiveIncomingPushWith:for:) 回调,给予应用 30 秒左右后台时间处理来电逻辑。
  • 无通知权限依赖:无需用户授权 “通知权限”,推送直接触发应用后台唤醒(仅在展示来电界面时需依赖 CallKit)。
  • 专属推送类型:需在 APNs 推送 payload 中指定 push-type: voip,否则推送会被苹果拦截。
基本使用流程
  1. 配置项目:在 Info.plist 中启用 UIBackgroundModes 的 voip 权限。

  2. 导入 PushKit 框架,创建 PKPushRegistry 实例,注册 VoIP 推送类型。

  3. 实现 PKPushRegistryDelegate 协议:

    • pushRegistry(_:didUpdate:for:):获取设备 VoIP 推送 Token,上传至应用服务器。
    • pushRegistry(_:didReceiveIncomingPushWith:for:):接收 VoIP 推送,触发 CallKit 注册来电。

二、iOS VoIP 开发完整流程(含代码示例)

以 “基于 Agora SDK 的视频通话应用” 为例,整合 CallKit 与 PushKit 的核心实现步骤:

1. 前期配置(环境与权限)

  • 开发环境:Xcode 12+,iOS Deployment Target ≥ 13.0(兼容 CallKit 新特性)。

  • 权限配置

    1. Info.plist 中添加后台模式:

      xml

      <key>UIBackgroundModes</key>
      <array>
        <string>voip</string>
        <string>audio</string> <!-- 确保通话时后台音频持续 -->
      </array>
      
    2. 申请 APNs VoIP 推送证书(需在 Apple Developer 后台创建,用于服务器签名推送请求)。

2. 集成 PushKit:实现后台唤醒

swift

import PushKit

class VoIPPushManager: NSObject, PKPushRegistryDelegate {
    private let pushRegistry = PKPushRegistry(queue: DispatchQueue.main)
    
    override init() {
        super.init()
        // 注册 VoIP 推送类型
        pushRegistry.delegate = self
        pushRegistry.desiredPushTypes = [.voIP]
    }
    
    // 获取 VoIP 推送 Token 并上传服务器
    func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType) {
        let token = credentials.token.map { String(format: "%02.2hhx", $0) }.joined()
        print("VoIP Token: (token)")
        // 上传 token 到应用服务器,用于定向推送来电通知
    }
    
    // 接收 VoIP 推送,触发来电逻辑
    func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType) {
        // 解析推送参数(如呼叫方ID、通话类型)
        let callID = payload.dictionaryPayload["call_id"] as? String ?? UUID().uuidString
        let callerName = payload.dictionaryPayload["caller_name"] as? String ?? "Unknown"
        
        // 启动后台任务,确保处理完成前应用不被挂起
        let backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "HandleVoIPCall") {
            UIApplication.shared.endBackgroundTask(backgroundTask)
        }
        
        // 通过 CallKit 注册来电,触发系统接听界面
        CallKitManager.shared.reportIncomingCall(callID: callID, callerName: callerName)
        
        UIApplication.shared.endBackgroundTask(backgroundTask)
    }
}

3. 集成 CallKit:管理通话生命周期

swift

import CallKit

class CallKitManager: NSObject, CXProviderDelegate {
    static let shared = CallKitManager()
    private let provider: CXProvider
    private let callController = CXCallController()
    
    private override init() {
        // 配置 CallKit 提供者(如应用名称、图标、支持的通话类型)
        let configuration = CXProviderConfiguration(localizedName: "VoIP Demo")
        configuration.supportsVideo = true // 支持视频通话
        configuration.maximumCallsPerCallGroup = 1
        configuration.iconTemplateImageData = UIImage(named: "call_icon")?.pngData()
        
        provider = CXProvider(configuration: configuration)
        super.init()
        provider.setDelegate(self, queue: DispatchQueue.main)
    }
    
    // 注册来电,触发系统接听界面
    func reportIncomingCall(callID: String, callerName: String) {
        let uuid = UUID(uuidString: callID) ?? UUID()
        let update = CXCallUpdate()
        update.remoteHandle = CXHandle(type: .generic, value: callerName) // 呼叫方名称
        update.supportsVideo = true
        
        // 向系统注册来电
        provider.reportNewIncomingCall(with: uuid, update: update) { error in
            if let error = error {
                print("注册来电失败:(error.localizedDescription)")
            }
        }
    }
    
    // 用户接听来电(系统界面触发)
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        let callID = action.callUUID.uuidString
        // 启动 Agora SDK 通话逻辑(加入频道、开启音视频)
        AgoraManager.shared.startCall(callID: callID)
        action.fulfill() // 告知系统动作完成
    }
    
    // 用户挂断来电(系统界面触发)
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        let callID = action.callUUID.uuidString
        // 停止 Agora SDK 通话逻辑(退出频道、释放资源)
        AgoraManager.shared.endCall(callID: callID)
        action.fulfill()
    }
    
    // 通话连接成功,更新系统状态
    func notifyCallConnected(callID: String) {
        let uuid = UUID(uuidString: callID) ?? UUID()
        provider.reportOutgoingCall(with: uuid, connectedAt: Date())
    }
}

4. 集成音视频 SDK(如 Agora):实现实际通话

CallKit 仅负责通话状态管理和界面展示,实际的语音 / 视频数据传输需依赖专业音视频 SDK(如 Agora、WebRTC):

swift

import AgoraRtcKit

class AgoraManager: NSObject, AgoraRtcEngineDelegate {
    static let shared = AgoraManager()
    private var rtcEngine: AgoraRtcEngineKit?
    private let appID = "你的 Agora AppID"
    
    private override init() {
        super.init()
        // 初始化 Agora 引擎
        rtcEngine = AgoraRtcEngineKit.sharedEngine(withAppId: appID, delegate: self)
        rtcEngine?.setChannelProfile(.communication) // 通话模式
        rtcEngine?.enableVideo() // 启用视频
    }
    
    // 开始通话(加入频道)
    func startCall(callID: String) {
        rtcEngine?.joinChannel(byToken: nil, channelId: callID, info: nil, uid: 0) { [weak self] _, _ in
            // 通知 CallKit 通话连接成功
            self?.notifyCallConnected(callID: callID)
        }
    }
    
    // 结束通话(退出频道)
    func endCall(callID: String) {
        rtcEngine?.leaveChannel(nil)
    }
    
    // 远端用户加入频道,设置视频渲染视图
    func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) {
        let remoteView = AgoraRtcVideoCanvas()
        remoteView.uid = uid
        remoteView.view = UIView() // 你的远端视频渲染视图
        remoteView.renderMode = .hidden
        rtcEngine?.setupRemoteVideo(remoteView)
    }
}

三、关键优化:音频质量、保活与状态同步

1. 音频质量优化

VoIP 应用的核心体验是通话清晰度,需从编解码、网络适配、音频处理三方面优化:

  • 编解码选择:优先使用 Opus 编解码器(低延迟、高抗丢包),动态调整比特率(8-64kbps)适配网络状态。
  • 网络适配:实现抖动缓冲机制(Jitter Buffer),补偿网络延迟;针对弱网场景,降低采样率(如 16kHz,延迟≤50ms)以减少带宽占用。
  • 音频增强:利用 AVFoundation 框架启用回声消除、噪声抑制;iOS 15+ 支持系统级 “语音突显” 功能,可通过代码或引导用户手动开启(设置 → 辅助功能 → 音频与视觉)。

2. 后台保活与稳定性

  • 后台任务延长:在接收 VoIP 推送后,通过 beginBackgroundTask(withName:) 申请后台执行时间,确保通话初始化完成前应用不被系统挂起。
  • BGTaskScheduler 补充:iOS 13+ 可通过 BGTaskScheduler 注册周期性后台任务,定期同步用户在线状态(需在 Info.plist 中配置 BGTaskSchedulerPermittedIdentifiers)。
  • 网络重连机制:使用 WebSocket 保持长连接,监听网络状态变化;通话中断时(如网络切换),缓存通话状态,待网络恢复后自动重连。

3. 跨设备状态同步

  • 通话状态(如接通、挂断、静音)实时同步至应用服务器,确保多设备登录时状态一致。
  • 网络中断时,本地缓存通话状态,恢复连接后与服务器校验,避免 “单边挂断” 等异常场景。

四、合规要点:App Store 审核规范

苹果对 VoIP 应用的审核要求严格,需重点关注以下条款,避免审核被拒:

  1. 后台模式权限合规:仅当应用核心功能为 VoIP 通话时,才可申请 voip 后台模式,不得滥用后台权限进行无关操作(如后台下载、广告推送)。
  2. CallKit 强制集成:iOS 13+ 要求 VoIP 应用必须集成 CallKit,否则无法通过审核(苹果认为未集成 CallKit 的应用体验不佳)Apple Developer。
  3. 推送合规:VoIP 推送仅用于触发来电通知,不得用于发送广告、营销信息;推送 payload 必须包含 push-type: voip,否则会被 APNs 拒绝。
  4. 内购合规:若应用提供付费通话服务(如国际长途),需通过苹果 IAP 完成支付,不得引导用户使用第三方支付渠道。

五、常见问题与解决方案

  1. iOS 18 后台 / 终止状态无法接收 VoIP 推送

    • 检查是否同时启用 voip 和 audio 后台模式(iOS 18 强化了音频权限依赖)Apple Developer。
    • 确认推送 payload 中 push-type 为 voip,且无多余无关字段。
    • 测试需使用真实设备(模拟器不支持 PushKit)。
  2. CallKit 来电界面不弹出

    • 检查 CXProviderConfiguration 是否配置正确(如 localizedName 非空、iconTemplateImageData 格式正确)。
    • 确保 reportNewIncomingCall(with:update:completion) 回调无错误(如 UUID 重复、权限不足)。
  3. 通话被其他应用打断

    • 确认 CallKit 已正确报告通话状态(reportOutgoingCall(with:connectedAt:) 已调用)。
    • 检查 AVAudioSession 配置,确保通话时激活音频会话并设置正确的类别(如 playAndRecord)。

总结

iOS VoIP 开发的核心是通过 CallKit 实现系统级体验,通过 PushKit 保障后台唤醒,再结合专业音视频 SDK 完成数据传输。开发过程中需重点关注音频质量优化、后台保活稳定性,同时严格遵守 App Store 审核规范。随着 iOS 版本迭代,苹果对 VoIP 的权限和体验要求不断升级,开发者需持续关注官方文档更新(如 iOS 18 的音频后台模式强化),确保应用兼容最新系统特性。

iOS - UIViewController 生命周期

核心概念

本质:一组较为稳定的事件回调。

VC的生命周期谈起,并扩展讲讲部分相关的API

UIViewController

1. 初始化阶段

  1. +initialize: 类的初始化方法 - 时机:仅 oc,且首次初始化时才会调用

  2. -init: 实例的初始化方法

    • 如果是从 xib/storyboard 来的,调用会变成:
      1. -initWithCoder: 在 nib 或 storyboard 解码时调用(对象刚被创建,未连接 IBOutlet)。
      2. -awakeFromNib: 所有子对象实例化后,并且IBOutlet都连接好后调用。
  3. -loadView: 创建 vc 的 view - 时机:访问 vc 的 view 且 view 为空时调用

    • [super loadView] 默认实现:
      1. 设置了 nibName,通过 name 查找对应 nib:
        1. 有资源,则加载对应 nib。
        2. 没资源,会按照类名匹配主 bundle 下的同名 nib。
      2. 未设置 nibName,创建一个空白 view。

2. 生命周期(相关流程)

stateDiagram-v2
    [*] --> viewDidLoad: vc 的 view 创建完成后调用
    viewDidLoad --> viewWillAppear: 视图即将可见
    viewWillAppear --> viewIsAppearing: 视图的几何尺寸、safe area、trait 等环境已确认
    viewIsAppearing --> updateViewConstraints: 约束更新,布局求解
    updateViewConstraints --> viewWillLayoutSubviews: 在本轮布局阶段开始前回调(即将布局子视图)
    viewWillLayoutSubviews --> viewDidLayoutSubviews: 在本轮布局阶段结束时回调
    viewDidLayoutSubviews --> updateViewConstraints: 循环次数系统决定,可能 0 次可能多次
    viewDidLayoutSubviews --> viewDidAppear: 过渡动画
    viewDidAppear --> viewWillDisappear: 视图即将不可见
    viewWillDisappear --> viewDidDisappear: 过渡动画
    viewDidDisappear --> [*]: 视图不可见    

⚠️:Appear 阶段的回调顺序并不是固定的,也可能是:

stateDiagram-v2
[*] --> updateViewConstraints
updateViewConstraints --> viewIsAppearing
viewIsAppearing --> viewWillLayoutSubviews
viewWillLayoutSubviews --> viewDidLayoutSubviews
viewDidLayoutSubviews --> [*]

可以看出-updateViewConstraints-viewIsAppearing的顺序不一定是固定的。

  • 原因:
    • 二者不构成先后必然关系;
    • 他们分别由“外观转场调度”与“布局引擎调度”驱动,是UIKit中两条协同的流程;
      • 外观转场调度:外观/生命周期由容器控制器(如导航)通过 begin/endAppearanceTransition 等驱动,负责“让谁消失/出现”的调度。
        • 触发外观回调viewWillAppear → viewIsAppearing → viewDidAppearviewWillDisappear → viewDidDisappear
      • 布局引擎调度:约束/布局由Auto Layout 引擎在布局阶段驱动,负责“计算 frame/安全区/约束应用”的调度。
        • 触发布局回调updateViewConstraints → viewWillLayoutSubviews → viewDidLayoutSubviews
    • 他们在主线程的同一个RunLoop上交替工作:
      • 外观转场会引发几何/安全区变化,从而“标记”需要布局。
      • 布局完成又为转场呈现提供正确的 frame。

3. 其他(不太常用)

  • 销毁
    • -dealloc
  • 内存告警
    • -didReceiveMemoryWarning:内存不足时,被 iOS 调用
    • -viewDidUnload:已弃用(iOS 3 ~ 6)
  • 容器关系
    • -willMoveToParentViewController
    • -didMoveToParentViewController
  • 环境特征/尺寸变化
    • viewWillTransition(to:with:):旋转/分屏、pageSheet 等拉动导致控制器视图 size 变化的场景。
    • traitCollectionDidChange(_:):布局方向变化(阿拉伯语 LTR -> RTL)、旋转/分屏等。

UIView(其实没有生命周期的概念,只是一些常用的事件回调)

1. 初始化

同 VC,只是没有 -loadView 而已。

2. 常用

  • 层级与窗口
    • -willMoveToSuperview -> -didMoveToSuperview
    • -willMoveToWindow -> -didMoveToWindow
  • 约束与布局
    • -setNeedsLayout:标记需要布局, 等待下次 RunLoop 执行布局
    • layoutIfNeeded:若被标记为需要布局,则“立刻在当前 RunLoop 执行一次布局”。
    • layoutSubviews:布局过程中的回调,不能手动直接调。

什么操作会标记“需要布局”呢?

  • 显示触发
    • 调用 -setNeedsLayout 方法。
    • 调用 -setNeedsUpdateConstraints修改约束
  • 几何与层级变更(UIKit 内部会标记)
    • 修改 frame/bounds/center/transform
    • 父视图的 bounds/safe area变化
    • 视图 首次加入窗口 或 窗口变化(-willMoveToWindow
  • Auto Layout 相关
    • 约束的 常量/优先级、启用/禁用
    • 组件的 抗压缩/抗拉伸 优先级
    • translatesAutoresizingMaskIntoConstraints 切换导致约束变化
  • 内在尺寸(intrinsicContentSize)变化 -(视图“基于自身内容的天然尺寸”,不依赖外部约束)
    • 调用invalidateIntrinsicContentSize
    • 改变内在尺寸的属性更新:text/font/numberOfLines等等。

3. 其他(不太常用)

  • 约束与布局
    • -setNeedsUpdateConstraints -> -updateConstraints
  • 环境变化
    • traitCollectionDidChange
    • tintColorDidChange
    • safeAreaInsetsDidChange
    • layoutMarginsDidChange
  • 渲染
    • setNeedsDisplay / setNeedsDisplay(_:)
    • draw(_:)

VC 和 View 回调的交叉(切换 vc,创建加载 view 等)

回调顺序:

1. VC 的切换

// VC(A) 切换到 VC(B)
1. B -loadView  
2. B -viewDidload  
  
3. A -viewWillDisappear  
  
4. B -viewWillAppear  
5. B -viewWillLayoutSubviews  
6. B -viewDidLayoutSubviews  
  
7. A -viewDidDisappear  
  
8. B -viewDidAppear

2. VC 与 View 的交叉

// 添加 viewB
1. VC - addSubview:viewB

2. viewB - willMoveToSuperview
3. viewB - didMoveToSuperview

// 出现 view
1. VC - viewWillAppear

2. viewB - willMoveToWindow
3. viewB - didMoveToWindow

4. VC - viewWillLayoutSubviews
5. VC - viewDidLayoutSubviews

6. viewB - layoutSubviews

7. VC - viewDidAppear

疑问:

为什么子 view 的 -layoutSubviews 打印在 -viewDidLayoutSubviews 之后?

-viewDidLayoutSubviews 的字面含义不是子 view 都做完 -layoutSubviews 了`?

  • 其实顺序是正确的,并不矛盾。-viewDidLayoutSubviews并不保证“所有子 view 的 -layoutSubviews 都已经执行完”,它只是“VC根视图这一轮布局周期结束”的回调。子视图的第一次布局可能被推迟到下一次布局循环,因此会出现在 viewDidLayoutSubviews 之后。

🚀 Flutter iOS App 上架 App Store 全流程(图文详解)

本文将详细讲解如何为 Flutter App 配置 iOS 签名证书与 Provisioning Profile,并顺利上传到 App Store。


📘 一、准备条件

在开始前,请确保你已具备:

  • ✅ 一台 Mac 电脑(安装最新 Xcode)
  • ✅ Flutter 环境配置完成(flutter doctor 通过)
  • ✅ 已注册 Apple Developer 账号($99/年)
  • ✅ 已创建 App ID(如 com.rain.yanhuoshijian

🧭 二、理解签名机制

名称 作用 文件 说明
Certificate 证明 App 是由你签名的 .cer 系统信任的身份凭证
Provisioning Profile 绑定 App ID + 证书 + 设备 .mobileprovision 定义谁可以安装此 App

⚠️ 发布到 App Store 必须使用:

  • iOS Distribution 证书
  • App Store 类型 的描述文件

🛠️ 三、创建 iOS Distribution 证书

1️⃣ 登录 Apple Developer

🔗 developer.apple.com/account/res…

点击右上角的 ➕ 按钮:

image.png


2️⃣ 选择证书类型

在证书类型列表中选择:

Production → App Store and Ad Hoc

点击「Continue」。


3️⃣ 生成 CSR 文件(在 Mac 上)

打开 钥匙串访问(Keychain Access)

应用程序 → 实用工具 → 钥匙串访问

点击菜单:

钥匙串访问 → 证书助理 → 从证书颁发机构请求证书…

填写信息:

  • 用户邮箱:Apple ID 邮箱
  • 常用名称:name
  • 勾选「存储到磁盘」
  • 点击「继续」保存为:
    CertificateSigningRequest.certSigningRequest

📸 示意图(示例):

image.png


4️⃣ 上传 CSR 文件到 Apple Developer

回到 Apple 网站 → 上传上一步的 .certSigningRequest 文件
点击「Continue」

Apple 将生成一个 .cer 文件
下载该文件(例如:ios_distribution.cer


5️⃣ 导入证书到钥匙串

双击 .cer 文件,它会自动导入钥匙串中:

image.png

在「钥匙串访问」搜索:

iPhone Distribution

若显示即表示导入成功 ✅


🧩 四、创建 Provisioning Profile(描述文件)

1️⃣ 打开 Profile 页面

🔗 developer.apple.com/account/res…

点击右上角「➕」创建新的 Profile。


2️⃣ 选择 Profile 类型

选择:

App Store → App Store Connect Distribution

点击「Continue」。

image.png


3️⃣ 选择 App ID

选择你的应用 ID(例如:com.rain.yanhuoshijian

点击「Continue」。


4️⃣ 选择 Distribution 证书

选择刚刚创建的 iOS Distribution Certificate
点击「Continue」。


5️⃣ 命名与生成

输入名称:

YanhuoShijian_AppStore_Profile

点击「Generate」生成
下载 .mobileprovision 文件。


6️⃣ 导入 Xcode

双击 .mobileprovision 文件,它会自动导入 Xcode。


🧱 五、在 Xcode 中配置签名

打开 Flutter 项目的 iOS 工程:

open ios/Runner.xcworkspace

在 Xcode 中:

1️⃣ 选中左侧的 Runner 项目
2️⃣ 切换到「Signing & Capabilities」
3️⃣ 设置:

  • Team:选择你的 Apple Developer 团队
  • Bundle Identifier:与 App ID 保持一致
  • Signing Certificate:选择 Apple Distribution
  • Provisioning Profile:选择刚刚创建的 Profile 名称

📸 示意图:

image.png


🧪 六、打包验证

执行命令:

flutter build ipa --release

如果成功,会输出:

Built IPA to: build/ios/ipa/Runner.ipa

🩺 七、常见问题排查

问题 原因 解决方式
No signing certificate 证书未导入或过期 重新下载 .cer 文件并导入
⚠️ Provisioning profile not found Bundle ID 不匹配 确认 Profile 与 Xcode 一致
❌ 打包失败 缺少描述文件 手动导入 .mobileprovision
💾 多台 Mac 共用 需导出 .p12 文件 在钥匙串中导出 Distribution 证书

☁️ 八、上传到 App Store Connect

1️⃣ 登录 appstoreconnect.apple.com
2️⃣ 进入「我的 App」
3️⃣ 新建 App
4️⃣ 使用 Xcode 或 Transporter 上传 .ipa 文件
5️⃣ 填写 App 需要审核的信息,截图等资料,提交审核即可 🎉


✅ 九、总结

环节 工具 关键点
证书生成 钥匙串访问 + Developer Portal 生成 .cer
描述文件 Developer Portal 生成 .mobileprovision
签名配置 Xcode 配对证书 + Profile
打包验证 Flutter CLI flutter build ipa
发布上传 App Store Connect 审核上线

💡 小贴士:
建议将 .p12.mobileprovision.cer 备份到安全云端,防止证书丢失。

SwiftUI 无限循环轮播图 支持手动控制

先看效果

无限轮播图.gif

前言

在移动应用开发中,轮播图(Banner)是一个非常常见的 UI 组件,用于展示广告、推荐内容或重要信息。虽然 SwiftUI 提供了 TabView 配合 .tabViewStyle(.page) 可以快速实现轮播效果,但它有一些局限性:

  • 无法实现真正的无限循环滚动
  • 难以实现精确的吸附效果
  • 自定义控制能力有限

本文将介绍如何使用 SwiftUI + UIKit 结合的方式,实现一个功能完善的无限循环轮播图组件,支持以下特性:

  • ✅ 真正的无限循环滚动
  • ✅ 自动轮播功能
  • ✅ 流畅的手势拖拽
  • ✅ 精确的卡片吸附
  • ✅ 外部控制接口(上一张、下一张、跳转指定索引)
  • ✅ 防抖处理避免重复触发

核心设计思路

1. 无限循环的实现原理

无限循环的关键在于复制内容。我们在原始内容的后面复制若干份相同的卡片,当滚动到边界时,通过瞬间重置 contentOffset 的方式跳转到等价位置,从而实现视觉上的无缝循环。

原始数据:[A, B, C]
实际渲染:[A, B, C, A, B, C, A, B, ...]

当用户滑到最后一张 C 继续向右滑时,会显示复制的 A,此时瞬间将 scrollView 的 offset 重置到第一个 A 的位置,用户完全感觉不到跳转。

2. 组件架构

整个轮播图组件由三部分组成:

  1. BannerView - SwiftUI 视图,负责 UI 渲染
  2. ScrollViewHelper - UIViewRepresentable,桥接 UIScrollView
  3. LoopingScrollController - 滚动控制器,提供外部控制接口

核心代码实现

1. BannerView - 主视图

struct BannerView<Content: View, Item: RandomAccessCollection>: View where Item.Element: Identifiable {
    var width: CGFloat
    var spacing: CGFloat = 0
    var items: Item
    var controller: LoopingScrollController? = nil
    @Binding var currentIndex: Int
    @ViewBuilder var content: (Int, Item.Element) -> Content
    
    @State private var hasAppear = false
    
    var body: some View {
        GeometryReader {
            let size = $0.size
            let itemsArray = Array(items)
            
            guard !itemsArray.isEmpty, width > 0 else {
                return AnyView(EmptyView())
            }
            
            // 计算需要重复的次数,至少需要2倍来保证无缝循环
            let repeatingCount = max(Int((size.width / width).rounded()) + 2, 2)
            
            return AnyView(
                ScrollView(.horizontal) {
                    LazyHStack(spacing: spacing) {
                        // 原始卡片
                        ForEach(Array(itemsArray.enumerated()), id: \.element.id) { index, item in
                            content(index, item)
                                .frame(width: width)
                        }
                        
                        // 复制的卡片 - 使用唯一的 id 避免冲突
                        ForEach(0 ..< repeatingCount, id: \.self) { repeatIndex in
                            let actualIndex = repeatIndex % itemsArray.count
                            let item = itemsArray[actualIndex]
                            content(actualIndex, item)
                                .frame(width: width)
                                .id("repeat_\(repeatIndex)_\(item.id)")
                        }
                    }
                    .background() {
                        ScrollViewHelper(
                            width: width,
                            spacing: spacing,
                            itemsCount: items.count,
                            repeatingCount: repeatingCount,
                            controller: controller,
                            currentIndex: $currentIndex
                        )
                    }
                }
                .scrollIndicators(.hidden)
                .onAppear {
                    guard hasAppear == false else { return }
                    hasAppear = true
                    controller?.startAutoScroll() // 自动切换
                }
            )
        }
    }
}

关键点:

  • 使用 GeometryReader 获取容器尺寸
  • 通过 repeatingCount 计算需要复制的次数
  • 为重复卡片添加唯一 ID:"repeat_\(repeatIndex)_\(item.id)"
  • .background() 中嵌入 ScrollViewHelper 获取底层 UIScrollView

2. ScrollViewHelper - UIKit 桥接

这是整个组件最核心的部分,通过 UIViewRepresentable 协议桥接 UIScrollView,实现精确的滚动控制。

fileprivate struct ScrollViewHelper: UIViewRepresentable {
    var width: CGFloat
    var spacing: CGFloat
    var itemsCount: Int
    var repeatingCount: Int
    var controller: LoopingScrollController?
    @Binding var currentIndex: Int
    
    func makeCoordinator() -> Coordinator {
        let coordinator = Coordinator(
            width: width,
            spacing: spacing,
            itemsCount: itemsCount,
            repeatingCount: repeatingCount,
            currentIndex: $currentIndex
        )
        coordinator.controller = controller
        controller?.coordinator = coordinator
        return coordinator
    }
    
    func makeUIView(context: Context) -> UIView {
        return .init()
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        if !context.coordinator.isAdded {
            if let scrollView = uiView.superview?.superview?.superview as? UIScrollView {
                scrollView.delegate = context.coordinator
                scrollView.decelerationRate = .fast // 快速减速,配合吸附效果
                context.coordinator.scrollView = scrollView
                context.coordinator.isAdded = true
            } else {
                // 延迟重试
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
                    if let scrollView = uiView.superview?.superview?.superview as? UIScrollView,
                       !context.coordinator.isAdded {
                        scrollView.delegate = context.coordinator
                        scrollView.decelerationRate = .fast
                        context.coordinator.scrollView = scrollView
                        context.coordinator.isAdded = true
                    }
                }
            }
        }
        
        // 更新参数
        context.coordinator.width = width
        context.coordinator.spacing = spacing
        context.coordinator.itemsCount = itemsCount
        context.coordinator.repeatingCount = repeatingCount
    }
}

关键点:

  • 通过视图层级关系获取底层 UIScrollView:uiView.superview?.superview?.superview
  • 设置 decelerationRate = .fast 实现快速减速
  • 使用延迟重试机制确保 ScrollView 被正确获取

3. Coordinator - 滚动逻辑核心

Coordinator 负责处理所有的滚动逻辑,包括无限循环、吸附效果、用户交互等。

3.1 无限循环边界检测

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    guard itemsCount > 0 else { return }
    let minX = scrollView.contentOffset.x
    let mainContentSize = CGFloat(itemsCount) * width
    let spacingSize = CGFloat(itemsCount) * spacing
    let sumLength = mainContentSize + spacingSize
    
    // 手动控制滚动期间,跳过边界检查,避免干扰动画
    if !isProgrammaticScrolling {
        if minX > sumLength {
            scrollView.contentOffset.x -= sumLength
        }
        if minX < 0 {
            scrollView.contentOffset.x += sumLength
        }
    }
    
    // 更新当前索引(对 itemsCount 取模,返回实际的索引)
    let itemWidth = width + spacing
    if itemWidth > 0 {
        let rawIndex = Int((scrollView.contentOffset.x / itemWidth).rounded())
        currentIndex = rawIndex % itemsCount
    }
}

关键点:

  • 当滚动超出原始内容范围时,瞬间重置 offset
  • 通过取模运算 % 计算真实的索引位置
  • 使用 isProgrammaticScrolling 标志避免在动画过程中触发边界检查

3.2 精确的卡片吸附

func scrollViewWillEndDragging(
    _ scrollView: UIScrollView,
    withVelocity velocity: CGPoint,
    targetContentOffset: UnsafeMutablePointer<CGPoint>
) {
    guard itemsCount > 0, width > 0 else { return }
    
    let itemWidth = width + spacing
    let targetX = targetContentOffset.pointee.x
    
    // 计算最近的卡片索引(考虑速度方向)
    var targetIndex = round(targetX / itemWidth)
    
    // 如果速度较大,倾向于滑动到下一张/上一张
    if abs(velocity.x) > 0.5 {
        if velocity.x > 0 {
            targetIndex = ceil(targetX / itemWidth)
        } else {
            targetIndex = floor(targetX / itemWidth)
        }
    }
    
    // 计算应该吸附到的位置
    let snapOffset = targetIndex * itemWidth
    
    // 修改目标偏移量
    targetContentOffset.pointee.x = snapOffset
}

关键点:

  • scrollViewWillEndDragging 中直接修改 targetContentOffset 实现吸附
  • 根据速度方向智能判断应该吸附到哪一张
  • 速度阈值 0.5 可根据需求调整

3.3 滚动到下一张

func scrollToNext(animated: Bool) {
    guard let scrollView = scrollView, itemsCount > 0, isAdded else { return }
    
    let itemWidth = width + spacing
    let sumLength = CGFloat(itemsCount) * (width + spacing)
    let currentOffset = scrollView.contentOffset.x
    
    // 找到当前最接近的卡片位置
    let currentIndex = round(currentOffset / itemWidth)
    let alignedCurrentOffset = currentIndex * itemWidth
    
    // 计算下一张的位置
    let nextOffset = alignedCurrentOffset + itemWidth
    
    isProgrammaticScrolling = true
    
    // 检查是否会超出边界(需要循环)
    if nextOffset > sumLength {
        // 跳转到主区域的等价位置
        let normalizedAligned = alignedCurrentOffset.truncatingRemainder(dividingBy: sumLength)
        let actualAligned = normalizedAligned < 0 ? normalizedAligned + sumLength : normalizedAligned
        
        scrollView.contentOffset.x = actualAligned
        
        // 然后从新位置滚动到下一张
        let finalOffset = actualAligned + itemWidth
        scrollView.setContentOffset(CGPoint(x: finalOffset, y: 0), animated: animated)
    } else {
        // 正常滚动
        if abs(currentOffset - alignedCurrentOffset) > 0.5 {
            scrollView.contentOffset.x = alignedCurrentOffset
        }
        scrollView.setContentOffset(CGPoint(x: nextOffset, y: 0), animated: animated)
    }
    
    if !animated {
        isProgrammaticScrolling = false
    }
}

关键点:

  • 先对齐到当前卡片位置,再滚动到下一张
  • 使用 isProgrammaticScrolling 标志避免触发边界检查
  • 通过 truncatingRemainder 计算等价位置实现循环

4. LoopingScrollController - 外部控制器

提供外部控制接口,支持手动切换、自动轮播等功能。

class LoopingScrollController: ObservableObject {
    fileprivate weak var coordinator: ScrollViewHelper.Coordinator?
    private var timer: Timer?
    private var autoScrollInterval: TimeInterval = 3.0
    @Published var isAutoScrolling: Bool = false
    
    // 防抖相关属性
    private var lastScrollToNextTime: Date?
    private var lastScrollToPreviousTime: Date?
    private let debounceInterval: TimeInterval = 0.3
    
    // 滚动到下一张(带防抖)
    func scrollToNext(animated: Bool = true) {
        guard let coordinator = coordinator else { return }
        
        // 检查是否正在滚动或用户正在拖拽
        if coordinator.isProgrammaticScrolling || coordinator.isUserScrolling {
            return
        }
        
        // 时间防抖
        let currentTime = Date()
        if let lastTime = lastScrollToNextTime {
            let timeInterval = currentTime.timeIntervalSince(lastTime)
            if timeInterval < debounceInterval {
                return
            }
        }
        
        lastScrollToNextTime = currentTime
        coordinator.scrollToNext(animated: animated)
    }
    
    // 开始自动滚动
    func startAutoScroll(interval: TimeInterval = 3.0) {
        autoScrollInterval = interval
        restartAutoScroll()
    }
    
    // 重启自动滚动
    fileprivate func restartAutoScroll() {
        stopTimer()
        isAutoScrolling = true
        timer = Timer.scheduledTimer(
            withTimeInterval: autoScrollInterval,
            repeats: true
        ) { [weak self] _ in
            self?.scrollToNext(animated: true)
        }
    }
    
    // 停止自动滚动
    func stopAutoScroll() {
        stopTimer()
        isAutoScrolling = false
    }
    
    // 暂停定时器(用户拖拽时)
    fileprivate func pauseTimer() {
        stopTimer()
    }
    
    private func stopTimer() {
        timer?.invalidate()
        timer = nil
    }
}

关键点:

  • 使用 Timer 实现自动轮播
  • 通过双重防抖机制避免重复触发:状态检查 + 时间间隔
  • 用户拖拽时暂停定时器,松手后恢复

使用示例

struct ExploreTopView: View {
    @StateObject private var scrollController = LoopingScrollController()
    @State private var currentBannerIndex: Int = 0
    
    var body: some View {
        GeometryReader { geo in
            let bannerWidth = geo.size.width * 0.6
            
            // 创建数据
            let items: [Item] = bannerCards.map { Item(game: $0) }
            
            BannerView(
                width: bannerWidth,
                spacing: 10,
                items: items,
                controller: scrollController,
                currentIndex: $currentBannerIndex
            ) { index, item in
                // 自定义卡片内容
                WebImage(url: URL(string: item.game.content_img))
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .clipped()
                    .cornerRadius(12)
                    .onTapGesture {
                        print("点击了第 \(index) 张卡片")
                    }
                    .overlay(
                        // 添加渐变遮罩和文字
                        VStack(alignment: .leading) {
                            Spacer()
                            Text(item.game.title)
                                .font(.headline)
                                .foregroundColor(.white)
                                .padding()
                        }
                    )
            }
            .overlay(
                // 分页指示器
                HStack(spacing: 5) {
                    ForEach(0..<items.count, id: \.self) { i in
                        Circle()
                            .fill(i == currentBannerIndex ? Color.white : Color.white.opacity(0.4))
                            .frame(width: 6, height: 6)
                    }
                }
                .padding(.bottom, 24)
                , alignment: .bottomTrailing
            )
        }
    }
}

高级用法:外部控制

// 焦点控制:获得焦点时停止自动轮播
.onChange(of: isFocused) { _, newValue in
    if newValue {
        scrollController.stopAutoScroll()
    } else {
        scrollController.startAutoScroll()
    }
}

// 手柄控制:左右切换
.onReceive(viewModel.$isNeedSwiftLeft) { need in
    guard need else { return }
    scrollController.scrollToPrevious()
}
.onReceive(viewModel.$isNeedSwiftRight) { need in
    guard need else { return }
    scrollController.scrollToNext()
}

// 跳转到指定索引
Button("跳转到第3张") {
    scrollController.scrollToIndex(2, animated: true)
}

优化要点

1. 性能优化

  • 使用 LazyHStack:只渲染可见区域的视图,减少内存占用
  • 复制次数动态计算:根据屏幕宽度动态计算需要复制的次数,避免过度渲染
  • 防抖机制:避免频繁触发滚动导致的性能问题

2. 用户体验优化

  • 快速减速:设置 decelerationRate = .fast 让滑动更跟手
  • 智能吸附:根据滑动速度判断应该停在哪一张
  • 拖拽时暂停:用户拖拽时暂停自动轮播,松手后恢复

3. 边界情况处理

  • 空数据保护guard !itemsArray.isEmpty 避免崩溃
  • 延迟重试:确保 UIScrollView 被正确获取
  • 动画期间保护:使用 isProgrammaticScrolling 标志避免冲突

总结

本文介绍了一个功能完善的 SwiftUI 无限循环轮播图实现方案,核心思路是:

  1. 内容复制 - 通过复制卡片实现视觉上的无限循环
  2. UIKit 桥接 - 利用 UIScrollView 的强大能力实现精确控制
  3. 边界重置 - 在滚动到边界时瞬间重置 offset
  4. 智能吸附 - 根据速度和位置智能判断停靠位置
  5. 防抖保护 - 多重防抖机制避免重复触发

相比 SwiftUI 原生的 TabView,这个方案具有更好的可控性和扩展性,适合复杂的业务场景。

完整代码已在生产环境验证,运行流畅稳定。希望这篇文章能帮助你实现更优秀的轮播图组件!

源码在这

参考资料


如果觉得这篇文章对你有帮助,欢迎点赞收藏!有任何问题欢迎在评论区讨论 🎉

《Flutter全栈开发实战指南:从零到高级》- 08 -导航与路由管理

前言

在移动应用开发中,页面跳转和导航是必不可少的功能。想象一下,如果微信不能从聊天列表跳转到具体聊天窗口,或者淘宝不能从商品列表进入商品详情,这样的应用体验会是多么糟糕!Flutter提供了一套强大而灵活的导航系统,今天我们就来深入探讨Flutter中的导航与路由管理。

无论你是刚接触Flutter的新手,还是有一定经验的开发者,掌握好路由管理都是提升开发效率和应用质量的关键。让我们开始学习吧!!!

Snipaste_2025-10-30_09-28-18.png

一:理解Flutter导航的基础概念

1.1 什么是导航栈?

原理解析: Flutter的导航系统基于栈-Stack数据结构。可以把导航栈理解成一叠扑克牌:

  • 初始状态:只有一张牌(首页)
  • 跳转新页面:在牌堆顶部添加新牌
  • 返回上一页:从牌堆顶部移除当前牌
// 导航栈的直观示例
// 初始栈:[HomePage]
Navigator.push(context, MaterialPageRoute(builder: (_) => DetailPage()));
// 现在栈:[HomePage, DetailPage]
Navigator.pop(context);
// 回到栈:[HomePage]

深度理解: 每个页面(Route)在栈中都是一个独立的对象,它们按照"后进先出"(LIFO)的原则管理。这种设计确保了用户可以通过返回按钮按顺序回溯浏览历史。

1.2 Navigator 的核心作用

Navigator 是Flutter中管理导航栈的组件,提供了丰富的方法来控制页面跳转:

  • push:压入新页面
  • pop:弹出当前页面
  • pushReplacement:替换当前页面
  • pushAndRemoveUntil:跳转并清理历史页面

二:基础导航

2.1 最简单的页面跳转

让我们从一个最基本的例子开始:

class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('首页')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // 核心跳转代码:使用Navigator.push
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (context) => const DetailPage(), // 创建目标页面
              ),
            );
          },
          child: const Text('进入详情页'),
        ),
      ),
    );
  }
}

class DetailPage extends StatelessWidget {
  const DetailPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('详情页')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // 返回上一页
            Navigator.pop(context);
          },
          child: const Text('返回'),
        ),
      ),
    );
  }
}

代码详解

  • Navigator.push:这是最核心的跳转方法,接收两个参数:

    • context:构建上下文,用于找到最近的Navigator
    • Route:路由对象,这里使用MaterialPageRoute
  • MaterialPageRoute:Material Design风格的路由,提供平台一致的过渡动画

  • Navigator.pop:关闭当前页面,返回上一级

2.2 导航栈状态

让我们画张图来深入理解导航栈的完整生命周期:

sequenceDiagram
    participant U as 用户
    participant N as Navigator
    participant H as HomePage
    participant D as DetailPage
    participant S as SettingsPage

    U->>H: 打开应用
    Note over N: 栈状态: [HomePage]
    
    U->>H: 点击"进入详情"
    H->>N: Navigator.push(DetailPage)
    N->>D: 创建DetailPage
    Note over N: 栈状态: [HomePage, DetailPage]
    
    U->>D: 点击"进入设置" 
    D->>N: Navigator.push(SettingsPage)
    N->>S: 创建SettingsPage
    Note over N: 栈状态: [HomePage, DetailPage, SettingsPage]
    
    U->>S: 点击"返回"
    S->>N: Navigator.pop()
    N->>S: 销毁SettingsPage
    Note over N: 栈状态: [HomePage, DetailPage]
    
    U->>D: 点击"返回"
    D->>N: Navigator.pop()
    N->>D: 销毁DetailPage
    Note over N: 栈状态: [HomePage]

2.3 多种跳转方式

除了基本的push和pop,Navigator还提供了其他有用的跳转方法:

// 1. 替换当前页面(不会保留当前页面在栈中)
Navigator.pushReplacement(
  context,
  MaterialPageRoute(builder: (context) => const NewPage()),
);

// 2. 跳转到新页面并移除之前的所有页面
Navigator.pushAndRemoveUntil(
  context,
  MaterialPageRoute(builder: (context) => const HomePage()),
  (route) => false, // 返回false表示移除所有路由
);

// 3. 跳转到新页面,保留指定页面
Navigator.pushAndRemoveUntil(
  context,
  MaterialPageRoute(builder: (context) => const ProfilePage()),
  ModalRoute.withName('/home'), // 只保留首页
);

三:命名路由

3.1 为什么需要命名路由?

在小型应用中,直接使用MaterialPageRoute可能没问题。但随着应用规模扩大,问题会逐渐暴露:

问题场景

// 问题代码:硬编码路由
Navigator.push(context, MaterialPageRoute(builder: (_) => ProductPage()));
Navigator.push(context, MaterialPageRoute(builder: (_) => CartPage()));
Navigator.push(context, MaterialPageRoute(builder: (_) => CheckoutPage()));
// 在多个地方都这样写,如果要修改页面构造函数...

解决方案:命名路由统一管理

3.2 配置命名路由系统

3.2.1 创建路由配置类

// routes/app_routes.dart
class AppRoutes {
  // 路由名称常量
  static const String splash = '/';
  static const String home = '/home';
  static const String productList = '/products';
  static const String productDetail = '/product/detail';
  static const String cart = '/cart';
  static const String checkout = '/checkout';
  static const String profile = '/profile';
  static const String login = '/login';
  static const String notFound = '/404';

  // 路由表配置
  static Map<String, WidgetBuilder> get routes {
    return {
      splash: (context) => const SplashPage(),
      home: (context) => const HomePage(),
      productList: (context) => const ProductListPage(),
      productDetail: (context) => const ProductDetailPage(),
      cart: (context) => const CartPage(),
      checkout: (context) => const CheckoutPage(),
      profile: (context) => const ProfilePage(),
      login: (context) => const LoginPage(),
    };
  }

  // 路由守卫配置
  static bool requiresAuth(String routeName) {
    final authRoutes = [profile, checkout];
    return authRoutes.contains(routeName);
  }

  // 获取路由名称
  static String getRouteTitle(String routeName) {
    final titles = {
      home: '首页',
      productList: '商品列表',
      cart: '购物车',
      profile: '个人中心',
    };
    return titles[routeName] ?? '未知页面';
  }
}

3.2.2 在MaterialApp中配置

// main.dart
class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '电商应用',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      // 初始路由
      initialRoute: AppRoutes.splash,
      // 路由表
      routes: AppRoutes.routes,
      // 未知路由处理(404页面)
      onUnknownRoute: (settings) {
        return MaterialPageRoute(
          builder: (context) => const NotFoundPage(),
        );
      },
    );
  }
}

3.2.3 使用命名路由跳转

// 在任意页面中使用命名路由
class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('首页')),
      body: ListView(
        children: [
          ListTile(
            title: const Text('商品列表'),
            onTap: () {
              // 使用命名路由跳转
              Navigator.pushNamed(context, AppRoutes.productList);
            },
          ),
          ListTile(
            title: const Text('购物车'),
            onTap: () {
              Navigator.pushNamed(context, AppRoutes.cart);
            },
          ),
          ListTile(
            title: const Text('个人中心'),
            onTap: () {
              Navigator.pushNamed(context, AppRoutes.profile);
            },
          ),
        ],
      ),
    );
  }
}

3.3 命名路由的优势

通过这种配置方式,有以下好处:

  1. 集中管理:所有路由配置在一个文件中,修改维护方便
  2. 类型安全:使用常量避免字符串拼写错误
  3. 智能提示:IDE可以提供自动补全
  4. 易于重构:修改页面类名只需改动一处

四:参数传递

4.1 页面间数据传递的常见场景

在实际应用中,页面跳转几乎总是需要传递数据:

  • 商品列表 → 商品详情:传递商品ID
  • 用户选择 → 结果返回:传递用户选择项
  • 编辑页面 → 保存返回:传递编辑结果

4.2 构造函数传参(推荐方式)

这是最直接、最类型安全的方式:

// 商品详情页 - 明确声明需要接收的参数
class ProductDetailPage extends StatelessWidget {
  final String productId;
  final String productName;
  final double price;
  
  const ProductDetailPage({
    Key? key,
    required this.productId,
    required this.productName,
    required this.price,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(productName)),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('商品ID: $productId', style: const TextStyle(fontSize: 16)),
            Text('商品名称: $productName', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
            Text('价格: ¥$price', style: const TextStyle(fontSize: 18, color: Colors.red)),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () => Navigator.pop(context),
              child: const Text('返回'),
            ),
          ],
        ),
      ),
    );
  }
}

// 在商品列表页中跳转并传递参数
class ProductListPage extends StatelessWidget {
  final List<Product> products = [
    Product(id: '1', name: 'iPhone 14', price: 5999),
    Product(id: '2', name: 'MacBook Pro', price: 12999),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('商品列表')),
      body: ListView.builder(
        itemCount: products.length,
        itemBuilder: (context, index) {
          final product = products[index];
          return ListTile(
            title: Text(product.name),
            subtitle: Text(${product.price}'),
            trailing: const Icon(Icons.arrow_forward),
            onTap: () {
              // 跳转到详情页并传递参数
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (context) => ProductDetailPage(
                    productId: product.id,
                    productName: product.name,
                    price: product.price,
                  ),
                ),
              );
            },
          );
        },
      ),
    );
  }
}

// 商品数据模型
class Product {
  final String id;
  final String name;
  final double price;

  const Product({
    required this.id,
    required this.name,
    required this.price,
  });
}

构造函数传参的优势:编译时类型检查、代码可读性高、IDE支持良好,同时便于后期重构。

4.3 命名路由的参数传递

当使用命名路由时,我们需要通过arguments参数传递数据:

4.3.1 配置支持参数的路由

// 修改AppRoutes配置
class AppRoutes {
  static Route<dynamic>? onGenerateRoute(RouteSettings settings) {
    switch (settings.name) {
      case productDetail:
        final args = settings.arguments as Map<String, dynamic>?;
        return MaterialPageRoute(
          builder: (context) => ProductDetailPage(
            productId: args?['productId'] ?? '',
            productName: args?['productName'] ?? '未知商品',
            price: args?['price'] ?? 0.0,
          ),
        );
      // 其他路由配置...
      default:
        return null;
    }
  }
}

// 在MaterialApp中配置
MaterialApp(
  // ... 其他配置
  onGenerateRoute: AppRoutes.onGenerateRoute,
)

4.3.2 跳转时传递参数

// 跳转时传递参数
ElevatedButton(
  onPressed: () {
    Navigator.pushNamed(
      context,
      AppRoutes.productDetail,
      arguments: {
        'productId': '12345',
        'productName': '高端智能手机',
        'price': 5999.0,
      },
    );
  },
  child: const Text('查看商品详情'),
)

4.4 返回时传递数据

这是一个非常重要的场景,比如从选择页面返回用户选择的结果:

// 颜色选择页面
class ColorSelectionPage extends StatelessWidget {
  const ColorSelectionPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('选择颜色')),
      body: ListView(
        children: [
          _buildColorOption(context, '红色', Colors.red),
          _buildColorOption(context, '蓝色', Colors.blue),
          _buildColorOption(context, '绿色', Colors.green),
          _buildColorOption(context, '黄色', Colors.yellow),
        ],
      ),
    );
  }

  Widget _buildColorOption(BuildContext context, String colorName, Color color) {
    return ListTile(
      leading: Container(
        width: 40,
        height: 40,
        color: color,
      ),
      title: Text(colorName),
      onTap: () {
        // 返回选择的颜色信息
        Navigator.pop(context, {
          'colorName': colorName,
          'colorValue': color.value,
        });
      },
    );
  }
}

// 主页面 - 接收返回数据
class ProductCustomizePage extends StatefulWidget {
  const ProductCustomizePage({Key? key}) : super(key: key);

  @override
  State<ProductCustomizePage> createState() => _ProductCustomizePageState();
}

class _ProductCustomizePageState extends State<ProductCustomizePage> {
  String? selectedColor;
  int? selectedColorValue;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('商品定制')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            // 显示当前选择的颜色
            if (selectedColor != null)
              Container(
                padding: const EdgeInsets.all(16),
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.grey),
                  borderRadius: BorderRadius.circular(8),
                ),
                child: Row(
                  children: [
                    Container(
                      width: 30,
                      height: 30,
                      color: Color(selectedColorValue!),
                    ),
                    const SizedBox(width: 10),
                    Text('已选择: $selectedColor'),
                  ],
                ),
              ),
            
            const SizedBox(height: 20),
            
            ElevatedButton(
              onPressed: () async {
                // 跳转到颜色选择页并等待返回结果
                final result = await Navigator.push(
                  context,
                  MaterialPageRoute(
                    builder: (context) => const ColorSelectionPage(),
                  ),
                );
                
                // 处理返回结果
                if (result != null && mounted) {
                  setState(() {
                    selectedColor = result['colorName'] as String;
                    selectedColorValue = result['colorValue'] as int;
                  });
                }
              },
              child: const Text('选择颜色'),
            ),
          ],
        ),
      ),
    );
  }
}

4.5 复杂数据传递

对于复杂的数据传递,建议使用统一的数据模型:

// 定义统一的路由参数模型
class RouteArguments {
  final Map<String, dynamic> data;
  
  RouteArguments(this.data);
  
  // 便捷方法
  String getString(String key, [String defaultValue = '']) {
    return data[key]?.toString() ?? defaultValue;
  }
  
  int getInt(String key, [int defaultValue = 0]) {
    return data[key] as int? ?? defaultValue;
  }
  
  double getDouble(String key, [double defaultValue = 0.0]) {
    return data[key] as double? ?? defaultValue;
  }
  
  bool getBool(String key, [bool defaultValue = false]) {
    return data[key] as bool? ?? defaultValue;
  }
}

// 使用示例
Navigator.pushNamed(
  context,
  AppRoutes.productDetail,
  arguments: RouteArguments({
    'productId': '123',
    'productName': '测试商品',
    'price': 99.9,
    'inStock': true,
  }),
);

五:路由守卫与权限控制

5.1 什么是路由守卫?

路由守卫就像是应用的"保安系统",在用户访问特定页面之前进行检查:

  • 身份验证:用户是否已登录?
  • 权限检查:用户是否有访问权限?
  • 数据预加载:页面是否需要预加载数据?
  • 日志记录:记录用户访问行为

5.2 实现完整的路由守卫系统

5.2.1 创建认证状态管理

// auth/auth_manager.dart
class AuthManager with ChangeNotifier {
  static final AuthManager _instance = AuthManager._internal();
  factory AuthManager() => _instance;
  AuthManager._internal();
  
  User? _currentUser;
  bool get isLoggedIn => _currentUser != null;
  User? get currentUser => _currentUser;
  
  // 模拟一个登录
  Future<bool> login(String email, String password) async {
    // 实际项目中这里应该是API调用
    await Future.delayed(const Duration(seconds: 1));
    
    if (email == 'user@example.com' && password == 'password') {
      _currentUser = User(
        id: '1',
        email: email,
        name: '测试用户',
      );
      notifyListeners();
      return true;
    }
    return false;
  }
  
  // 退出登录
  void logout() {
    _currentUser = null;
    notifyListeners();
  }
}

// 用户模型
class User {
  final String id;
  final String email;
  final String name;
  
  const User({
    required this.id,
    required this.email,
    required this.name,
  });
}

5.2.2 实现路由守卫

// routes/route_guard.dart
class RouteGuard {
  static Future<Route?> checkPermission(
    RouteSettings settings, 
    AuthManager authManager
  ) async {
    final String routeName = settings.name ?? '/';
    
    print('路由守卫检查: $routeName');
    
    // 1. 登录状态检查
    if (_requiresAuth(routeName) && !authManager.isLoggedIn) {
      print('访问受限页面需要登录');
      return _redirectToLogin(routeName);
    }
    
    // 2. 权限检查
    if (!_checkUserPermission(routeName, authManager.currentUser)) {
      print('用户权限不足');
      return _redirectToHome();
    }
    
    // 3. 页面维护检查
    if (_isUnderMaintenance(routeName)) {
      print('页面维护中');
      return _redirectToMaintenancePage();
    }
    
    // 4. 记录访问日志
    _logAccess(routeName, authManager.currentUser?.id);
    
    // 允许访问
    return null;
  }
  
  // 检查路由是否需要认证
  static bool _requiresAuth(String routeName) {
    const protectedRoutes = [
      AppRoutes.profile,
      AppRoutes.cart,
      AppRoutes.checkout,
    ];
    return protectedRoutes.contains(routeName);
  }
  
  // 检查用户权限
  static bool _checkUserPermission(String routeName, User? user) {
    // 这里可以实现更复杂的权限逻辑
    // 比如管理员权限、VIP权限等
    return true; // 默认允许访问
  }
  
  // 检查页面是否在维护中
  static bool _isUnderMaintenance(String routeName) {
    // 可以从配置文件中读取维护状态
    return false;
  }
  
  // 跳转到登录页
  static MaterialPageRoute _redirectToLogin(String originalRoute) {
    return MaterialPageRoute(
      builder: (context) => LoginPage(
        returnToRoute: originalRoute,
      ),
      settings: const RouteSettings(name: AppRoutes.login),
    );
  }
  
  // 跳转到首页
  static MaterialPageRoute _redirectToHome() {
    return MaterialPageRoute(
      builder: (context) => const HomePage(),
      settings: const RouteSettings(name: AppRoutes.home),
    );
  }
  
  // 跳转到维护页面
  static MaterialPageRoute _redirectToMaintenancePage() {
    return MaterialPageRoute(
      builder: (context) => const MaintenancePage(),
    );
  }
  
  // 记录访问日志
  static void _logAccess(String routeName, String? userId) {
    final timestamp = DateTime.now().toIso8601String();
    print('访问记录 - 用户: $userId, 路由: $routeName, 时间: $timestamp');
    
    // 实际项目中可以发送到日志服务器
  }
}

5.2.3 集成路由守卫

// main.dart
class MyApp extends StatelessWidget {
  final AuthManager authManager = AuthManager();
  
  MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider.value(
      value: authManager,
      child: MaterialApp(
        title: 'Flutter路由守卫示例',
        theme: ThemeData(primarySwatch: Colors.blue),
        initialRoute: AppRoutes.splash,
        routes: AppRoutes.routes,
        onGenerateRoute: (RouteSettings settings) async {
          // 调用路由守卫检查
          final guardedRoute = await RouteGuard.checkPermission(settings, authManager);
          if (guardedRoute != null) {
            return guardedRoute;
          }
          
          // 正常路由处理
          return AppRoutes.onGenerateRoute(settings);
        },
        navigatorObservers: [AppRoutes.routeObserver],
      ),
    );
  }
}

5.3 路由守卫工作流程详解

通过一张流程图来理解路由守卫的完整工作流程:

graph TD
    A[用户点击跳转] --> B[触发路由守卫]
    B --> C{需要登录?}
    C -->|是| D{已登录?}
    C -->|否| F{有权限?}
    D -->|是| F
    D -->|否| E[跳转到登录页]
    F -->|是| G{页面维护中?}
    F -->|否| H[跳转到首页<br/>权限不足]
    G -->|是| I[跳转到维护页]
    G -->|否| J[记录访问日志]
    J --> K[正常跳转目标页]
    E --> L[登录成功?]
    L -->|是| M[跳转原目标页]
    L -->|否| N[停留在当前页]

5.4 导航观察者与日志记录

// routes/route_observer.dart
class CustomRouteObserver extends NavigatorObserver {
  @override
  void didPush(Route route, Route? previousRoute) {
    super.didPush(route, previousRoute);
    _logRouteChange('打开页面', route, previousRoute);
  }

  @override
  void didPop(Route route, Route? previousRoute) {
    super.didPop(route, previousRoute);
    _logRouteChange('关闭页面', route, previousRoute);
  }

  @override
  void didReplace({Route? newRoute, Route? oldRoute}) {
    super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
    _logRouteChange('替换页面', newRoute, oldRoute);
  }

  void _logRouteChange(String action, Route? currentRoute, Route? previousRoute) {
    final currentName = currentRoute?.settings.name ?? '未知页面';
    final previousName = previousRoute?.settings.name ?? '未知页面';
    
    print('$action: $previousName$currentName');
    
    // 在实际项目中,可以发送到分析平台
    _sendToAnalytics(action, currentName, previousName);
  }
  
  void _sendToAnalytics(String action, String current, String previous) {
    // 集成Firebase Analytics或其他分析工具
    // FirebaseAnalytics().logEvent(
    //   name: 'route_change',
    //   parameters: {
    //     'action': action,
    //     'current_route': current,
    //     'previous_route': previous,
    //     'timestamp': DateTime.now().millisecondsSinceEpoch,
    //   },
    // );
  }
}

六:高级路由技巧与性能优化

6.1 自定义路由过渡动画

默认的Material页面过渡动画可能不适合所有场景,我们可以创建自定义动画:

// animations/custom_route_animations.dart
class FadePageRoute extends PageRouteBuilder {
  final Widget page;

  FadePageRoute({required this.page})
      : super(
          pageBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
          ) => page,
          transitionsBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
            Widget child,
          ) => FadeTransition(
            opacity: animation,
            child: child,
          ),
          transitionDuration: const Duration(milliseconds: 500),
        );
}

class SlideLeftRoute extends PageRouteBuilder {
  final Widget page;

  SlideLeftRoute({required this.page})
      : super(
          pageBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
          ) => page,
          transitionsBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
            Widget child,
          ) => SlideTransition(
            position: Tween<Offset>(
              begin: const Offset(1.0, 0.0),
              end: Offset.zero,
            ).animate(animation),
            child: child,
          ),
          transitionDuration: const Duration(milliseconds: 300),
        );
}

class ScaleRoute extends PageRouteBuilder {
  final Widget page;

  ScaleRoute({required this.page})
      : super(
          pageBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
          ) => page,
          transitionsBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
            Widget child,
          ) => ScaleTransition(
            scale: Tween<double>(
              begin: 0.0,
              end: 1.0,
            ).animate(
              CurvedAnimation(
                parent: animation,
                curve: Curves.fastOutSlowIn,
              ),
            ),
            child: child,
          ),
          transitionDuration: const Duration(milliseconds: 400),
        );
}

// 使用自定义动画
Navigator.push(context, FadePageRoute(page: const DetailPage()));
Navigator.push(context, SlideLeftRoute(page: const SettingsPage()));
Navigator.push(context, ScaleRoute(page: const ProfilePage()));

6.2 路由性能优化策略

6.2.1 页面懒加载

对于复杂页面,可以使用FutureBuilder实现懒加载:

class LazyProductDetailPage extends StatelessWidget {
  final String productId;
  
  const LazyProductDetailPage({Key? key, required this.productId}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<Product>(
      future: _loadProductDetail(productId),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Scaffold(
            body: Center(child: CircularProgressIndicator()),
          );
        }
        
        if (snapshot.hasError) {
          return Scaffold(
            appBar: AppBar(title: const Text('错误')),
            body: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Icon(Icons.error, size: 64, color: Colors.red),
                  const SizedBox(height: 16),
                  Text('加载失败: ${snapshot.error}'),
                  const SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: () => Navigator.pop(context),
                    child: const Text('返回'),
                  ),
                ],
              ),
            ),
          );
        }
        
        final product = snapshot.data!;
        return ProductDetailPage(product: product);
      },
    );
  }
  
  Future<Product> _loadProductDetail(String id) async {
    // 网络请求
    await Future.delayed(const Duration(seconds: 2));
    return Product(
      id: id,
      name: '商品 $id',
      price: 100.0 * int.parse(id),
      description: '这是商品的详细描述...',
    );
  }
}

6.2.2 使用const构造函数优化

// 优化前的代码
class ProductDetailPage extends StatelessWidget {
  final Product product;
  
  ProductDetailPage({Key? key, required this.product}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(product.name)),
      body: Column(
        children: [
          Image.network(product.imageUrl), // 每次重建都会重新加载图片
          Text(product.name), // 每次重建都会重新创建Text widget
          Text(${product.price}'),
        ],
      ),
    );
  }
}

// 优化后的代码
class OptimizedProductDetailPage extends StatelessWidget {
  final Product product;
  
  const OptimizedProductDetailPage({Key? key, required this.product}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(product.name)),
      body: Column(
        children: [
          // 使用CachedNetworkImage避免重复加载
          CachedNetworkImage(imageUrl: product.imageUrl),
          // 使用const Text widget
          const _ProductNameText(product.name),
          const _ProductPriceText(product.price),
        ],
      ),
    );
  }
}

// 提取为const widget
class _ProductNameText extends StatelessWidget {
  final String name;
  
  const _ProductNameText(this.name);

  @override
  Widget build(BuildContext context) {
    return Text(
      name,
      style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
    );
  }
}

class _ProductPriceText extends StatelessWidget {
  final double price;
  
  const _ProductPriceText(this.price);

  @override
  Widget build(BuildContext context) {
    return Text(
      $price',
      style: const TextStyle(fontSize: 18, color: Colors.red),
    );
  }
}

七:电商应用路由系统案例

让我们构建一个完整的电商应用路由系统:

7.1 完整的项目架构

lib/
├── main.dart
├── routes/
│   ├── app_routes.dart
│   ├── route_guard.dart
│   └── route_observer.dart
├── models/
│   ├── product.dart
│   ├── user.dart
│   └── route_arguments.dart
├── pages/
│   ├── splash_page.dart
│   ├── home_page.dart
│   ├── product/
│   │   ├── product_list_page.dart
│   │   ├── product_detail_page.dart
│   │   └── product_search_page.dart
│   ├── cart/
│   │   ├── cart_page.dart
│   │   └── checkout_page.dart
│   ├── user/
│   │   ├── login_page.dart
│   │   ├── profile_page.dart
│   │   └── order_history_page.dart
│   └── common/
│       ├── not_found_page.dart
│       └── maintenance_page.dart
├── widgets/
│   ├── bottom_navigation.dart
│   └── route_aware_widget.dart
└── services/
    ├── auth_service.dart
    └── analytics_service.dart

7.2 核心路由配置

// routes/app_routes.dart
class AppRoutes {
  // 路由常量
  static const String splash = '/';
  static const String home = '/home';
  static const String productList = '/products';
  static const String productDetail = '/product/detail';
  static const String productSearch = '/product/search';
  static const String cart = '/cart';
  static const String checkout = '/checkout';
  static const String login = '/login';
  static const String profile = '/profile';
  static const String orders = '/orders';
  static const String notFound = '/404';

  // 路由观察者
  static final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();

  // 路由表
  static Map<String, WidgetBuilder> get routes {
    return {
      splash: (context) => const SplashPage(),
      home: (context) => const HomePage(),
      productList: (context) => const ProductListPage(),
      productSearch: (context) => const ProductSearchPage(),
      cart: (context) => const CartPage(),
      login: (context) => const LoginPage(),
    };
  }

  // 动态路由生成
  static Route<dynamic>? onGenerateRoute(RouteSettings settings) {
    final args = settings.arguments;

    switch (settings.name) {
      case productDetail:
        final productArgs = args as ProductDetailArguments;
        return MaterialPageRoute(
          builder: (context) => ProductDetailPage(
            productId: productArgs.productId,
            fromSearch: productArgs.fromSearch,
          ),
        );
      
      case checkout:
        final cartItems = args as List<CartItem>;
        return MaterialPageRoute(
          builder: (context) => CheckoutPage(cartItems: cartItems),
        );
      
      case profile:
        return MaterialPageRoute(
          builder: (context) => const ProfilePage(),
        );
      
      case orders:
        return MaterialPageRoute(
          builder: (context) => const OrderHistoryPage(),
        );
      
      default:
        return null;
    }
  }

  // 便捷跳转方法
  static Future<void> toProductDetail(
    BuildContext context, {
    required String productId,
    bool fromSearch = false,
  }) async {
    await Navigator.pushNamed(
      context,
      productDetail,
      arguments: ProductDetailArguments(
        productId: productId,
        fromSearch: fromSearch,
      ),
    );
  }

  static Future<void> toCheckout(
    BuildContext context, {
    required List<CartItem> cartItems,
  }) async {
    await Navigator.pushNamed(
      context,
      checkout,
      arguments: cartItems,
    );
  }

  // 重置到首页
  static void resetToHome(BuildContext context) {
    Navigator.pushNamedAndRemoveUntil(
      context,
      home,
      (route) => false,
    );
  }
}

// 参数模型
class ProductDetailArguments {
  final String productId;
  final bool fromSearch;

  ProductDetailArguments({
    required this.productId,
    this.fromSearch = false,
  });
}

7.3 底部导航与路由集成

// widgets/bottom_navigation.dart
class AppBottomNavigation extends StatefulWidget {
  const AppBottomNavigation({Key? key}) : super(key: key);

  @override
  State<AppBottomNavigation> createState() => _AppBottomNavigationState();
}

class _AppBottomNavigationState extends State<AppBottomNavigation> {
  int _currentIndex = 0;

  final _pages = [
    const HomePage(),
    const ProductListPage(),
    const CartPage(),
    const ProfilePage(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: IndexedStack(
        index: _currentIndex,
        children: _pages,
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (index) {
          setState(() {
            _currentIndex = index;
          });
        },
        items: const [
          BottomNavigationBarItem(
            icon: Icon(Icons.home),
            label: '首页',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.shopping_bag),
            label: '商品',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.shopping_cart),
            label: '购物车',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.person),
            label: '我的',
          ),
        ],
      ),
    );
  }
}

八:调试与问题排查

8.1 常见路由问题及解决方案

8.1.1 路由跳转失败

// 问题:路由名不存在
// 错误代码:
Navigator.pushNamed(context, '/detial'); // 拼写错误

// 解决方案:使用常量
Navigator.pushNamed(context, AppRoutes.productDetail);

8.1.2 参数传递错误

// 问题:参数类型不匹配
// 错误代码:
Navigator.pushNamed(
  context,
  AppRoutes.productDetail,
  arguments: '123', // 应该传递Map或自定义对象
);

// 解决方案:使用类型安全的参数
Navigator.pushNamed(
  context,
  AppRoutes.productDetail,
  arguments: ProductDetailArguments(productId: '123'),
);

8.1.3 上下文错误

// 问题:在异步回调中使用错误的context
// 错误代码:
Future<void> loadData() async {
  final data = await api.getData();
  Navigator.pushNamed(context, AppRoutes.detail); // 可能使用已销毁的context
}

// 解决方案:检查mounted
Future<void> loadData() async {
  final data = await api.getData();
  if (mounted) {
    Navigator.pushNamed(context, AppRoutes.detail);
  }
}

8.2 路由调试工具

// utils/route_debugger.dart
class RouteDebugger {
  static void printRouteStack(BuildContext context) {
    final navigator = Navigator.of(context);
    print('=== 当前路由栈 ===');
    navigator.toString().split('\n').forEach(print);
    print('================');
  }

  static void printCurrentRoute(BuildContext context) {
    final route = ModalRoute.of(context);
    print('当前路由: ${route?.settings.name}');
    print('路由参数: ${route?.settings.arguments}');
  }

  static bool canPop(BuildContext context) {
    return Navigator.canPop(context);
  }

  static int getStackLength(BuildContext context) {
    final navigator = Navigator.of(context);
    final stackString = navigator.toString();
    return stackString.split('↳').length - 1;
  }
}

// 使用调试工具
FloatingActionButton(
  onPressed: () {
    RouteDebugger.printRouteStack(context);
    RouteDebugger.printCurrentRoute(context);
    print('是否可以返回: ${RouteDebugger.canPop(context)}');
    print('栈长度: ${RouteDebugger.getStackLength(context)}');
  },
  child: const Icon(Icons.bug_report),
)

总结

通过本文的深入学习,我们全面掌握了Flutter导航与路由管理的各个方面:

核心知识回顾

  1. 导航基础

    • 理解Navigator和Route的工作原理
    • 掌握push/pop等基本导航操作
    • 熟悉导航栈的生命周期管理
  2. 命名路由

    • 学会配置集中式路由管理
    • 掌握路由表的组织和维护
    • 理解命名路由的优势和适用场景
  3. 参数传递

    • 掌握多种参数传递方式
    • 学会处理页面间数据回传
    • 理解类型安全的参数设计
  4. 路由守卫

    • 实现完整的权限控制系统
    • 掌握路由拦截和重定向
    • 理解路由守卫的设计模式

高级特性

  • 自定义路由过渡动画
  • 路由性能优化策略
  • 导航状态监听和日志记录
  • 复杂应用的路由架构设计

实战建议

  1. 项目初期规划:在项目开始阶段就设计好路由结构
  2. 统一管理:使用集中式的路由配置管理
  3. 类型安全:优先使用类型安全的参数传递
  4. 错误处理:完善的错误处理和用户反馈
  5. 性能监控:监控路由性能并及时优化

写在最后的话

路由管理是Flutter开发中的核心技能,建议在实际项目中不断实践和优化。关注Flutter官方更新,及时了解新的路由特性和最佳实践。保持持续学习的动力,共勉~~~

有什么问题或建议?欢迎在评论区留言讨论!

iOS - 从 @property 开始

核心概念


本质@property 是一组访问器方法的声明 (setter/getter) ,编译器可以自动“合成”「访问器」以及「底层存储(ivar)」,并且允许用点语法调用。

  • 例如:
    @property (nonatomic) NSInteger age;
    
  • 编译器等价(自动合成):
    {
        NSInteger _age; // 可选的“底层存储” (backing ivar)
    }
    - (NSInteger)age { return _age; }              // getter
    - (void)setAge:(NSInteger)age { _age = age; }  // setter
    

好处:统一内存语义(strong/weak/copy...)、线程原子性控制(atomic/nonatomic)、可读性与 KVC/KVO 兼容。


常见属性修饰符

  • 读写性
    • readwrite:可读可写(默认)
    • readonly:只读
  • 原子性
    • atomic:保证“单次访问器调用”的原子性,速度慢。(默认)
      • 注意:atomic慢,且不保证你“对该对象做的一系列操作”是线程安全的;也不保证顺序、事务或对象内部的并发安全,实际场景还是需要显式同步。
    • nonatomic:不做同步,速度快。
  • 内存语义修饰符
    • strong:持有关系,引用计数+1,新值 retain,旧值 release.
      • 场景:一般对象所有权、父持子
    • weak:不持有,引用计数不变,对象释放时指针置空。
      • 场景:避免循环引用,如 delegate,子持父、IBOutlet
      • 注意:访问时可能已经变 nil。
    • copy:生成不可变副本,setter 执行 -(id)copy 方法。
      • 场景:阻止赋值可变对象的后续修改,block入堆。
    • assign:位拷贝,引用计数不变。
      • 场景:用于标量和结构体
      • 注意:对象指针使用 assign 会产生悬垂指针
    • unsafe_unretained:不持有,引用计数不变,对象释放不会置空。
      • 场景:以往无weak可用时使用的。
  • 其他
    • getter=isEnabled/setter=setFoo:指定自定义 setter/getter。
    • class:类属性。

copy相关延伸

strongcopy修饰的 可变/不可变 对象,对其 赋值/拷贝 会发生什么?

属性 -copy -mutableCopy 赋值 NSString 赋值 NSMutableString
(copy) NSString 浅拷贝 深拷贝 浅拷贝 深拷贝
(strong) NSString 浅拷贝 深拷贝 浅拷贝 浅拷贝
(copy) NSMutableString 浅拷贝 深拷贝 浅拷贝 深拷贝
(strong) NSMutableString 深拷贝 深拷贝 浅拷贝 浅拷贝

拷贝:

  • -copy一定返回不可变对象
    • 调用对象实际为「不可变类型」,产生浅拷贝。
    • 调用对象实际为「可变类型」,产生深拷贝,得到「不可变类型」的对象。
  • -mutableCopy一定返回可变对象(一定会深拷贝

赋值:

  • strong修饰的属性赋值,只会产生浅拷贝,属性与赋值对象「可变性」一致。
  • copy修饰的属性赋值,可能深拷贝可能浅拷贝,但是结果一定「不可变」的。
    • 赋值对象是「不可变类型」,产生浅拷贝。
      • 赋值对象是「可变类型」,产生深拷贝,得到「不可变类型」的对象。

提问:那为什么[(copy)NSMutableString copy]会是浅拷贝?

  • 答案: 基于上述结论,我们可以将答案拆分成 2 步。
    • 赋值:copy修饰的NSMutableString类型属性,在赋值时会将目标对象“深拷贝”,变为不可变的NSString。因此,我们的属性self.pStr此时实际指向的是一个NSString(不可变对象)。
    • 拷贝:在对「可变对象」进行copy操作时,返回“指针级别”的统一对象。
    • 综上,[(copy)NSMutableString copy]会是一个浅拷贝操作。

何时存储(背后存储backing ivar的规则)

  • 会有存储
    • 在类的@interface或类扩展里声明@property
    • 没有显式使用@dynamic,且没有同时手写 setter + getter 的。
  • 不会有存储
    • category里声明的@property
    • 使用@dynamic的, 承诺运行时提供访问器的。
    • 已经实现了 getter + setter 的。
    • 协议@protocol里的@property
    • 类属性。
  • 例外和细节
    • readonly 若你实现了 getter,则不会再自动合成 ivar
    • “类属性”没有ivar实例,通常用static或者其他存储来实现存储。
      @interface Config : NSObject
      @property (class, nonatomic, copy) NSString *build;
      @end
      
      @implementation Config
      static NSString *_build;
      + (NSString *)build { return _build; }
      + (void)setBuild:(NSString *)b { _build = [b copy]; }
      @end
      
    • 分类里的属性如何有“存储”?
      • 分类里的属性需要通过关联对象实现存储。
      #import <objc/runtime.h>
      @interface UIView (Badge)
      @property (nonatomic, copy) NSString *badgeText; // 分类里不会有 ivar
      @end
      
      @implementation UIView (Badge)
      static const void *kBadgeKey = &kBadgeKey;
      
      - (void)setBadgeText:(NSString *)badgeText {
          objc_setAssociatedObject(self, kBadgeKey, badgeText, OBJC_ASSOCIATION_COPY_NONATOMIC);
      }
      - (NSString *)badgeText {
          return objc_getAssociatedObject(self, kBadgeKey);
      }
      @end
      

@dynamic@synthesize计算属性

  • @dynamic

    • 作用:告诉编译器,不需要生成访问器和ivar,也不要因为找不到方法而告警。
    • 场景:Core Data 的NSManagedObject子类:
      @interface Book : NSManagedObject
      @property (nonatomic, copy) NSString *title;
      @end
      
      @implementation Book
      @dynamic title; // 访问器由运行时(Core Data)注入;编译器不生成也不报缺实现
      @end
      
  • @synthesize

    • 作用:让编译器为@property生成 getter/setter 以及背后存储 ivar,并把属性名映射到自定义 ivar 名。
  • 计算属性

    • 作用:不依赖存储,按需计算。

propertyivar 的区别

  1. ivar == 纯存储
  2. property == 访问这个存储的“方法接口”
  3. 大多数情况使用 self.age,在 init/dealloc/自定义访问器内部 常用 _age 直接访问,避免递归等问题。

SwiftUI布局之AnchorPreferences

SwiftUI 中的 AnchorPreferences:连接父子视图的几何桥梁

在 SwiftUI 中,数据流通常是单向的 —— 父视图向下传递数据。但有时我们希望子视图能告诉父视图一些信息,比如自己的尺寸、位置、坐标区域。这时,AnchorPreferences 就是我们的「秘密武器」。


🌱 什么是 AnchorPreferences?

AnchorPreferences 是 SwiftUI 的一个 View 修饰符,可以让你从子视图中提取出与布局相关的几何信息(如位置、尺寸、Bounds 等),并通过 PreferenceKey 向上传递给父视图。

一句话总结它的用途:

让父视图获取子视图在全局布局中的几何信息。


🧩 基础用法示例

我们先从一个简单的例子开始。

假设我们想知道某个子视图(一个按钮)在父视图中的位置。

struct AnchorPreferenceExample: View {
    @State private var buttonFrame: CGRect = .zero

    var body: some View {
        VStack {
            Text("按钮位置:\(Int(buttonFrame.origin.x)), \(Int(buttonFrame.origin.y))")
                .padding()

            Button("点我") {}
                .anchorPreference(key: BoundsPreferenceKey.self, value: .bounds) { anchor in
                    anchor
                }
        }
        .backgroundPreferenceValue(BoundsPreferenceKey.self) { anchor in
            GeometryReader { geo in
                if let anchor {
                    let rect = geo[anchor]
                    Color.clear
                        .onAppear { buttonFrame = rect }
                        .onChange(of: rect) { buttonFrame = $0 }
                }
            }
        }
    }
}

struct BoundsPreferenceKey: PreferenceKey {
    static var defaultValue: Anchor<CGRect>? = nil
    static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
        value = nextValue() ?? value
    }
}

📖 解析一下

  1. .anchorPreference(key:value:transform:)

    • 从当前视图提取一个「锚点」信息,比如 .bounds。
    • 存储在一个 PreferenceKey 中。
  2. .backgroundPreferenceValue(_:)

    • 父视图读取子视图上传的锚点信息。
    • 通过 GeometryProxy[anchor] 获取实际的坐标与尺寸。
  3. PreferenceKey

    • 负责定义数据类型与合并策略(多个子视图时如何处理)。

⚡ 实战案例:弹窗跟随按钮位置

来看一个常见需求:

点击按钮后弹出一个菜单,而菜单要自动出现在按钮正下方。

struct FloatingMenuExample: View {
    @State private var showMenu = false
    
    var body: some View {
        ZStack(alignment: .topLeading) {
            Color(UIColor.systemBackground)
                .ignoresSafeArea()

            Button("显示菜单") {
                withAnimation { showMenu.toggle() }
            }
            .anchorPreference(key: MenuAnchorKey.self, value: .bounds) { $0 }
        }
        .overlayPreferenceValue(MenuAnchorKey.self) { anchor in
            GeometryReader { geo in
                if let anchor, showMenu {
                    let rect = geo[anchor]
                    VStack(spacing: 0) {
                        Text("🍎 Apple")
                        Text("🍊 Orange")
                        Text("🍌 Banana")
                    }
                    .padding()
                    .background(RoundedRectangle(cornerRadius: 12).fill(.ultraThinMaterial))
                    .overlay(
                        RoundedRectangle(cornerRadius: 12)
                            .stroke(Color.black.opacity(0.1))
                    )
                    .shadow(radius: 5)
                    .position(x: rect.midX, y: rect.maxY + 40)
                    .transition(.opacity.combined(with: .move(edge: .top)))
                }
            }
        }
    }
}

struct MenuAnchorKey: PreferenceKey {
    static var defaultValue: Anchor<CGRect>? = nil
    static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
        value = nextValue() ?? value
    }
}

✨ 运行效果:

  • 点击「显示菜单」,一个浮动弹窗出现在按钮正下方;
  • 无论按钮在什么位置,菜单都自动对齐;
  • 不需要 GeometryReader 嵌套在子视图内 —— 一切由 AnchorPreferences 完成。

⚙️ 工作原理解析

AnchorPreferences 的核心思想是 “布局信息在 SwiftUI 的 View Tree 上传播”

子视图通过 anchorPreference -> PreferenceKey
         ↓
父视图通过 backgroundPreferenceValue / overlayPreferenceValue 读取
         ↓
利用 GeometryProxy[anchor] 将相对锚点转换为具体坐标

可以理解为 SwiftUI 的「几何信息管道」:

角色 作用
.anchorPreference() 子视图上传几何锚点
PreferenceKey 存储锚点信息
.overlayPreferenceValue() 父视图读取锚点信息
GeometryReader 将 Anchor 转为实际位置

🧱 常用 Anchor 类型

类型 描述
.bounds 当前视图的边界矩形
.topLeading / .bottomTrailing 等 具体角位置
.center 中心点
.rect(in:) 自定义矩形区域(从 GeometryProxy 获取)

例如:

.anchorPreference(key: MyKey.self, value: .topLeading) { $0 }

🚀 应用场景举例

场景 描述
💬 Tooltip 定位 获取目标控件位置,显示气泡提示
🎯 弹窗/菜单 动态跟随点击位置
🧭 路径动画 两个 View 之间画线连接(如流程图)
📦 自适应布局 根据子项布局动态调整父容器的对齐方式
🔍 高亮引导 新手引导框高亮控件(可定位按钮位置)

🧠 进阶:多个 Anchor 合并

多个子视图都可以通过相同的 PreferenceKey 上传 Anchor,

父视图会在 reduce() 方法中收到多个值,可用于批量布局。

例如:

struct MultiAnchorKey: PreferenceKey {
    static var defaultValue: [Anchor<CGRect>] = []
    static func reduce(value: inout [Anchor<CGRect>], nextValue: () -> [Anchor<CGRect>]) {
        value.append(contentsOf: nextValue())
    }
}

这样就能在父视图中获取所有子项的位置,用于绘制连线或分布动画。


🎯 小结

特性 说明
📡 数据方向 子 → 父
💡 功能 传递几何信息(位置、尺寸)
🧩 关键组件 .anchorPreference()、PreferenceKey、.overlayPreferenceValue()
🧱 典型应用 Tooltip、菜单、引导、高亮框、连线图
AnchorPreferences 是 SwiftUI 布局系统中一块隐藏的宝石。

掌握它,就能实现很多 UIKit 中要手动计算坐标的复杂布局,而无需任何 Frame 操作。


🪶 结语

SwiftUI 的核心思想是“声明式布局”,但 AnchorPreferences 给了我们“命令式的洞口”——可以在纯声明式框架里实现自定义的几何逻辑。

一旦掌握它,你可以做出很多令人惊叹的交互,比如微信小程序弹窗、指向动画、可跟踪的标签定位等等。


【Swift 可选链】从“如果存在就点下去”到“安全穿隧”到空合运算符

什么是可选链(Optional Chaining)

一句话:“当某个实例可能是 nil 时,允许你用 问号? 一路点下去;只要链中任何一环为 nil,整条表达式就优雅地返回 nil,而不会崩溃。”

与“强制解包 !”的生死对比

写法 成功时 失败时 是否安全
a!.b 拿到值 运行时崩溃 ❌ 不安全
a?.b 拿到值 返回 nil ✅ 安全
class Person {
    var residence: Residence?   // 可选类型,默认 nil
}

class Residence {
    var numberOfRooms = 1
}

let john = Person()

// 1. 强制解包——危险
// let roomCount = john.residence!.numberOfRooms
// 运行时崩溃:Unexpectedly found nil while unwrapping

// 2. 可选链——安全
if let roomCount = john.residence?.numberOfRooms {
    print("房间数:\(roomCount)")   // 不会进来,因为 residence 是 nil
} else {
    print("无法获取房间数")          // 走到这里,安全!
}

可选链的 4 条核心规则

  1. 链上任意环节为 nil → 整条表达式立即返回 nil,后续代码不再执行。
  2. 返回值总是可选类型:即使原属性是 Int,经过可选链后也变成 Int?
  3. 可连续多级链(A?.B?.C),但不会叠加可选层数;String? 再多层链也是 String?
  4. 不仅能点属性,还能 调方法、取下标、赋值;失败时返回 Void?nil

完整模型:Person → Residence → Room / Address

class Room {
    let name: String
    init(name: String) {
        self.name = name
    }
}

class Address {
    var buildingName: String?
    var buildingNumber: String?
    var street: String?
    
    // 返回可选 String
    func buildingIdentifier() -> String? {
        if let name = buildingName {
            return name
        }
        if let num = buildingNumber, let street = street {
            return "\(num) \(street)"
        }
        return nil
    }
}

class Residence {
    var rooms: [Room] = []
    var address: Address?
    
    // 计算属性
    var numberOfRooms: Int {
        rooms.count
    }
    
    // 下标
    subscript(i: Int) -> Room? {
        get {
            return i < rooms.count ? rooms[i] : nil
        }
        set {
            if let newValue, i <= rooms.count {
                if i == rooms.count {
                    rooms.append(newValue)
                }
                else {
                    rooms[i] = newValue
                }
            }
        }
    }
    
    // 无返回值方法,默认是返回Void
    func printNumberOfRooms() {
        print("这个房子有 \(numberOfRooms) 个房间")
    }
}

class Person {
    var residence: Residence?
}

实战场景 1:访问属性

let p = Person()
//  residence 为 nil,链式失败 → 返回 nil
let roomCount: Int? = p.residence?.numberOfRooms
print(roomCount as Any)   // nil

实战场景 2:调用方法

// 失败时返回 Void?,可利用与 nil 比较
if p.residence?.printNumberOfRooms() == nil {
    print("方法没执行,因为 residence 是 nil")
}

实战场景 3:通过下标读写

p.residence?[0] = Room(name: "主卧")   // 失败,不会崩溃
// 给 residence 赋值后再试
p.residence = Residence()
p.residence?[0] = Room(name: "主卧")   // 成功添加
print(p.residence?.rooms.first?.name ?? "无房间")  // 主卧

实战场景 4:多级链(链中链)

p.residence?.address = Address()
p.residence?.address?.street = "Infinite Loop"
let streetName: String? = p.residence?.address?.street
print(streetName as Any)   // Optional("Infinite Loop")

// 再深一层:调用返回可选值的方法
let id: String? = p.residence?.address?.buildingIdentifier()
print(id as Any)           // nil(因为 buildingName/Number 都为空)

// 在方法返回后继续链
let firstChar: Character? = p.residence?.address?.buildingIdentifier()?.first
print(firstChar as Any)    // nil

可选链的赋值操作也有返回值

A?.B = C 整体返回 Void?,可用来判断赋值是否成功。

func createAddress() -> Address {
    print("⚠️ 这行会打印吗?")
    return Address()
}
// 赋值失败,createAddress() 不会执行
let result: Void? = (p.residence = nil)
(p.residence?.address = createAddress())
// 控制台无输出,证明短路了

常见踩坑与调试技巧

  1. 链太长看不清?用断点看每一步的中间值。
  2. 忘了返回值是可选?直接当非可选用会编译错误。
  3. try?as? 混用时,注意可选层级不会叠加,但可读性会变差,建议拆行。
  4. 在 @objc 协议 或 KVO 中,可选链无法直接观察,需先解包再观察。

扩展场景:在日常业务里花式用链

  1. JSON 嵌套解析
let city: String? = json["user"]?.["address"]?.["city"]?.stringValue
  1. 路由跳转判空
if navigationController?.topViewController?.isKind(of: DetailVC.self) == true { ... }
  1. 链式动画
view?.layer?.animate()?.next()?.start()
  1. Combine 管道
publisher?.flatMap { $0.optionalField?.publisher } // 依旧只需一个 ?

为什么需要 ??

可选链让我们安全地拿到可选值,但业务里更常见的是:“拿不到就算了,给个备胎。”

这时空合运算符(Nil-Coalescing Operator)?? 就是最佳接盘侠。

?? 基础回顾

let roomCount = john.residence?.numberOfRooms ?? 0

解读:

  • 链成功 → 返回真实房间数
  • 链任意环节为 nil → 返回 0
  • 结果类型退化成非可选 Int,直接可用,无需再解包

6 个实战场景,把 ?? 用到极致

  1. 多级链 + 自定义默认值
// 业务:显示“城市+街道”,拿不到就显示“未知地址”
let addressText = p.residence?.address?.street ?? "未知地址"
// 再升一级:整条都为空时显示“火星”
let finalText = addressText.isEmpty ? "火星" : addressText
  1. 方法链返回值是可选
// buildingIdentifier() 返回 String?
let badgeText = p.residence?.address?.buildingIdentifier() ?? "暂无门牌"
  1. 下标访问越界 or key 不存在
let scores = ["Alice": [80, 90], "Bob": []]
let aliceFirst = scores["Alice"]?.first ?? 0   // 80
print(aliceFirst)
let bobFirst   = scores["Bob"]?.first   ?? 0   // 0(数组空)
print(bobFirst)
let cindyFirst = scores["Cindy"]?.first ?? 0   // 0(key 不存在)
print(cindyFirst)
  1. 与 try? 混用——解析 JSON 一行代码
let username = (try? JSONDecoder().decode(User.self, from: data))?.name ?? "游客"
  1. 与 as? 混用——VC 安全取值
let indexPath = tableView.indexPathForSelectedRow
let cell = tableView.cellForRow(at: indexPath ?? IndexPath(row: 0, section: 0))
  1. 链式动画缺省回调
UIView.animate(
    withDuration: 0.3,
    animations: { self.view.alpha = 0 },
    completion: { _ in
        self.dismissAnimation?() ?? self.defaultDismiss() // 备胎动画
    }
)

性能陷阱:?? 的右表达式何时执行?

?? 是短路的:

  • 左值非 nil → 右值根本不会求值
  • 左值 nil → 才会计算右值

因此可以放心把昂贵构造放在右边:

// 数据库查询很耗资源,仅当缓存为 nil 时才查
let config = loadCache()?.config ?? loadFromDB()

与三目运算符的区别

写法 是否强制解包 可读性表现
a != nil ? a : b 需要手动解包 啰嗦
a ?? b 编译器自动处理 简洁

可选链 + ?? 的 3 条最佳实践

  1. 先链后合:把 ?? 放在最外层,保证链的每一步都可读。
  2. 默认值类型匹配:Swift 类型推导严格,Int??Int 不会自动合并。
  3. 日志友好:给默认值加前缀标识,方便灰度排查。
let uid = user?.id ?? "unknown_uid"

一道面试真题

写出编译通过的表达式:在“链可能失败”且“失败后要抛错”的场景下,如何把 ??throw 结合?

一个答案:

let url = Bundle.main.url(forResource: "config", withExtension: "json")
            ?? { throw AppError.missingConfig }()

利用立即执行闭包把 throw 包成表达式,满足 ?? 右侧要求。

Swift 反初始化器详解——在实例永远“消失”之前,把该做的事做完

为什么要“反初始化”

  1. ARC 已经帮我们释放了内存,但“内存”≠“资源”。

    可能你打开过文件、有过数据库连接、订阅过通知、甚至握着 GPU 纹理句柄。

  2. 反初始化器(deinit)是 Swift 给你“最后一声道别”的钩子:

    实例即将被销毁 → 系统自动调用 → 你可以把文件关掉、把硬币还回银行、把日志写盘……

  3. 只有 class 有 deinit,struct / enum 没有;一个类最多一个 deinit;不允许手动显式调用。

deinit 的 6 条铁律

  1. 无参无括号:
class MyCls {
    deinit { // 不能写 deinit() { ... }
        // 清理代码
    }
}
  1. 自动调用,调用顺序:子类 deinit 执行完 → 父类 deinit 自动执行。
  2. 实例“还没死”:deinit 里可访问任意 self 属性,甚至可调用实例方法。
  3. 不能自己调、不能重载、不能抛异常、不能带 async。
  4. 如果实例从未被真正强引用(例如刚 init 就赋 nil),deinit 不会触发。
  5. 若存在循环引用(strong reference cycle),deinit 永远不会触发——必须先解环。

示例

import Foundation

// MARK: - 银行:管理游戏世界唯一货币
@MainActor
class Bank {
    // 静态共享实例 + 私有初始化,保证“全世界只有一家银行”
    static let shared = Bank()
    private init() {}
    
    // 剩余硬币,private(set) 让外部只读
    private(set) var coinsInBank = 10_000
    
    /// 发放硬币;返回实际发出的数量(可能不够)
    func distribute(coins number: Int) -> Int {
        let numberToVend = min(number, coinsInBank)
        coinsInBank -= numberToVend
        print("银行发放 \(numberToVend) 枚,剩余 \(coinsInBank)")
        return numberToVend
    }
    
    /// 回收硬币
    func receive(coins number: Int) {
        coinsInBank += number
        print("银行回收 \(number) 枚,当前 \(coinsInBank)")
    }
}

// MARK: - 玩家:从银行拿硬币,离开时自动归还
@MainActor
class Player {
    var coinsInPurse: Int
    
    /// 指定构造器:向银行申请“启动资金”
    init(coins: Int) {
        let received = Bank.shared.distribute(coins: coins)
        coinsInPurse = received
        print("玩家初始化,钱包得到 \(received)")
    }
    
    /// 赢钱:从银行再拿一笔
    func win(coins: Int) {
        let won = Bank.shared.distribute(coins: coins)
        coinsInPurse += won
        print("玩家赢得 \(won),钱包现在 \(coinsInPurse)")
    }
    
    /// 反初始化器:人走茶不凉,硬币先还银行
    @MainActor
    deinit {
        print("玩家 deinit 开始,归还 \(coinsInPurse)")
        Bank.shared.receive(coins: coinsInPurse)
        print("玩家 deinit 结束")
    }
}

// MARK: - 游戏主流程
@MainActor
func gameDemo() {
    print("=== 游戏开始 ===")
    
    // 1. 创建玩家;注意用可选类型,因为玩家随时可能 leave
    var playerOne: Player? = Player(coins: 100)
    // 如果不加调试打印,可简写:playerOne?.win(coins: 2000)
    if let p = playerOne {
        print("玩家当前硬币:\(p.coinsInPurse)")
        p.win(coins: 2_000)
    }
    
    // 2. 玩家离开游戏;引用置 nil → 强引用归零 → deinit 被调用
    print("玩家离开,引用置 nil")
    playerOne = nil
    
    print("=== 游戏结束 ===")
}

gameDemo()

运行结果

=== 游戏开始 ===
银行发放 100 枚,剩余 9900
玩家初始化,钱包得到 100
玩家当前硬币:100
银行发放 2000 枚,剩余 7900
玩家赢得 2000,钱包现在 2100
玩家离开,引用置 nil
玩家 deinit 开始,归还 2100
银行回收 2100 枚,当前 10000
玩家 deinit 结束
=== 游戏结束 ===

3 个高频扩展场景

  1. 关闭文件句柄
class Logger {
    private let handle: FileHandle
    init(path: String) throws {
        handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path))
    }
    deinit {
        handle.closeFile()   // 文件一定会被关掉
    }
}
  1. 注销通知中心观察者
class KeyboardManager {
    private var tokens: [NSObjectProtocol] = []
    init() {
        tokens.append(
            NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { _ in }
        )
    }
    deinit {
        tokens.forEach(NotificationCenter.default.removeObserver)
    }
}
  1. 释放手动分配的 C 内存 / GPU 纹理
class Texture {
    private var raw: UnsafeMutableRawPointer?
    init(size: Int) {
        raw = malloc(size)
    }
    deinit {
        free(raw)          // 防止内存泄漏
    }
}

常见踩坑与排查清单

现象 可能原因 排查工具
deinit 从不打印 出现强引用循环 Xcode Memory Graph / leaks 命令
子类 deinit 未调用 父类 init 失败提前 return 在 init 各阶段加打印
访问属性崩溃 在 deinit 里访问了 weak / unowned 已释放属性 改用 strong 或提前判空

小结:把 deinit 当成“遗嘱执行人”

  1. 它只负责“身后事”:释放非内存资源、归还全局状态、写日志。
  2. 它不能保命:如果实例因为循环引用一直活着,就永远走不到 deinit。
  3. 它不能抢戏:别在 deinit 里做耗时任务(网络、IO),否则可能阻塞主线程或单元测试。
  4. 用好 weak / unowned + deinit,可以让 Swift 代码在“自动”与“可控”之间取得最佳平衡。

深入底层:deinit 在 SIL & 运行时到底做了什么

swiftc -emit-sil main.swift mainsil

  1. SIL(Swift Intermediate Language)视角

    编译器会为每个类生成一个 sil_vtable,里面存放了类中的所有方法,可以看到deinit中调用的是Player.__deallocating_deinit

    image.png

    Player.__deallocating_deinit中调用的 Player.__isolated_deallocating_deinit

    image.png

    Player.__isolated_deallocating_deinit中调用Player.deinit

    image.png

    伪代码:

   sil @destroy_Player : $@convention(method) (@owned Player) -> () {
     bb0(%0 : $Player):
       // 1. 调用 deinit
       %2 = function_ref @$s4main6PlayerCfZ : $@convention(thin) (@owned Player) -> ()
       %3 = apply %2(%0) : $@convention(method) (@guaranteed Player) -> @owned Builtin.NativeObject // user: %4
       // 2. 销毁存储属性
       destroy_addr %0.#coinsInPurse
       // 3. 释放整个对象内存
       strong_release %5
   }

结论:deinit 只是“销毁流水线”里的一环;先跑 deinit,再跑成员销毁,最后归还堆内存。

  1. 运行时视角

    Swift 对象头部有一个 32-byte 的 HeapObject,其中 refCounts 字段采用“Side Table” 策略。

    当最后一次 swift_release 把引用计数降到 0 时,会立即跳到 destroy 函数指针 → 也就是上面的 SIL 函数。

    因此:

    • deinit 执行线程 = 最后一次 release 发生的线程;
    • deinit 执行耗时 ≈ 对象大小 + 成员销毁耗时 + 你写的代码耗时;
    • 如果 deinit 里再产生强引用(例如把 self 塞进全局数组),对象会被“复活”,但 Swift 5.5 之后禁止这种 resurrection,会直接 trap。

多线程与 deinit 的 4 个实战坑

场景 风险 正确姿势
子线程释放主线程创建的实例 deinit 里刷新 UI DispatchQueue.main.asyncMainActor.assertIsolated()
deinit 里加锁 可能和 init 锁顺序相反 → 死锁 尽量无锁;必须加锁时统一层级
deinit 里用 unowned 访问外部对象 外部对象可能已释放 改用 weak 并判空
deinit 里继续派发异步任务 任务持有 self → 循环复活 使用 Task { [weak self] in ... }

与 Objective-C 的交叉:dealloc vs deinit

  1. 继承链
@objc class BaseNS: NSObject {
   deinit { print("Swift deinit") }   // 实际上会生成 -dealloc 方法
}

编译器把 deinit 映射成 Objective-C 的 -dealloc,并在末尾自动插入 [super dealloc](ARC 下自动插入)。
2. 混编时序

  • Swift 侧先跑完 deinit;
  • 再跑 Objective-C 侧生成的 -dealloc
  • 最后 NSObject 的 -dealloc 释放 isa 与 ARC 附带内存。
  1. 注意点

    若你在 Objective-C 侧手动 override -dealloc,记得不要显式调用 [super dealloc](ARC 会自动加),否则编译报错。

Swift 5.9 新动向:move-only struct 的 deinit

SE-0390 已经落地 move-only ~Copyable struct,也可以写 deinit!

struct FileDescriptor: ~Copyable {
    private let fd: Int32
    init(path: String) throws { fd = open(path, O_RDONLY) }
    deinit {               // struct 也能有 deinit!
        close(fd)
    }
}

规则:

  • 只要值被消耗(consume)或生命周期结束,deinit 就执行;
  • 不能同时实现 deinitCopyable
  • 用于文件句柄、GPU 描述符等“必须唯一所有权”场景,彻底告别 class + deinit 的性能损耗。

一张“思维导图”收尾

class 实例
   │
   ├─ refCount == 0 ?
   │     ├─ 否:继续浪
   │     └─ 是:进入 destroy 流水线
   │           1. 子类 deinit 跑
   │           2. 父类 deinit 跑
   │           3. 销毁所有存储属性
   │           4. 归还堆内存
   │
   ├─ 线程:最后一次 release 线程
   ├─ 复活:Swift 5.5+ 禁止,直接 trap

彩蛋:把 deinit 做成“叮”一声

#if DEBUG
deinit {
    // 只调一次,不会循环引用
    DispatchQueue.main.async {
        AudioServicesPlaySystemSound(1057) // 键盘“叮”
    }
}
#endif

每次对象销毁都会“叮”,办公室同事会投来异样眼光,但你能瞬间听出内存泄漏——当该响的没响,就说明循环引用啦!

Swift 并发编程新选择:Mutex 保护可变状态实战解析

前言

Swift 5.5 带来 async/await 与 Actor 后,「用 Actor 包一层」几乎成了默认答案。

但在日常开发里,我们经常会遇到两种尴尬:

  1. 只想保护一个计数器、缓存或 token,却不得不把整段逻辑都改成异步;
  2. 把对象放到 @MainActor 后,发现后台线程也要用,结果到处是 await。

Apple 在 Swift 5.9 前后把 Mutex 正式搬进标准库(通过 Synchronization 模块),给“同步但不想异步”的场景提供了第三条路。

Mutex 是什么(一句话先记住)

Mutex = 互斥锁,同步、阻塞、轻量。

它只干一件事:同一时刻最多一个线程进入临界区,保证对共享状态的“读-改-写”原子化。

与 Actor 的“异步消息”不同,Mutex 的等待是阻塞线程,所以临界区必须短、快、不阻塞。

基础用法:从 0 到 1 保护一个计数器

  1. 引入模块(Xcode 15+/Swift 5.9 自带)
import Synchronization
  1. 定义线程安全的 Counter
final class Counter: Sendable {          // ① Sendable 空标记即可,Mutex 本身已 Sendable
    private let mutex = Mutex(0)         // ② 初始值 0

    /// 加 1,同步返回
    func increment() {
        mutex.withLock { value in
            value += 1                   // ③ 闭包内 value 是 inout,直接改
        }
    }

    /// 减 1
    func decrement() {
        mutex.withLock { value in
            value -= 1
        }
    }

    /// 读值,也要拿锁
    var count: Int {
        mutex.withLock { value in
            return value                 // ④ 只读,同样原子
        }
    }
}
  1. 客户端代码——完全同步
let counter = Counter()
counter.increment()
print(counter.count)   // 1

要点回顾

  • withLock<T> 泛型返回,既能读也能写;
  • 闭包里的 valueinout,修改即生效;
  • 锁的持有时间 = 闭包运行时间,务必短。

让属性看起来“像正常变量”——封装 getter/setter

extension Counter {
    var count: Int {
        get {
            mutex.withLock { $0 }        // $0 就是 value,直接返回
        }
        set {
            mutex.withLock { value in
                value = newValue
            }
        }
    }
}

// 使用方无感
counter.count = 10
print(counter.count) // 10

与 @Observable 搭档——让 SwiftUI 刷新

Mutex 只保护值,不会触发属性观察器。

若直接 @Observable final class Counter,视图不会刷新。

需要手动告诉 Observation 框架:

@Observable
final class Counter: Sendable {
    private let mutex = Mutex(0)

    var count: Int {
        get {
            access(keyPath: \.count)          // ① 读标记
            return mutex.withLock { $0 }
        }
        set {
            withMutation(keyPath: \.count) {  // ② 写标记
                mutex.withLock { $0 = newValue }
            }
        }
    }
}

SwiftUI 端无额外成本:

struct ContentView: View {
    @State private var counter = Counter()

    var body: some View {
        VStack {
            Text("\(counter.count)")
            Button("++") { counter.increment() }
            Button("--") { counter.decrement() }
        }
    }
}

Actor or Mutex?一张决策表帮你 10 秒选

维度 Mutex Actor
同步/异步 同步、阻塞 异步、非阻塞
适用场景 极短临界区(赋值、累加) 长时间任务、IO、网络
性能 极轻量,纳秒级锁 微秒毫秒,调度开销
语法侵入 无 async 强制 async/await
Sendable Mutex 已 Sendable,类标即可 Actor 引用即 Sendable
调试难度 简单,栈清晰 异步堆栈难追踪

“只想保护一两行, Mutex 别犹豫; 流程长、要并发, Actor 顶上。”

扩展场景实战

  1. 高频读写缓存(图片、Token)
final class ImageCache: Sendable {
    private let cache = Mutex([String: Image]())

    func image(for key: String) -> Image? {
        cache.withLock { $0[key] }
    }

    func save(_ image: Image, for key: String) {
        cache.withLock { dict in
            dict[key] = image
        }
    }
}
  1. 统计接口 QPS
final class Stats: Sendable {
    private let counter = Mutex(0)
    private let start = Date()

    func record() {
        counter.withLock { $0 += 1 }
    }

    var qps: Double {
        counter.withLock { Double($0) / start.timeIntervalSinceNow * -1 }
    }
}
  1. 保护非 Sendable 的 C 句柄
final class SQLiteHandle: @unchecked Sendable {
    private let db: UnsafeMutableRawPointer
    public init(db: UnsafeMutableRawPointer) {
        self.db = db
    }
    
    private let lock = Mutex(())

    func execute(_ sql: String) {
        lock.withLock { _ in
            sqlite3_exec(db, sql, nil, nil, nil)   // 临界区
        }
    }
}

踩坑与提醒

  1. 长任务别用 Mutex

    一旦临界区阻塞 IO,整个线程池都会被卡死,比 Actor 还惨。

  2. 递归加锁会死锁

    Mutex 不可重入,同一线程重复拿锁直接挂起;Actor 不会。

  3. 锁粒度要细

    大对象整颗锁会变成性能瓶颈,可拆成多颗 Mutex 或按 Key 分片。

  4. Swift 6 数据竞争检查

    打开 -strict-concurrency=complete 后,凡是非 Sendable 全局变量都会报错;用 Mutex 包一层即可通过。

小结

Actor 把“线程安全”装进黑盒子,让开发者用消息思考;Mutex 把“锁”暴露给你,却换回最简洁的同步代码。

两者不是谁取代谁,而是互补:

  • 短、频、快 → Mutex
  • 长、流、异步 → Actor

iOS 26 你的 property 崩了吗?

本文首次发表在快手大前端公众号

背景

iOS 26 Runtime 新增特性,对 nonatomic (非原子) 属性的并发修改更加容易产生崩溃。系统合成的 setter 方法会短暂地存入一个哨兵值 0x400000000000bad0 ,而该值可能会被另一个并发访问此属性的线程所读取。如果程序因访问这个哨兵值而崩溃,则表明正在访问的属性存在线程安全问题。

崩溃示例:

核心改动

对于 nonatomic strong 属性的赋值操作,编译时会自动生成对 objc_storeStrong 函数的调用。

示例:

@property (nonatomic, strong) NSObject *obj1;

系统生成的 setter 方法:

Example`-[ViewController setObj1:]:
    0x1046298c4 <+0>:  sub    sp, sp, #0x30
    0x1046298c8 <+4>:  stp    x29, x30, [sp, #0x20]
    0x1046298cc <+8>:  add    x29, sp, #0x20
    0x1046298d0 <+12>: stur   x0, [x29, #-0x8]
    0x1046298d4 <+16>: str    x1, [sp, #0x10]
    0x1046298d8 <+20>: str    x2, [sp, #0x8]
    0x1046298dc <+24>: ldr    x1, [sp, #0x8]
    0x1046298e0 <+28>: ldur   x8, [x29, #-0x8]
    0x1046298e4 <+32>: adrp   x9, 5159
    0x1046298e8 <+36>: ldrsw  x9, [x9, #0xba4]
    0x1046298ec <+40>: add    x0, x8, x9
--> 0x1046298f0 <+44>: bl     0x105600a10               ; symbol stub for: objc_storeStrong
    0x1046298f4 <+48>: ldp    x29, x30, [sp, #0x20]
    0x1046298f8 <+52>: add    sp, sp, #0x30
    0x1046298fc <+56>: ret    

objc_storeStrong 在旧版本的的实现:

void objc_storeStrong(id *location, id obj) {
    // 1. 先用一个临时变量 prev 持有旧值
    id prev = *location;
    
    // 2. 如果新旧值相同,直接返回,避免不必要的内存操作
    if (obj == prev) {
        return;
    }
    
    // 3. 对新值执行 retain,使其引用计数+1
    objc_retain(obj);
    
    // 4. 将指针指向新值
    *location = obj;
    
    // 5. 对旧值执行 release,使其引用计数-1
    objc_release(prev);
}

反汇编 objc_storeStrong 在 iOS 26 新版本的实现:

void objc_storeStrong_iOS_26(id *location, id obj) {
    // 1. 读取旧值
    // ldr x20, [x0]
    id prev = *location;

    // 2. 检查新旧值是否相同,相同则直接返回
    // cmp x20, x1
    // b.eq ... (跳转到函数末尾)
    if (prev == obj) {
        return;
    }

    // 为了后续操作,保存新值 obj 和地址 location
    // mov x19, x1  (x19 = obj)
    // mov x21, x0  (x21 = location)
    id new_obj_saved = obj;
    id* location_saved = location;

    // 3. 【核心改动】向属性地址写入哨兵值
    // mov  x8, #0xbad0
    // movk x8, #0x4000, lsl #48  --> x8 = 0x400000000000bad0
    // str  x8, [x0]
    *location = (id)0x400000000000bad0; // 调试陷阱

    // 4. 对新值执行 retain
    // mov x0, x1
    // bl objc_retain
    objc_retain(obj);

    // 5. 将真正的新值写入属性地址,覆盖哨兵值
    // str x19, [x21]
    *location_saved = new_obj_saved;

    // 6. 释放旧值(通过尾调用优化)
    // mov x0, x20
    // b objc_release
    // 这相当于 return objc_release(prev);
    objc_release(prev);
}

为了更主动地暴露 nonatomic 属性的线程安全问题,objc_storeStrong 函数在 iOS 26 中增加了一个关键步骤。

旧实现 (时序:Retain -> Assign -> Release)

  1. objc_retain(newValue);
  2. *location = newValue;
  3. objc_release(oldValue);

新实现 (时序:写入哨兵值 -> Retain -> Assign -> Release)

  1. *location = 0x4...bad0; // <-- 新增:写入哨兵值

  2. objc_retain(newValue);

  3. *location = newValue;

  4. objc_release(oldValue);

旧实现中数据竞争触发崩溃需要满足的条件:

  1. 对象状态:prev 对象的引用计数 == 1,执行完 objc_release 之后 prev 对象被释放。

  2. 线程时序:读线程获取到了 prev 对象,并未对 prev 对象的引用计数+1,写线程执行完 objc_release(prve),读线程仍在继续使用 prev。

  3. 行为前提:读线程必须对这个已成为悬垂指针的 prev 地址执行解引用操作,但是这是一个必要不充分条件,因为该内存可能已被重用,不一定会触发崩溃。

新实现通过引入哨兵值,将不确定的崩溃条件转变为一个确定的、主动触发的机制:

  1. 定义"危险窗口": 写线程在 objc_storeStrong 内部创建了一个明确的"危险窗口"——从写入哨兵值 (*location = 0x4...bad0) 开始,到写入新值 (*location = obj) 结束。访问哨值触发崩溃与旧值 prev 对象的引用计数无关
  2. 简化触发条件: 只要读线程的读取操作落入这个时间窗口内,它必然会获取到哨兵值。对这个非法的哨兵地址进行任何解引用操作,都将必然、立即触发一个带有明确特征 (0x4...bad0) 的 EXC_BAD_ACCESS 崩溃。

另外新的崩溃机制并非替换了旧的崩溃逻辑,而是与之叠加,因此极大地放大了崩溃的概率。

崩溃场景

当一个线程(线程 A)正在为属性赋值,并已写入哨兵值但尚未写入新值时,*location 处于 "危险窗口"。此时,另一个线程(线程 B)的并发读写操作会导致崩溃。

崩溃场景一:写写并发 → objc_release 崩溃

  1. 线程 A:执行 setter,向属性地址写入哨兵值 0x4...bad0。

  2. 线程 B:并发执行 setter,调用 objc_storeStrong 函数。

  3. 关键点:线程 B 此时读到了 "旧值" 是线程 A 写入的哨兵值 0x4...bad0。

  4. 崩溃:objc_storeStrong 在赋值完成后,尝试调用 objc_release(旧值),实际上执行了 objc_release(0x4...bad0)。由于这是一个无效的对象地址,程序立即崩溃,堆栈栈顶指向 objc_release。

复现代码:

崩溃栈顶:

崩溃场景二:读写并发 → objc_retain 崩溃

  1. 线程 A:执行 setter,写入哨兵值 0x4...bad0。

  2. 线程 B:此时执行 getter 来读取该属性。

  3. 关键点:getter 直接从内存中返回了当前的哨兵值 0x4...bad0。

  4. 崩溃:ARC 为了保证对象生命周期,会对这个值执行 retain 操作。这导致系统调用 objc_retain(0x4...bad0)。同样,由于这是一个无效地址,程序崩溃,堆栈栈顶指向 objc_retain。

复现代码:

崩溃栈顶:

根因修复

iOS 26 Runtime 针对 property 新增的哨兵机制,其目的是主动暴露潜藏的多线程数据竞争问题。因此,修复的根本目标是解决底层的线程冲突。

最直接快速的修复方案是把 nonatomic 修改为 atomic。它能有效地规避 iOS 26 此次更新导致的,访问哨兵 0x400000000000bad0 触发的崩溃问题。

需要注意的是 atomic 也有一些局限性,只保证 setter 或 getter 本身是原子操作。如果有一系列依赖该属性的操作,atomic 无法保护整个操作序列是线程安全的。典型场景比如数组、字典的更新,atomic 可以保证线程安全的获取数组或字典对象,但是无法保证对数组和字典的增删是线程安全的,此时需要用锁或者队列覆盖系列复合操作来保证线程安全。

影响范围

这是一次由操作系统 Runtime 变更引发的、波及全量线上版本的崩溃问题。当用户升级操作系统后,代码库中所有潜藏的 nonatomic 数据竞争问题都将被新的"哨兵"机制主动暴露,导致崩溃呈现高度分散的特点,增加了问题处置的复杂性。如下所示,不仅分布为多个崩溃堆栈,并且每个堆栈的 App 版本跨度非常大。

对于线上 App 版本,如果不做任何止损操作,iOS 26 系统的用户崩溃率将比存量系统激增近两个数量级。

Ekko(安全气垫)

崩溃波及全量的线上 App 版本,对于线上历史版本, 从用户体验的角度出发,我们不能够任由崩溃发生,也不能简单粗暴地强制用户升级 App。那么如何在允许的的规则范围内进行崩溃止损呢?快手的答案是使用 Ekko(安全气垫)。

Ekko 是什么?

Ekko 是快手自研的全新的安全气垫框架,命名源自英雄联盟的艾克,他的 R 技能可以回到数秒前位置并恢复生命值,非常契合快手安全气垫的技术实现。Ekko 核心机制是:在异常发生之后,App 闪退之前,通过修改程序执行流,在代码逻辑上等价于绕过执行发生异常的函数,从而让 App 免于崩溃。Ekko 兜底偶现崩溃的场景下,当目标函数未发生崩溃时,执行逻辑不会受任何影响。

以典型的数组越界为例:

Ekko 兜底 objectAtIndex: 后,上述代码在异常发生后,执行逻辑上等价于:

Ekko 简介:

  • 平台覆盖:iOS & Android

  • 兜底能力:在 iOS 端能处理包括 Mach 异常在内的所有崩溃类型。在 Android 端能处理 Java Exception 和 Native Exception。

  • 稳定可靠:兜底的核心逻辑在异常发生后执行,对正常运行的 App 不发生作用。Ekko 系统上线至今,已在线上稳定运行超过一年,多次在异常退出类型的故障处置中发挥关键作用,为快手 App 的稳定性提供了坚实的保障。

Ekko 兜底实践

iOS 传统的安全气垫会通过 hook Objective-C 可能会抛异常的系统方法,在替换的方法内,添加 try catch 或者校验异常参数,防御已知的、可枚举的风险点,从而避免崩溃发生。

因为访问 0x400000000000bad0 触发了 bad access 类型的 Mach 异常能不能被 try catch 住呢?答案是不可以的。但是 Mach 同 Exception 一样,也是两段式的处理,当异常发生时,内核会挂起出错线程,并向用户态发起“问询”,并等待用户态的响应,然后根据用户态的回复决定是否终止进程还。这个问询等待回复后决策的机制是 Ekko 兜底 Mach 异常的关键所在。

针对此次 nonatomic 哨兵值崩溃,快手稳定性组通过 Ekko,对访问哨兵地址 0x400000000000bad0 触发的崩溃类型进行了统一兜底,拦截了用户百万次量级的崩溃。兜底主要处理以下两种系统堆栈触发的崩溃场景:

  • 场景一:objc_release
    • 兜底策略: 检测到参数为哨兵地址时,直接返回,不执行任何操作。
    • 业务影响: 无额外影响。此操作仅跳过了一次无效的 release,避免了崩溃。
  • 场景二:objc_retain
    • 兜底策略: 检测到参数为哨兵地址时,中断原始 retain 流程,并向上层返回 nil。

    • 业务影响:可控降级。上层业务代码在获取该属性值后会得到 nil。需要和相关业务方沟通并确认,业务逻辑能够正确处理 nil 返回值,兜底后的效果可接受。

本文仅是 Ekko 系列的开篇,后续我们将通过公众号,为大家详细介绍 Ekko 的技术实现细节,对相关内容感兴趣的可以关注公众号,敬请期待后续的更新~~

iOS - 关于如何在编译时写入文件并在代码内读取文件内容

使用场景: 自动化打包时使用脚本生成特定内容的文件并在代码内读取上报,用于区分具体的打包版本等。应该也有其他方案,这里只是我的一个想法,项目上测试也算是比较符合预期。仅供参考。

以打包时生成时间戳为例:

1,先编写一个脚本代码,命名为 timestamp.sh

#!/bin/bash

TIMESTAMP=$(date +%s)

OUTPUT_FILE="${CONFIGURATION_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/timestamp.txt"

echo "$TIMESTAMP" > "$OUTPUT_FILE"

echo "Timestamp written to: $OUTPUT_FILE"

2,将其配置在项目内 依次选择:Target - Build Phases - '+' - New Run Script Phase,并配置下述代码:

#将 ***filePath***替换为 timestamp.sh文件的实际路径
bash "${SRCROOT}/***filePath***/timestamp.sh"

3,在代码内读取文件,获取时间戳

if let fileURL = Bundle.main.url(forResource: "timestamp", withExtension: "txt") {
    do {
        let timestamp = try String(contentsOf: fileURL, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)
        // 在这里做一些业务相关的事项
        print("timestamp = \(timestamp)")
    } catch { }
}
❌