阅读视图

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

Tracking renamed files in Git

Git famously doesn’t track file renames. That is, Git doesn’t store the information “file A has been renamed to B in commit X”.

Instead, Git stores snapshots of the repository at each commit. It then uses a (customizable) heuristic during diffing to guess at likely renames: “File B in commit X is new, and file A has been deleted. B is 90 % identical to A’s previous contents, so A was probably renamed to B.”

This behavior is very much by design:

Linus Torvald’s email is worth reading. It’s well-reasoned and I agree with his arguments:

  • Tracking renames is a superficial solution that fixes only part of the actual problem: how do you track the history of a particular piece of information, which may be much smaller (a single line) or larger (the design of an entire subsystem) than a file, depending on context.
  • Shifting the task of history tracking from commit time to search time allows the search algorithm to do a much better job, because it can be tweaked to the structure of the underlying data.

And yet, I still miss the ability to explicitly register a rename operation with Git. Maybe this is because the history tracking tools we have are not as good as what Linus Torvalds envisioned in 2005. Or because sometimes the file is a good enough unit of granularity for history tracking, even if imperfect.

Use a separate commit for the rename

Git’s heuristics work great if renaming a file is all you do in a commit. Tracking only becomes a problem if the renaming coincides with substantial changes to the file’s contents in the same commit. Unfortunately, this happens very frequently in my experience: more often than not, my reason for renaming a file is that I made substantial edits and now the filename no longer represents the file’s contents.

The golden rule: To track a file’s identity across renames, perform the rename in a standalone commit, separate from any edits to the file.

git mv stages the rename but not the edits

Git has the promisingly named git mv command, but since Git doesn’t track renames, git mv is mostly no different than doing the renaming in some other way and then staging the change (the deleted and newly created file). The FAQ answer I linked to above even says so:

Git has a rename command git mv, but that is just for convenience. The effect is indistinguishable from removing the file and adding another with different name and the same content.

But there is an important difference: git mv will stage the rename, but crucially it keeps any edits in the renamed file unstaged. This is exactly what I want since it allows me to commit the rename and the edits separately.

Example: Let’s create a fresh Git repository that contains a single file, and then make some edits to the file:

# Create repository
mkdir testrepo
cd testrepo
git init
# Create a file and commit it
echo "Hello" > A.txt
git add .
git commit -m "Create A"
# Make edits to the file
echo "World" > A.txt

If we now rename A.txt to B.txt and stage the changes, Git won’t track this as a rename:

# Variant 1 (bad):
# Rename A to B
mv A.txt B.txt
# Stage changes
git add .
git status
Changes to be committed:
	deleted:    A.txt
	new file:   B.txt

But, if we instead use git mv to rename the edited file, Git will stage the rename and keep the edits unstaged:

# Variant 2 (good):
# Use git mv for renaming
git mv A.txt B.txt
git status
Changes to be committed:
	renamed:    A.txt -> B.txt

Changes not staged for commit:
	modified:   B.txt

Now we can commit the rename, and then stage and commit the edits:

git commit -m "Rename A to B"
git add .
git commit -m "Edit B"

Great!

If you can’t use git mv

I’m often in situations where I can’t use git mv to rename a file because it’s important to perform the rename in some other tool. For example, I write my notes in Obsidian and track them with Git. Obsidian can automatically update links to a note when you rename it, but only if you do the renaming in Obsidian.

The workaround I came up with in this case:

  1. Rename the renamed file back to its original name. I do this in Terminal using the normal mv command.
  2. Redo the intended renaming, but this time with git mv. This puts the repository into the desired state where I can commit the rename operation separately from edits to the file’s contents.

I wrote myself a shell script to perform these steps:

#!/bin/bash
# git-fix-rename

if [ "$#" -ne 2 ]; then
    echo "Allow git to track a rename even when the renamed file has been edited."
    echo "Usage: $0 <new_filename> <old_filename>"
    exit 1
fi

old="$2"
new="$1"

# Situation: we renamed $old to $new. But Git can’t track the rename
# because we made changes to $new at the same time. `git status` shows:
#
# ```
# $ git status
# Changes not staged for commit:
# 	deleted:    $old
#
# Untracked files:
# 	$new
# ```

# Solution:
# 1. Undo the rename temporarily:
if [ -e "$old" ]; then
    echo "Error: Destination file '$old' already exists. Aborting."
    exit 1
fi
mv "$new" "$old"
# 2. Redo the rename, but this time with `git mv`:
git mv "$old" "$new"

# Result: Git stages the pure rename operation (ready to be committed)
# while leaving the edits to $new unstaged. You can now commit the
# the rename and edit steps separately, allowing Git to track the rename.
#
# ```
# $ git status
# Changes to be committed:
# 	renamed:    $old -> $new
#
# Changes not staged for commit:
# 	modified:   $new
# ```

If you name the script e.g. git-fix-rename (no file extension) and make it executable, you can even call it like any built-in Git command:

# We have renamed A.txt to B.txt and made edits to B.txt.
# Now we want to record the rename in Git.
git fix-rename B.txt A.txt

So far, this has worked well for me. But beware: I wrote the script for myself and it doesn’t have robust edge case handling. There’s a chance it might mess up your uncommitted changes.

最近玩的几款卡牌构筑类电子游戏

最近玩了几个卡牌构筑类的电子游戏,觉得颇为有趣,值得记录一下。

首先是 Decktamer(训牌师)。我玩了十几个小时,把初级难度通关了。

它的新设计是用卡牌构筑的形式重新做了一个宝可梦。和杀戮尖塔开创的战斗结束后抽卡,用战斗胜利的奖励钱买卡、洗卡、升级的模式不同。它的战斗卡是不需要洗的,战斗中死亡就直接消失;新卡片是在战斗中捕获对手获得。加强战斗卡的方式主要是用道具卡杂交战斗卡:从一张战斗卡上抽取需要的技能,加到另一张战斗卡上。

战斗过程更像是万智牌那种更传统的卡牌战斗模式:摆放战斗卡都场上,再由上场的卡片发动能力。这种传统战斗模式不同,发动战斗技能没有额外的资源消耗,而修改成每回合必然从卡片上所有技能中选择一个。这可以避免给同一张卡片合成太多能力造成的不平衡。更多能力往往只是增加了容错性,可以应付更多场景。

玩家卡牌被分成了两个牌堆:战斗卡堆和道具卡堆。这种双卡堆的模式最近的卡牌构筑游戏中比较常见,下面还会再提到。不过这里道具卡堆并不是抽牌堆,更像是一个道具背包,可以随时使用。

我在简单难度通关的感受是:只有最终 boss 有挑战。而这种挑战更像是一个谜题。所以第一次面对最终 boss 我没有一次通过。而是熟悉了它的技能,第二次刻意针对这些技能来升级牌组,这样才通关。整个游戏给我的感觉是,解密成分更重一些,也就是该如何养卡才能解决对手。所以游戏里(简单模式下)有无限次的 undo 。我相信选择更高难度后会有不同的感受。不过暂时没有玩下去。


第二个游戏是 Rogue Hex 。大约玩了 7 个小时,四个设计角色中,前三个获得了胜利。

这个游戏用卡牌构筑的形式重新实现了一个简化版的文明。这是我之前就特别想做,但是没想到合适方案的点子。所以,我在初玩时感觉相当有趣。能把卡牌构筑的乐趣融合到 4x 游戏中相当不错。不过,玩到游戏后期,数值还是有点崩,往往中盘就几乎碾压对手了,但为了胜利,依旧需要机械性的玩很多回合。

当然,我觉得它的核心机制设计的还是挺好的。这是个新游戏,平衡还有很大的改善空间。

在这个游戏中,基本资源分别是:劳动力用于抽牌打牌、食物用来发展发展城市、计划用来移动地图单位、信仰用来重置、金钱用来替换前面的基本资源。这些资源有仓储上限,回合结束的时候超过上限的部分会浪费掉。我认为“仓储限制”是让玩家有更多选择的核心设计点之一。

无仓储限制的成长涉及两个点数,科技用于增加正面 buf (类似杀戮尖塔中的神器)以及生产用于增强牌组。我认为设计两个的原因也是给玩家提供选择。大多数情况下,发展科技就会降低生产的增速,反之亦然。

像文明那样,玩家可以在版图上探索采集一次性资源转换为一次性消耗的卡片;扩展城市获得永久资源再用卡牌转换为永久建筑或用版图上的工人单位采集转换为卡片。这部分对文明原本的系统还原的挺不错的(至少在每局游戏的前半段很好)。

游戏循环基本上是用抽牌的手牌积累科技点、生产点和上述的基本资源。用这些点数兑换成发展。和文明一样,指挥地图单位探索和攻击对手也是发展重要的一环。把文明游戏机制中的各种操作翻译为打牌,最大的区别在于:原本玩家可以自由分配每个回合的行动,而在卡牌模式下,需要根据抽到的手牌做决定。这有两个方面的变化:其一、原始机制下提供给玩家的选择非常多,容易产生选择困难;手牌是有限的,玩家可以聚焦在有限行动选择中。其二、可选行动有了随机性,玩家需要根据牌组、抽牌堆、抽牌能力去管理随机性。这也是卡牌构筑类型游戏的核心玩法。

但嫁接卡牌构筑类型和 4x 类型系统有一个需要设计的地方: 4x 游戏中每个回合给玩家的选择都是有意义的,如果把太多东西做成卡牌供玩家选择,而每个回合选择受限,很可能极大的降低游戏的容错性,变得太看脸。所以,在这个游戏中,劳动力即用于支付打牌成本,又可以用来抽牌。这等于提供给玩家一个主动增加选择的能力,而不是像杀戮尖塔中那样,抽牌本身也是特定卡片的技能。

我发现很多类似游戏(下面会再次提到)都有这个设计:允许玩家主动花一个特定资源点,就可以抽牌。

但是,这类游戏成长点太多。不像杀戮尖塔那样只是提高和优化卡组,每局游戏后期很难不崩掉。要么前期开荒死掉,要么后期碾压变得选择没有太大意义。我觉得这个游戏还可以改进,我也很期待会使用怎样的解决方法。


最后一个游戏是 Dawnmaker ,也是最近我最喜欢的一个。我玩了 30 个小时,所有难度都通关了。

这个游戏回答了我很多之前没想到设计解法的问题。它对卡牌构筑类型做了更多的保留,没有卡牌战斗部分,更接近一个生存类型的基地建造游戏。玩这个游戏时让我想到了 retromine 和 Stellar Orphans (星际孤儿)。但很多设计处理的更好。

这个游戏就是打出卡牌在六边形棋盘上建造建筑让基地活下去。没有敌人,无需战斗,需要对抗的只是逐步增加的粮食需求。

单局游戏内只有五种资源:粮食、科技点、工程点、行动点、胜利点。赢得单局游戏可以获得金钱,金钱用来升级卡组,在后续的游戏局种获得优势,以对抗难度逐步上升的挑战。

游戏设定了两个卡堆:手牌堆和市场建筑堆。这两个卡堆分开抽牌。手牌在当前回合打出的工程点可用来在市场堆购买建筑,并在版图上建造。建筑放在版图上就有了持久能力,通常用于配合手牌更有效的获取资源。和 Rogue Hex 不同(它没有双卡堆),购买的建筑卡必须立刻摆在版图上,而不是置入抽牌/弃牌堆。从我游戏的感受看,这是个没有巧妙的改良设计。

星际孤儿也有市场设定,但受传统卡牌构筑规则的影响,还是设计成从市场购买,投入弃牌堆,抽到手牌使用的循环。控制前期卡和后期卡的方式也只是简单的通过市场掉率决定。但在 Dawnmaker 中,通过市场卡分级严格区分了前期卡和后期卡。如果城镇中心没升级到特定等级,对应的卡片也不会在市场上出现。刷市场只需要消耗科技点,这点和 Rogue Hex 用劳动力抽卡异曲同工。但双卡堆设计避免了直接抽行动牌。因为这类游戏中,行动牌和建筑牌本质上是有区别的。Rogue Hex 用一次性消耗卡表达建筑,我觉得是因为没能跳出传统卡牌构筑规则。

在版图上摆建筑是这个游戏的核心玩法。在非卡牌构筑游戏中非常常见,我也见过很多企图和卡牌构筑类型结合的游戏,只有这个我认为结合的最流畅。在星际孤儿中也有四个 slot 用于安装卡片获得永久能力,但只是点缀,而且安装位置并不改变游戏。

Dawnmaker 的建筑摆放相当重要。甚至比构筑行动卡堆更重要。其变化也非常多,留给玩家很大的选择空间。这种在六边形弃牌上填格子的玩法在桌游中很常见,技巧深度和乐趣很有保障。最近几年也有很多电子游戏基于这类玩法,但我觉得更像桌游电子化,而我玩 Dawnmaker 的感觉则更接近电子游戏的体验。

Dawnmaker 在 steam 上有 demo ,有兴趣的同学可以试试。但 demo 只能玩第一个角色,我在正式版本中尝试了另外两个角色,让我惊讶的是,其实三个角色共享同一套完整的卡池。仅仅只是初始卡组不同,但游戏风格差别非常巨大。而且,游戏中并不设常规卡和稀有卡,抽到的概率是一致的。也就是说无论你用那个初始卡组开局,都可能抽到所有卡片,概率是一致的。但玩游戏的感受却并没有那种:我抽到了特定卡以后,游戏就变容易了的感觉。在熟悉了游戏之后,几乎任何卡片搭配都可以玩出花来。

三个初始卡组(角色)标注的是 regular ,complex ,extreme 。我一开始以为是三档难度,通过调整数值减少容错性,需要逐级更熟悉游戏规则才能玩下去。

通关后的感受是:的确越后面的角色需要对游戏更熟悉。但并没有在数值上减少容错性,而是更复杂的角色代表的需要更复杂的卡片 combo 。三个角色玩的体验也很不一样。

虽然在玩第一个角色时,我能感受到不同的流派:用大块农田组合堆砌粮食产能、用主动激活的方式生产粮食、用科技转换粮食等等。胜利点的来源也可以来源于建筑、科技等。但直到我玩第二个角色才发现,靠版图主动技能的组合也可以另成一番景象。而第一个角色更多的思考如何在版图上摆放建筑的位置。第三个角色更是要求不断的拆建建筑,让版图的布局变成动态的。学会这些玩法后,回头在不同角色中也可以实现(因为共享一个大卡池)。

Dawnmaker 的数值调的非常好。每局游戏的生死总是在一线间。尤其是后面的角色更偏重于主动触发版图技能,相比调配行动手牌多了一层选择。有时候看似死局,仔细思考后居然是有解的。同时又需要一点点运气,概率管理原本就是卡牌构筑游戏的核心,在小丑牌中体现得淋漓尽致,Dawnmaker 也有类似的体验。


在游戏规则上,给我的一点启发:

如何把卡牌构筑结合到基地建设类别游戏上?加入版图元素,把建筑卡堆和行动卡堆分离是个不错的设计。

游戏资源种类不需要太多,不需要完全用卡牌表达。传统的卡牌构筑基本规则中,资源点都是靠当前回合的行动手牌生成,不留到下个回合。但建设类游戏的建造资源完全可以跨回合保留积累,但需要强调仓储上限。这样就可以提供给玩家足够的行动选择和组牌的多样性。

非战斗类游戏,可以用不断上升的需求来制造压力。而需求的上升速度可以和玩家的发展规模挂钩。这样玩家就必须在长期发展和短期生存困境上做出抉择。从自己组建的卡堆中抽卡是卡牌构筑类游戏的核心乐趣来源:提供给玩家更丰富的风险管理手段。

React Native 视图拍平(View Flattening)详解

本文详细介绍 React Native 中的“视图拍平”(View Flattening)优化:它是什么、为什么需要、如何工作、哪些情况会阻止拍平、如何检查与控制,以及实践建议和示例代码。面向想提升 RN UI 性能或理解渲染器内部行为的开发者。


概述:什么是视图拍平(View Flattening)?

视图拍平是 React Native 渲染器用来减少原生视图(native view)数量的一类优化手段。渲染器会把那些仅用于布局、且不承担绘制/事件/可访问性等职责的中间 View 标记为“layout-only”(或可被拍平),从而不在原生层创建对应的 UIView/Android View,而直接把它们的布局结果用于父视图或子视图的布局计算。最终在原生层生成的视图层级更扁平(fewer native views),减少内存、布局与绘制开销、提高性能。


为什么需要拍平?

React Native 的视图树由 JS 层描述,但最终需要在原生层创建对应的视图。过多的原生视图会带来多方面成本:

  • 原生视图创建与销毁(桥接/同步)开销。
  • 布局传递(measure/layout)次数和复杂度增加,尤其是大量嵌套时。
  • 绘制/合成(compositing)开销增大,可能导致更多 GPU/CPU 工作或触发额外的渲染层。
  • 内存占用和视图层级复杂度增高,调试与渲染器同步成本上升。

通过拍平可以:

  • 减少原生视图数量;
  • 降低布局与绘制成本;
  • 改善滚动、交互和渲染帧率。

React Native 中是如何实现的?(核心思路)

  • 在 JS 描述的视图树向原生层提交之前,渲染器维护一棵“shadow tree”(影子树)或中间表示,用于布局计算(Yoga)和决定哪些节点需要在原生层实际创建。
  • 如果一个 View 仅承担布局位置、尺寸(例如只用于包裹以实现 margin/padding/排列),且没有会影响绘制或事件行为的属性,渲染器将其标记为“layout-only”。这种节点不会在原生层创建对应控件。
  • 被拍平的节点仍然参与布局计算(Yoga),但不会对应单独的 native view,也不会产生独立的绘制层(compositing layer)。
  • 在旧架构(Paper/旧渲染器)与新架构(Fabric)中实现细节不同,但目标一致:尽量避免生成不必要的 native views。

备注:React Native 中有一个 View 属性 collapsable(默认 true),它允许渲染器在一定条件下移除或拍平该 View;将其设为 false 可以禁止移除/拍平(见下文)。


哪些属性/情况会阻止拍平?

当某个 View 拥有会影响绘制、合成或交互的“显著”属性时,渲染器通常不能将其视为 layout-only,从而不会被拍平。常见会阻止拍平的情形(非穷尽,且随 RN 版本/渲染器实现可能有所差异):

  • 背景/边框/阴影
    • backgroundColor、borderWidth、borderColor、borderRadius(圆角通常需要额外层以实现裁剪)、shadow*(iOS)或 elevation(Android)
  • 透明度与合成
    • opacity < 1(通常需要合成层)
    • 需要离屏合成(needsOffscreenAlphaCompositing 等)
  • 变换与位移
    • transform(rotate/scale/translate)会触发合成层
  • 溢出/裁剪
    • overflow: 'hidden'(会影响子视图裁剪,通常需要独立原生视图)
  • 层级、堆叠、绘制顺序
    • zIndex、和其它需要改变堆叠顺序的属性
  • 事件与交互
    • 注册了触摸/手势回调(onStartShouldSetResponder、onTouchStart、onPress 等),或者使用 Touchable* 包装
  • 可访问性与标识
    • accessibilityLabel、accessible、accessibilityRole、importantForAccessibility 等
    • nativeID、testID(在某些实现中)
  • 引用/测量/原生交互
    • 需要直接的原生 view 引用(例如用 ref 调用 measure、findNodeHandle、原生模块传递 view)
  • 动画、Native Driver
    • 使用需要原生层支持的动画驱动(取决于具体动画方案)
  • 特定平台或第三方原生模块需求
    • 一些原生组件或第三方库可能要求存在原生 view

注意:上述规则在不同 RN 版本或新旧渲染器间会有差别。总的原则是:凡是会影响最终绘制、拦截事件或需要单独原生标识的属性,都可能阻止拍平。


如何检查某个 View 是否被拍平?

  • React Native 的开发者菜单中的 Performance Monitor / Inspector(或 Flipper 的 React DevTools)可以帮助检查视图数量与性能。
  • 在 Android:使用 Android Studio 的 Layout Inspector 或 Hierarchy Viewer 查看 native view 层级。
  • 在 iOS:使用 Xcode 的 View Debugger 查看 UIView 层级。
  • 在调试时,临时设置 collapsable={false} 来强制保留某个 View,观察是否对性能或布局产生影响。
  • 使用 profiling 工具(Systrace、Flipper、React DevTools Profiler)分析帧时间与布局/绘制时间。

如何控制或影响拍平行为?

  • 强制禁止拍平(或移除优化):在 View 上设置 collapsable={false}。这会阻止渲染器把该 View 合并/移除,常用于调试或确实需要保留原生 view 的场景(例如需要稳定的 ref/measure)。
    • 例:<View collapsable={false} ref={v => this._v = v} />
  • 通过避免给中间 View 添加会阻止拍平的属性来让渲染器保持拍平:
    • 将背景、边框等样式合并到父视图或子视图。
    • 用父容器或样式替代额外的包装 View(例如用 padding 替代额外的内层 View)。
  • 使用 React.Fragment(<>...</>)替代不必要的 (当不需要视图时)。
  • 优化布局:尽量让装饰性布局通过父组件的样式来完成,而不是添加大量嵌套包装。

常见优化建议(实践)

  • 审查 UI 组件树,删除那些仅作空容器或用于简单间距的多余 View。
  • 将样式尽可能合并到可合并的父 View(padding、margin、border 等),避免多个只为间距/对齐存在的嵌套 View。
  • 使用 Flexbox 的属性(justifyContent、alignItems、padding、margin)来代替额外 wrapper。
  • 避免在大量简单布局的地方使用透明度、复杂 transform、overflow hidden 等会产生合成层的属性。
  • 在需要精确测量或必须有原生 view 的地方(例如动画、手势或原生模块需求),才禁用拍平(collapsable={false})。
  • 在性能敏感界面(如长列表 item)特别注意不要给每个 item 加很多会阻止拍平的样式或属性。比如在 FlatList 的渲染项内避免多余包装。

代码示例

示例 1 — 不必要的嵌套(可被拍平或优化):

// before
<View style={styles.container}>
  <View style={styles.wrapper}>
    <View style={styles.inner}>
      <Text>内容</Text>
    </View>
  </View>
</View>

优化后(减少中间 wrapper):

// after
<View style={styles.container}>
  <View style={[styles.wrapper, styles.inner]}>
    <Text>内容</Text>
  </View>
</View>

或者直接合并样式到父容器,或使用 padding/margin 替代包装层。

示例 2 — 强制保留原生 view(调试或必要情况):

<View collapsable={false} ref={ref => (this.viewRef = ref)}>
  {/* 这个 View 不会被渲染器拍平或移除 */}
  <Text>必须保留原生 view 的场合</Text>
</View>

何时不该拍平(或应禁止拍平)

  • 你依赖原生视图引用(measure、nativeEvent.target、原生模块);
  • 需要单独的可访问性节点或 a11y 行为;
  • 某个包装 View 的视觉效果(背景、圆角、阴影)或遮罩/裁剪是必须的;
  • 你使用的动画或第三方原生模块明确要求存在 native view。

在这些情况下,保持 view 不被拍平是合理的,但应有意识地只在确实需要时才保留。


与 Fabric(新渲染器)相关的说明

React Native 的新架构(Fabric)在内部对组件树与提交方式做了很多改进,拍平/layout-only 的实现细节会有变化,但高层目标一致:减少不必要的 native view,降低跨层通信成本。如果你在新架构中遇到差异(某些属性的行为不同),建议查看对应 RN 版本的变更日志与 docs,因为某些规则在 Fabric 下可能更严格或更灵活。


检测与度量工具(快速清单)

  • RN Dev Menu -> Show Perf Monitor / Inspector
  • Flipper + React DevTools / React Native Performance 插件
  • Android Studio Layout Inspector / GPU Profiler
  • Xcode View Debugger / Instruments
  • Systrace / Trace Event profiler(分析帧时间、布局与绘制)
  • 在代码中临时设置 collapsable={false} 并对比性能,作为实验手段

总结

视图拍平是 React Native 在桥接与原生层面上非常重要的性能优化:通过避免创建无实质渲染或交互作用的原生视图来降低布局与绘制开销。但拍平并非“万能”,某些样式、交互或可访问性需求会阻止拍平。最好的实践是:

  • 在设计 UI 时尽量减少不必要的嵌套;
  • 理解哪些属性会触发额外的原生层或合成层;
  • 在确实需要时才禁用拍平(collapsable={false})或保留包装视图;
  • 使用合适的 profiling/inspection 工具来验证优化效果。

参考(建议阅读)

  • React Native 文档(Layout / Views / Performance)
  • React Native Issue/PR 与源码中关于 layout-only / collapsable 的讨论
  • Fabric 架构说明与性能最佳实践

(注:不同 React Native 版本与不同渲染器实现细节会有所差异。上文侧重原理与实践建议;在遇到具体问题时,请参照你当前 RN 版本的官方文档与实现源码以获得最准确的规则。)

IOS SwiftUI 全组件详解

SwiftUI 全组件详解(iOS 端)

SwiftUI 是苹果推出的声明式 UI 框架,覆盖 iOS、macOS、watchOS 等多平台,下面是 iOS 端核心组件的分类详解,包含用法、核心属性和常见场景,基于最新 SwiftUI 5(iOS 18)适配。

一、基础视图组件

基础视图是构建 UI 的最小单元,用于展示文本、图片、图标等核心内容。

1. Text(文本)

作用:展示静态/动态文本,支持富文本、样式定制。 核心属性

  • font(_:):设置字体(Font.title/Font.system(size:16, weight:.bold));
  • foregroundStyle(_:):文本颜色(支持渐变);
  • multilineTextAlignment(_:):对齐方式(.leading/.center/.trailing);
  • lineLimit(_:):行数限制(nil 表示无限制);
  • truncationMode(_:):截断方式(.tail/.head/.middle)。

示例

Text("Hello SwiftUI")
    .font(.title)
    .foregroundStyle(LinearGradient(colors: [.red, .blue], startPoint: .leading, endPoint: .trailing))
    .padding()

2. Image(图片)

作用:展示本地/网络图片,支持缩放、裁剪、渲染模式。 核心属性

  • resizable(capInsets:resizingMode:):自适应尺寸(必加,否则图片按原始尺寸显示);
  • scaledToFit()/scaledToFill():适配方式(保持比例);
  • clipShape(_:):裁剪形状(Circle()/RoundedRectangle(cornerRadius:10));
  • renderingMode(_:):渲染模式(.original 保留原图,.template 作为模板色);
  • asyncImage(url:):加载网络图片(iOS 15+)。

示例

// 本地图片
Image("avatar")
    .resizable()
    .scaledToFit()
    .frame(width: 100, height: 100)
    .clipShape(Circle())

// 网络图片
AsyncImage(url: URL(string: "https://example.com/photo.jpg")) { phase in
    switch phase {
    case .empty: ProgressView() // 加载中
    case .success(let image): image.resizable().scaledToFit()
    case .failure: Image(systemName: "photo.badge.exclamationmark") // 加载失败
    @unknown default: EmptyView()
    }
}
.frame(width: 200, height: 200)

3. Image(systemName:)(SF Symbols 图标)

作用:使用苹果内置的 SF Symbols 图标库,支持自定义颜色/大小。 核心属性

  • font(_:):设置图标大小(Font.system(size:24));
  • foregroundStyle(_:):图标颜色;
  • symbolVariant(_:):图标变体(.fill/.circle/.square);
  • symbolRenderingMode(_:):渲染模式(.hierarchical/.palette)。

示例

Image(systemName: "heart.fill")
    .font(.system(size: 32))
    .foregroundStyle(.red)
    .symbolVariant(.circle)

4. Label(标签)

作用:组合图标+文本的复合视图(系统默认排版)。 核心属性

  • init(_ title: String, systemImage: String):快捷创建(文本+SF 图标);
  • labelStyle(_:):样式(.iconOnly/.titleOnly/.titleAndIcon/.automatic);
  • titleSpacing(_:):图标与文本间距。

示例

Label("设置", systemImage: "gear")
    .labelStyle(.automatic)
    .labelIconToTitleSpacing(8)
    .foregroundStyle(.gray)

二、容器视图组件

容器视图用于布局和管理多个子视图,是构建复杂 UI 的核心。

1. VStack(垂直栈)

作用:垂直排列子视图,默认居中对齐。 核心属性

  • init(alignment: HorizontalAlignment, spacing: CGFloat?, content:):对齐方式、子视图间距;
  • spacing:子视图间距(默认系统自适应);
  • alignment:水平对齐(.leading/.center/.trailing/.top 等)。

示例

VStack(alignment: .leading, spacing: 12) {
    Text("标题")
        .font(.headline)
    Text("副标题")
        .font(.subheadline)
        .foregroundStyle(.gray)
}
.padding()

2. HStack(水平栈)

作用:水平排列子视图,默认居中对齐。 核心属性

  • init(alignment: VerticalAlignment, spacing: CGFloat?, content:):垂直对齐、子视图间距;
  • alignment:垂直对齐(.top/.center/.bottom/.firstTextBaseline 等)。

示例

HStack(alignment: .center, spacing: 8) {
    Image(systemName: "bell")
    Text("消息通知")
    Spacer() // 推挤右侧视图到最右
    Image(systemName: "chevron.right")
        .foregroundStyle(.gray)
}
.padding(.horizontal)

3. ZStack(层级栈)

作用:重叠排列子视图,默认居中对齐。 核心属性

  • init(alignment: Alignment, content:):对齐方式(.topLeading/.center/.bottomTrailing 等);
  • zIndex(_:):子视图层级(数值越大越上层)。

示例

ZStack(alignment: .bottomTrailing) {
    Image("background")
        .resizable()
        .scaledToFill()
        .frame(width: 200, height: 200)
    Text("标签")
        .padding(4)
        .background(Color.red)
        .foregroundStyle(.white)
        .cornerRadius(4)
        .padding(8)
}
.clipShape(RoundedRectangle(cornerRadius: 10))

4. List(列表)

作用:滚动列表,支持单行/分组/可编辑,替代 UIKit 的 UITableView。 核心属性

  • listStyle(_:):列表样式(.plain/.grouped/.insetGrouped/.sidebar);
  • onDelete(perform:):删除操作(需配合 ForEach);
  • onMove(perform:):移动操作(需配合 EditButton);
  • selection(_:):选中项绑定(iOS 14+)。

示例

struct ListDemo: View {
    @State private var items = ["苹果", "香蕉", "橙子"]
    @State private var editing = false
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(items, id: \.self) { item in
                    Text(item)
                }
                .onDelete { indexSet in
                    items.remove(atOffsets: indexSet)
                }
                .onMove { source, destination in
                    items.move(fromOffsets: source, toOffset: destination)
                }
            }
            .listStyle(.insetGrouped)
            .navigationTitle("水果列表")
            .toolbar {
                EditButton() // 编辑按钮
            }
        }
    }
}

5. ScrollView(滚动视图)

作用:可滚动的容器,支持垂直/水平滚动,无默认单元格复用(区别于 List)。 核心属性

  • init(.vertical/.horizontal, showsIndicators: Bool, content:):滚动方向、是否显示滚动条;
  • scrollIndicators(_:):滚动条样式(.hidden/.automatic/.visible);
  • scrollDismissesKeyboard(_:):滚动时关闭键盘(.immediately/.interactively)。

示例

ScrollView(.vertical, showsIndicators: false) {
    VStack(spacing: 20) {
        ForEach(1...20, id: \.self) { i in
            Text("滚动项 \(i)")
                .frame(maxWidth: .infinity)
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(8)
        }
    }
    .padding()
}

6. LazyVStack/LazyHStack(懒加载栈)

作用:类似 VStack/HStack,但仅渲染可视区域内的子视图,适合长列表(性能优化)。 核心属性

  • pinnedViews(_:):固定视图(.sectionHeaders/.sectionFooters);
  • 其他属性与普通栈一致。

示例

ScrollView {
    LazyVStack(spacing: 10, pinnedViews: .sectionHeaders) {
        Section(header: Text("头部固定").font(.title).background(Color.white)) {
            ForEach(1...100, id: \.self) { i in
                Text("懒加载项 \(i)")
                    .frame(height: 50)
            }
        }
    }
}

7. Grid(网格)

作用:二维网格布局(iOS 16+),替代手动嵌套 HStack/VStack。 核心组件

  • GridRow:网格行;
  • GridItem:网格列配置(.flexible()/.adaptive(minimum:)/.fixed());
  • alignment:单元格对齐方式。

示例

Grid(alignment: .center, horizontalSpacing: 8, verticalSpacing: 8) {
    GridRow {
        Text("1")
            .frame(maxWidth: .infinity)
            .background(Color.gray.opacity(0.1))
        Text("2")
            .frame(maxWidth: .infinity)
            .background(Color.gray.opacity(0.1))
    }
    GridRow {
        Text("3")
            .frame(maxWidth: .infinity)
            .background(Color.gray.opacity(0.1))
        Text("4")
            .frame(maxWidth: .infinity)
            .background(Color.gray.opacity(0.1))
    }
}
.padding()

8. Group(分组)

作用:分组子视图,无视觉效果,仅用于拆分代码或突破 10 个子视图限制。 示例

VStack {
    Group {
        Text("1")
        Text("2")
        Text("3")
    }
    Group {
        Text("4")
        Text("5")
    }
}

三、交互控件组件

用于响应用户操作(点击、输入、选择等)的交互型组件。

1. Button(按钮)

作用:响应点击操作,支持自定义样式。 核心属性

  • init(action: @escaping () -> Void, label: () -> Label):点击事件、按钮内容;
  • buttonStyle(_:):样式(.plain/.bordered/.borderedProminent/.borderless);
  • tint(_:):按钮主色调;
  • disabled(_:):是否禁用。

示例

Button(action: {
    print("按钮点击")
}) {
    HStack {
        Image(systemName: "paperplane")
        Text("提交")
    }
}
.buttonStyle(.borderedProminent)
.tint(.blue)
.disabled(false)
.padding()

2. Toggle(开关)

作用:布尔值切换控件(类似 UISwitch)。 核心属性

  • init(isOn: Binding<Bool>, label:):绑定布尔值、标签;
  • toggleStyle(_:):样式(.switch/.button/.checkbox);
  • tint(_:):开启状态颜色。

示例

struct ToggleDemo: View {
    @State private var isOn = false
    
    var body: some View {
        Toggle(isOn: $isOn) {
            Text("开启通知")
        }
        .toggleStyle(.switch)
        .tint(.green)
        .padding()
    }
}

3. TextField(单行输入框)

作用:单行文本输入,支持键盘类型、占位符等。 核心属性

  • init(_ prompt: String, text: Binding<String>):占位符、绑定文本;
  • keyboardType(_:):键盘类型(.numberPad/.emailAddress/.default);
  • autocapitalization(_:):自动大写(.none/.words);
  • disableAutocorrection(_:):禁用自动纠错;
  • onCommit(perform:):回车触发事件。

示例

struct TextFieldDemo: View {
    @State private var text = ""
    
    var body: some View {
        TextField("请输入用户名", text: $text)
            .keyboardType(.default)
            .autocapitalization(.none)
            .disableAutocorrection(true)
            .padding()
            .background(Color.gray.opacity(0.1))
            .cornerRadius(8)
            .padding(.horizontal)
    }
}

4. TextEditor(多行文本编辑器)

作用:多行文本输入(类似 UITextView)。 核心属性

  • init(text: Binding<String>):绑定文本;
  • foregroundStyle(_:):文本颜色;
  • font(_:):字体;
  • scrollContentBackground(_:):是否显示滚动背景(.hidden 可去除默认白色背景)。

示例

struct TextEditorDemo: View {
    @State private var text = ""
    
    var body: some View {
        TextEditor(text: $text)
            .font(.body)
            .foregroundStyle(.black)
            .scrollContentBackground(.hidden)
            .background(Color.gray.opacity(0.1))
            .frame(height: 150)
            .padding()
    }
}

5. Slider(滑块)

作用:数值选择滑块(范围值)。 核心属性

  • init(value: Binding<Double>, in range: ClosedRange<Double>, step: Double):绑定值、范围、步长;
  • tint(_:):滑块进度颜色;
  • onChange(of: value) { newValue in }:值变化监听。

示例

struct SliderDemo: View {
    @State private var progress = 0.0
    
    var body: some View {
        VStack {
            Slider(value: $progress, in: 0...100, step: 1)
                .tint(.orange)
                .padding()
            Text("进度:\(Int(progress))%")
        }
    }
}

6. Picker(选择器)

作用:下拉/滚轮选择器,支持多类型数据源。 核心属性

  • init(selection: Binding<Value>, label: Label, content:):选中值绑定、标签;
  • pickerStyle(_:):样式(.menu/.wheel/.segmented/.inline);
  • ForEach:数据源遍历。

示例

struct PickerDemo: View {
    @State private var selectedFruit = "苹果"
    let fruits = ["苹果", "香蕉", "橙子", "葡萄"]
    
    var body: some View {
        Picker(selection: $selectedFruit, label: Text("选择水果")) {
            ForEach(fruits, id: \.self) {
                Text($0)
            }
        }
        .pickerStyle(.menu) // 下拉菜单样式
        .padding()
    }
}

7. DatePicker(日期选择器)

作用:日期/时间选择器(类似 UIDatePicker)。 核心属性

  • init(_ label: String, selection: Binding<Date>, displayedComponents:):标签、绑定日期、显示组件(.date/.time/.dateAndTime);
  • datePickerStyle(_:):样式(.compact/.wheel/.graphical);
  • range(_:):可选日期范围。

示例

struct DatePickerDemo: View {
    @State private var selectedDate = Date()
    
    var body: some View {
        DatePicker("选择日期", selection: $selectedDate, displayedComponents: .date)
            .datePickerStyle(.compact)
            .padding()
    }
}

8. Stepper(步进器)

作用:增减数值的控件(+/- 按钮)。 核心属性

  • init(_ label: String, value: Binding<Value>, in range: ClosedRange<Value>, step: Value):标签、绑定值、范围、步长;
  • onIncrement(perform:):增加触发事件;
  • onDecrement(perform:):减少触发事件。

示例

struct StepperDemo: View {
    @State private var count = 0
    
    var body: some View {
        Stepper("数量:\(count)", value: $count, in: 0...10, step: 1)
            .padding()
    }
}

四、导航与页面组件

用于页面跳转、导航栏管理、模态弹窗等场景。

1. NavigationStack(导航栈,iOS 16+)

作用:替代旧版 NavigationView,实现页面层级导航。 核心属性

  • init(path: Binding<NavigationPath>, root:):导航路径绑定(支持任意可哈希类型);
  • navigationTitle(_:):导航栏标题;
  • navigationBarTitleDisplayMode(_:):标题显示模式(.large/.inline/.automatic);
  • toolbar(content:):导航栏工具栏(按钮、菜单);
  • navigationDestination(for:destination:):目标页面映射。

示例

struct NavigationDemo: View {
    @State private var path = NavigationPath()
    let fruits = ["苹果", "香蕉", "橙子"]
    
    var body: some View {
        NavigationStack(path: $path) {
            List(fruits, id: \.self) { fruit in
                NavigationLink(value: fruit) {
                    Text(fruit)
                }
            }
            .navigationTitle("水果列表")
            .navigationDestination(for: String.self) { fruit in
                Text("你选择了:\(fruit)")
                    .navigationTitle(fruit)
            }
            .toolbar {
                Button("返回首页") {
                    path.removeLast(path.count)
                }
            }
        }
    }
}

2. Sheet(模态弹窗)

作用:弹出模态视图(从底部滑入)。 核心属性

  • sheet(isPresented: Binding<Bool>, onDismiss: (() -> Void)?, content:):显示状态绑定、关闭回调、弹窗内容;
  • presentationDetents(_:):弹窗高度(.height(200)/.medium/.large,iOS 16+);
  • presentationDragIndicator(_:):拖拽指示器(.visible/.hidden,iOS 16+)。

示例

struct SheetDemo: View {
    @State private var showSheet = false
    
    var body: some View {
        Button("打开弹窗") {
            showSheet = true
        }
        .sheet(isPresented: $showSheet) {
            Text("模态弹窗内容")
                .presentationDetents([.medium, .large])
                .presentationDragIndicator(.visible)
        }
    }
}

3. Alert(警告弹窗)

作用:系统样式的警告弹窗(含标题、按钮)。 核心属性

  • alert(_: isPresented: actions: message:):标题、显示状态、按钮组、消息内容;
  • ButtonRole:按钮角色(.cancel/.destructive/.none)。

示例

struct AlertDemo: View {
    @State private var showAlert = false
    
    var body: some View {
        Button("显示警告") {
            showAlert = true
        }
        .alert("提示", isPresented: $showAlert) {
            Button("取消", role: .cancel) {}
            Button("确认", role: .none) {}
        } message: {
            Text("确定要执行此操作吗?")
        }
    }
}

4. Popover(弹出框,iOS 14+)

作用:从指定位置弹出的气泡视图。 核心属性

  • popover(isPresented: attachmentAnchor: arrowEdge: content:):显示状态、锚点、箭头方向、内容。

示例

struct PopoverDemo: View {
    @State private var showPopover = false
    
    var body: some View {
        Button("弹出菜单") {
            showPopover = true
        }
        .popover(isPresented: $showPopover, attachmentAnchor: .point(.topTrailing), arrowEdge: .top) {
            VStack {
                Text("选项1")
                Text("选项2")
                Text("选项3")
            }
            .padding()
            .frame(width: 150)
        }
    }
}

五、视觉装饰组件

用于增强 UI 视觉效果的装饰性组件。

1. Divider(分割线)

作用:水平分割线,用于分隔视图。 核心属性

  • foregroundStyle(_:):分割线颜色;
  • frame(height:):分割线高度。

示例

VStack {
    Text("上部分")
    Divider()
        .foregroundStyle(.gray.opacity(0.3))
        .frame(height: 1)
    Text("下部分")
}
.padding()

2. Spacer(空白填充)

作用:占据剩余空间,用于推挤子视图到指定位置。 核心属性

  • minLength(_:):最小长度(默认 0)。

示例

HStack {
    Text("左侧")
    Spacer(minLength: 20)
    Text("右侧")
}
.padding()

3. Color(颜色视图)

作用:纯色背景/装饰,可作为视图使用。 核心属性

  • opacity(_:):透明度;
  • gradient(_:):渐变(LinearGradient/RadialGradient/AngularGradient);
  • frame():尺寸(默认填充父视图)。

示例

Color.blue
    .opacity(0.5)
    .frame(height: 100)
    .cornerRadius(10)
    .padding()

4. RoundedRectangle/Circle/Capsule(形状视图)

作用:基础形状,用于背景、裁剪、装饰。 核心属性

  • fill(_:):填充颜色/渐变;
  • stroke(_:lineWidth:):描边;
  • cornerRadius(_:)(仅 RoundedRectangle):圆角。

示例

RoundedRectangle(cornerRadius: 12)
    .fill(LinearGradient(colors: [.purple, .pink], startPoint: .leading, endPoint: .trailing))
    .stroke(Color.white, lineWidth: 2)
    .frame(width: 200, height: 100)

六、高级组件(iOS 14+)

1. ProgressView(进度条)

作用:展示加载进度(确定/不确定)。 核心属性

  • init(value: Binding<Double>?, total: Double = 1.0):进度值、总进度;
  • progressViewStyle(_:):样式(.linear/.circular/.automatic);
  • tint(_:):进度颜色。

示例

struct ProgressViewDemo: View {
    @State private var progress = 0.5
    
    var body: some View {
        VStack {
            // 确定进度
            ProgressView(value: progress, total: 1.0)
                .progressViewStyle(.linear)
                .tint(.blue)
                .padding()
            // 不确定进度(加载中)
            ProgressView()
                .progressViewStyle(.circular)
                .tint(.orange)
        }
    }
}

2. Menu(菜单)

作用:下拉菜单(点击展开选项)。 核心属性

  • init(content: label:):菜单内容、触发标签;
  • menuStyle(_:):样式(.borderlessButton/.contextMenu);
  • Button(role:):菜单选项(支持取消/销毁角色)。

示例

Menu {
    Button("编辑", action: {})
    Button("删除", role: .destructive, action: {})
    Button("分享", action: {})
    Divider()
    Button("取消", role: .cancel, action: {})
} label: {
    Label("更多操作", systemImage: "ellipsis.circle")
}

3. TabView(标签页)

作用:底部/顶部标签页切换(类似 UITabBarController)。 核心属性

  • tabViewStyle(_:):样式(.page/.tabBar);
  • tabItem { Label(...) }:标签项内容;
  • tag(_:):标签标识(配合选中绑定);
  • selection(_:):选中标签绑定。

示例

struct TabViewDemo: View {
    @State private var selectedTab = 0
    
    var body: some View {
        TabView(selection: $selectedTab) {
            Text("首页")
                .tabItem {
                    Label("首页", systemImage: "house")
                }
                .tag(0)
            Text("消息")
                .tabItem {
                    Label("消息", systemImage: "bell")
                }
                .tag(1)
            Text("我的")
                .tabItem {
                    Label("我的", systemImage: "person")
                }
                .tag(2)
        }
        .tint(.blue)
    }
}

七、关键注意事项

  1. 版本适配:部分组件(如 NavigationStackGrid)仅支持 iOS 16+,低版本需用 NavigationView 等兼容方案;
  2. 性能优化:长列表优先用 List/LazyVStack,避免普通 VStack 导致的卡顿;
  3. 状态管理:所有交互组件需配合 @State/@Binding/@ObservedObject 等状态属性;
  4. 样式定制:可通过 ViewModifier 封装自定义样式,减少重复代码;
  5. 多平台兼容:部分组件(如 SidebarListStyle)仅在 iPad/macOS 生效,iOS 需适配。

以上覆盖了 SwiftUI 核心组件,实际开发中可结合苹果官方文档(SwiftUI 官方文档)和 SF Symbols 库扩展使用。

iOS开发之MetricKit监控App性能

介绍

iOS 13 之后,Apple 推出了 MetricKit — 一个由系统统一收集性能指标并按日自动送达给应用的强大框架。不需要侵入式埋点,不需要长期后台运行,也不需要手动分析复杂的系统行为,MetricKit 能够帮助开发者在真实用户设备上捕获 CPU、内存、启动耗时、异常诊断等关键性能指标。

特点

  • 自动收集:基于设备上的真实行为,系统会在后台定期收集性能数据。
  • 每天上报:每次应用启动,系统会把前一天的性能指标通过回调送达。
  • 极低侵入:性能统计由系统统一完成,不增加 CPU/内存负担,不影响用户体验。
  • 结构标准:系统提供结构化的 MXMetricPayload,便于解析与分析。
  • 隐私保护:Apple 会对数据进行匿名化和聚合处理,保护用户隐私。

步骤

  1. 导入 MetricKit。
  2. 注册 MetricKit 的订阅者。
  3. 实现回调协议,接受 Metric 数据。
  4. 逐项解析 Metric 数据。
  5. 上传 Metric 数据到服务器(可选)。

案例

以下是一份应用 MetricKit 的模版代码。

import MetricKit
import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        MXMetricManager.shared.add(self)
        return true
    }

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }

    func applicationWillTerminate(_ application: UIApplication) {
        MXMetricManager.shared.remove(self)
    }
}

// MARK: - MXMetricManagerSubscriber,回调协议
extension AppDelegate: MXMetricManagerSubscriber {
    // MARK: 接收每日性能指标
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            handleMetrics(payload)
        }
    }

    // MARK: 接收诊断报告(崩溃、挂起等)
    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            handleDiagnostic(payload)
        }
    }

    // MARK: 处理每日性能指标
    func handleMetrics(_ payload: MXMetricPayload) {
        print("===== 开始处理性能指标 =====")
        // 时间范围
        let formatter = DateFormatter()
        formatter.dateStyle = .short
        formatter.timeStyle = .short
        let timeRange = "\(formatter.string(from: payload.timeStampBegin)) - \(formatter.string(from: payload.timeStampEnd))"
        print("指标时间范围: \(timeRange)")

        // CPU指标
        if let cpu = payload.cpuMetrics {
            let cpuTime = cpu.cumulativeCPUTime.value
            print("CPU总使用时间: \(String(format: "%.2f", cpuTime)) 秒")
        }

        // GPU指标
        if let gpu = payload.gpuMetrics {
            let gpuTime = gpu.cumulativeGPUTime.value
            print("GPU总使用时间: \(String(format: "%.2f", gpuTime)) 秒")
        }

        // 内存指标
        if let memory = payload.memoryMetrics {
            let avgMemory = memory.averageSuspendedMemory.averageMeasurement.value
            let peakMemory = memory.peakMemoryUsage.value
            print("平均挂起内存: \(String(format: "%.2f", avgMemory / 1024 / 1024)) MB")
            print("峰值内存使用: \(String(format: "%.2f", peakMemory / 1024 / 1024)) MB")
        }

        // 启动时间指标
        if let launch = payload.applicationLaunchMetrics {
            let histogram = launch.histogrammedTimeToFirstDraw
            print("启动时间分布: ")
            for bucket in histogram.bucketEnumerator {
                if let bucket = bucket as? MXHistogramBucket<UnitDuration> {
                    let start = String(format: "%.2f", bucket.bucketStart.value)
                    let end = String(format: "%.2f", bucket.bucketEnd.value)
                    print("范围: \(start)-\(end)秒, 次数: \(bucket.bucketCount)")
                }
            }
        }

        // 上传数据
        let jsonData = payload.jsonRepresentation()
        uploadToServer(jsonData)
    }

    // MARK: 处理诊断报告
    func handleDiagnostic(_ payload: MXDiagnosticPayload) {
        print("===== 开始处理诊断报告 =====")

        // 崩溃诊断
        if let crashes = payload.crashDiagnostics {
            print("崩溃次数: \(crashes.count)")
            for (index, crash) in crashes.enumerated() {
                print("崩溃 \(index + 1): ")
                print("应用版本: \(crash.metaData.applicationBuildVersion)")
                print("设备类型: \(crash.metaData.deviceType)")
                print("系统版本: \(crash.metaData.osVersion)")
                print("平台架构: \(crash.metaData.platformArchitecture)")
                print("调用栈: \(crash.callStackTree)")
            }
        }

        // CPU异常
        if let cpuExceptions = payload.cpuExceptionDiagnostics {
            print("CPU异常次数: \(cpuExceptions.count)")
            for (index, exception) in cpuExceptions.enumerated() {
                print("CPU异常 \(index + 1): ")
                print("总CPU时间: \(exception.totalCPUTime.value) 秒")
                print("调用栈: \(exception.callStackTree)")
            }
        }

        // 上传数据
        let jsonData = payload.jsonRepresentation()
        uploadToServer(jsonData)
    }

    // MARK: 上传数据到服务器
    func uploadToServer(_ json: Data) {
        guard !json.isEmpty else { return }
        // 上传数据,如URLSession上传
    }
}

《Flutter全栈开发实战指南:从零到高级》- 24 -集成推送通知

引言

推送通知在移动开发中随处可见,比方说你关注的商品降价了,你的微信收到了新消息,你的外卖提示骑手已取餐,等等这些都离不开推送通知。推送通知不仅仅是“弹个窗”,它是移动应用与用户保持连接的生命线。

在Flutter生态中,推送通知的实现方案多种多样,但最主流、最成熟的方案当属Firebase Cloud Messaging。今天,我们就来深入探讨如何在Flutter应用中集成FCM,并实现本地通知、自定义通知栏以及消息路由。

一、推送通知的底层机制

1.1 FCM工作原理

核心原理:FCM不是简单的HTTP请求,本质是一个消息路由,设备与FCM服务器保持长连接,而不是每次推送都新建连接。

graph TD
    A[你的服务器] -->|HTTPS| B[FCM服务器]
    B -->|长连接| C[设备1]
    B -->|长连接| D[设备2]
    B -->|长连接| E[设备N...]
    
    C -->|心跳包| B
    D -->|心跳包| B
    E -->|心跳包| B
    
    F[APNs/GCM] -->|平台通道| B
    B -->|二进制协议| F

过程

  • 设备通过Google Play服务(Android)或APNs(iOS)与FCM建立连接
  • 连接建立后,设备定期发送心跳包维持连接
  • 你的服务器只需要把消息发给FCM,FCM负责路由到具体设备

核心代码实现

// 设备注册流程
class FCMRegistration {
  Future<String?> getToken() async {
    // Android:检查Google服务是否可用
    if (Platform.isAndroid) {
      await _checkGoogleServices();
    }
    
    // 请求权限
    final settings = await FirebaseMessaging.instance.requestPermission();
    if (settings.authorizationStatus != AuthorizationStatus.authorized) {
      return null;
    }
    
    // 获取Token
    return await FirebaseMessaging.instance.getToken();
  }
  
  // Token刷新监听
  void setupTokenRefresh() {
    FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
      // Token变化时更新到你的服务器
      _updateTokenOnServer(newToken);
    });
  }
}

注意

  1. Token在以下情况会变化:重装应用、清除应用数据等情况;
  2. 必须监听Token刷新,否则用户会收不到推送;
  3. iOS需要在真机上测试,模拟器不支持推送;

1.2 消息类型的本质区别

很多人分不清通知消息和数据消息,其实它们的区别在于处理者不同

维度 通知消息 (Notification) 数据消息 (Data) 混合消息 (Hybrid)
处理者 操作系统自动处理 应用程序自己处理 系统显示通知 + 应用处理数据
消息格式 包含 notification 字段 包含 data 字段 同时包含 notificationdata 字段
应用状态 任何状态都能收到
(前台/后台/终止)
必须在前台或后台处理时才能收到 系统部分任何状态都能收到
数据部分需要应用处理
推送示例 json<br>{<br> "notification": {<br> "title": "新消息",<br> "body": "您收到一条消息"<br> },<br> "to": "token"<br>} json<br>{<br> "data": {<br> "type": "chat",<br> "from": "user123"<br> },<br> "to": "token"<br>} json<br>{<br> "notification": {<br> "title": "新消息"<br> },<br> "data": {<br> "type": "chat"<br> },<br> "to": "token"<br>}
iOS处理 通过APNs直接显示 应用必须在前台或配置后台模式 系统显示通知,
点击后应用处理数据
Android处理 系统通知栏直接显示 应用需要在前台或
创建前台服务处理
系统显示通知,
点击后应用处理数据
payload大小 较小,只包含显示内容 最大4KB 通知部分+数据部分≤4KB
推荐场景 简单通知提醒
营销推送
系统公告
需要应用处理的业务逻辑
实时数据同步
静默更新
既需要显示通知
又需要处理业务逻辑

核心代码

// 处理不同类型的消息
void setupMessageHandlers() {
  // 1. 处理前台消息
  FirebaseMessaging.onMessage.listen((RemoteMessage message) {
    if (message.notification != null) {
      // 通知消息
      _showLocalNotification(message);
    }
    
    if (message.data.isNotEmpty) {
      // 数据消息
      _processDataMessage(message.data);
    }
  });
  
  // 2. 处理后台消息
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
}

// 后台处理函数
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // 注意:这里不能直接更新UI,只能处理数据或显示本地通知
  if (message.data.isNotEmpty) {
    await _processInBackground(message.data);
  }
}

二、本地通知

2.1 Android

为什么需要通知渠道?

  • 用户可以对不同类型的通知进行更精细地控制
  • 应用需要为通知分类,否则无法在Android 8.0+上显示

核心实现

// 创建通知渠道
Future<void> createNotificationChannels() async {
  // 唯一的渠道ID
  const AndroidNotificationChannel channel = AndroidNotificationChannel(
    'important_channel',  
    '重要通知',           
    description: '账户安全、订单状态等关键通知',
    importance: Importance.max,  
    
    // 配置
    playSound: true,
    sound: RawResourceAndroidNotificationSound('notification'),
    enableVibration: true,
    vibrationPattern: Int64List.fromList([0, 500, 200, 500]),
    showBadge: true,  // 角标
  );
  
  await FlutterLocalNotificationsPlugin()
      .resolvePlatformSpecificImplementation<
          AndroidFlutterLocalNotificationsPlugin>()
      ?.createNotificationChannel(channel);
}

级别说明

  • Importance.max:发出声音并作为抬头通知显示
  • Importance.high:发出声音
  • Importance.default:没有声音
  • Importance.low:没有声音,且不会在状态栏显示

2.2 通知样式

大图通知

Future<void> showBigPictureNotification() async {
  // 先将图片下载到本地
  final String imagePath = await _downloadImageToCache(url);
  
  final bigPictureStyle = BigPictureStyleInformation(
    FilePathAndroidBitmap(imagePath),  
    
    // 延迟加载
    hideExpandedLargeIcon: false,
    contentTitle: '<b>标题</b>',
    htmlFormatContentTitle: true,
  );
  
  // 显示通知
  await notificationsPlugin.show(
    id,
    title,
    body,
    NotificationDetails(android: AndroidNotificationDetails(
      'channel_id',
      'channel_name',
      styleInformation: bigPictureStyle,  
    )),
  );
}

优化

  1. 图片缓存:下载的图片应该缓存,避免重复下载
  2. 图片压缩:大图需要压缩,建议不超过1MB
  3. 懒加载:大图在通知展开时才加载

2.3 实时更新

原理:通过不断更新同一个通知ID来实现进度显示。

class ProgressNotification {
  static const int notificationId = 1000;  // 固定ID
  
  Future<void> updateProgress(int progress, int total) async {
    final percent = (progress / total * 100).round();
    
    await notificationsPlugin.show(
      notificationId, 
      '下载中',
      '$percent%',
      NotificationDetails(
        android: AndroidNotificationDetails(
          'progress_channel',
          '进度通知',
          showProgress: true,
          maxProgress: total,
          progress: progress,
          onlyAlertOnce: true,  // 只提醒一次
        ),
      ),
    );
  }
}

注意

  1. 使用onlyAlertOnce: true避免每次更新都弹出通知
  2. 进度完成后应该取消或更新为完成状态的通知
  3. 考虑网络中断的恢复机制

三、消息路由

3.1 深度链接

路由设计

graph LR
    A[点击通知] --> B{判断链接类型}
    B -->|应用内链接| C[路由解析器]
    B -->|HTTP/HTTPS链接| D[WebView打开]
    B -->|其他应用链接| E[系统处理]
    
    C --> F{匹配路径}
    F -->|匹配成功| G[跳转对应页面]
    F -->|匹配失败| H[跳转首页]
    
    G --> I[传递参数]
    H --> J[显示错误]

核心代码

class DeepLinkRouter {
  // 配置路由表
  static final Map<RegExp, String Function(Match)> routes = {
    RegExp(r'^/product/(\d+)$'): (match) => '/product?id=${match[1]}',
    RegExp(r'^/order/(\d+)$'): (match) => '/order?id=${match[1]}',
    RegExp(r'^/chat/(\w+)$'): (match) => '/chat?userId=${match[1]}',
  };
  
  // 路由解析
  static RouteInfo? parse(String url) {
    final uri = Uri.parse(url);
    
    // 提取路径
    final path = uri.path;
    
    // 匹配路由
    for (final entry in routes.entries) {
      final match = entry.key.firstMatch(path);
      if (match != null) {
        final route = entry.value(match);
        final queryParams = Map<String, String>.from(uri.queryParameters);
        
        return RouteInfo(
          route: route,
          params: queryParams,
        );
      }
    }
    
    return null;
  }
}

注意

  1. URL Scheme需要在Info.plist(iOS)和AndroidManifest.xml(Android)中声明
  2. 冷启动处理:应用被终止时,需要保存启动参数
  3. 参数验证:需要对传入参数进行安全性检查

3.2 状态恢复的两种策略

策略1:URL参数传递

// 实现相对简单,但参数长度有限,不适合复杂状态
void navigateWithParams(String route, Map<String, dynamic> params) {
  final encodedParams = Uri.encodeComponent(json.encode(params));
  Navigator.pushNamed(context, '$route?data=$encodedParams');
}

策略2:状态管理+ID传递

// 适合复杂状态,但需要状态管理框架
class StateRecoveryManager {
  // 保存状态
  Future<String> saveState(Map<String, dynamic> state) async {
    final id = Uuid().v4();
    await _storage.write(key: 'state_$id', value: json.encode(state));
    return id;  
  }
  
  // 恢复状态
  Future<Map<String, dynamic>?> restoreState(String id) async {
    final data = await _storage.read(key: 'state_$id');
    if (data != null) {
      return json.decode(data) as Map<String, dynamic>;
    }
    return null;
  }
}

注意

  1. 简单参数用URL传递
  2. 复杂状态用ID传递,配合状态管理
  3. 状态应该有有效期,定期清理过期状态

四、性能优化

4.1 网络请求

批量发送Token更新:减少频繁的HTTP请求,特别是应用启动时可能多个组件都要同步Token。

class TokenSyncManager {
  final List<String> _pendingTokens = [];
  Timer? _syncTimer;
  
  // 延迟批量同步
  void scheduleTokenSync(String token) {
    _pendingTokens.add(token);
    
    // 延迟500ms,批量发送
    _syncTimer?.cancel();
    _syncTimer = Timer(const Duration(milliseconds: 500), () {
      _syncTokensToServer();
    });
  }
  
  Future<void> _syncTokensToServer() async {
    if (_pendingTokens.isEmpty) return;
    
    final uniqueTokens = _pendingTokens.toSet().toList();
    _pendingTokens.clear();
    
    try {
      await _api.batchUpdateTokens(uniqueTokens);
    } catch (e) {
      // 失败重试
      _pendingTokens.addAll(uniqueTokens);
    }
  }
}

4.2 内存优化

class LightweightNotification {
  final String id;
  final String title;
  final String? body;
  final DateTime timestamp;
  final bool read;
  final Map<String, dynamic>? data;  // 延迟加载
  
  // 工厂方法
  factory LightweightNotification.fromJson(Map<String, dynamic> json) {
    return LightweightNotification(
      id: json['id'],
      title: json['title'],
      body: json['body'],
      timestamp: DateTime.parse(json['timestamp']),
      read: json['read'] ?? false,
      data: json['data'],  
    );
  }
}

优化

  1. 列表显示时只加载必要字段
  2. 大字段(如图片、详细数据)延迟加载
  3. 定期清理内存中的通知缓存

4.3 电池优化

减少不必要的通知

class SmartNotificationScheduler {
  // 根据用户活跃时间调整通知频率
  Future<bool> shouldSendNotification(NotificationType type) async {
    final now = DateTime.now();
    
    // 1. 检查免打扰时间
    if (await _isQuietTime(now)) {
      return false;
    }
    
    // 2. 检查上一次活跃时间
    final lastActive = await _getLastActiveTime();
    if (now.difference(lastActive) > Duration(hours: 24)) {
      // 用户24小时未活跃,避免过多推送
      return type == NotificationType.important;
    }
    
    // 3. 检查同类型通知频率
    final recentCount = await _getRecentNotificationCount(type);
    if (recentCount > _getRateLimit(type)) {
      return false;
    }
    
    return true;
  }
}

五、调试

开发环境

class NotificationDebugger {
  static bool _isDebugMode = false;
  
  static void log(String message, {dynamic data}) {
    if (_isDebugMode) {
      print('[通知调试] $message');
      if (data != null) {
        print('数据: $data');
      }
    }
  }
  
  static Future<void> testAllScenarios() async {
    log('开始推送测试...');
    
    // 测试1: 前台通知
    await _testForeground();
    
    // 测试2: 后台通知
    await _testBackground();
    
    // 测试3: 数据消息
    await _testDataMessage();
    
    // 测试4: 点击处理
    await _testClickHandling();
    
    log('测试完成');
  }
  
  // 服务器推送
  static Future<void> simulatePush({
    required String type,
    required Map<String, dynamic> data,
  }) async {
    final message = RemoteMessage(
      data: data,
      notification: RemoteNotification(
        title: '测试通知',
        body: '这是一个测试通知',
      ),
    );
    
    // 直接触发消息处理器
    FirebaseMessaging.onMessage.add(message);
  }
}

六、平台特定优化

6.1 Android端

后台限制的应对方法

class AndroidOptimizer {
  // Android 10+的后台限制
  static Future<void> optimizeForBackgroundRestrictions() async {
    if (Platform.isAndroid) {
      // 1. 使用前台服务显示重要通知
      if (await _isAppInBackground()) {
        await _startForegroundServiceForImportantNotification();
      }
      
      // 2. 适配电源优化
      final status = await _checkBatteryOptimizationStatus();
      if (status == BatteryOptimizationStatus.optimized) {
        await _requestIgnoreBatteryOptimizations();
      }
    }
  }
  
  // 适配不同的Android版本
  static Future<void> adaptToAndroidVersion() async {
    final version = await DeviceInfoPlugin().androidInfo;
    final sdkVersion = version.version.sdkInt;
    
    if (sdkVersion >= 31) {  // Android 12+
      // 需要精确的闹钟权限
      await _requestExactAlarmPermission();
    }
    
    if (sdkVersion >= 33) {  // Android 13+
      // 需要新的通知权限
      await _requestPostNotificationsPermission();
    }
  }
}

6.2 iOS端

iOS推送的特殊处理

class IOSOptimizer {
  // APNs环境设置
  static Future<void> configureAPNsEnvironment() async {
    if (Platform.isIOS) {
      // 设置推送环境
      await FirebaseMessaging.instance.setForegroundNotificationPresentationOptions(
        alert: true,   // 显示弹窗
        badge: true,   // 更新角标
        sound: true,   // 播放声音
      );
      
      // 获取APNs Token
      final apnsToken = await FirebaseMessaging.instance.getAPNSToken();
      if (apnsToken != null) {
        print('APNs Token: $apnsToken');
      }
    }
  }
  
  // 处理静默推送
  static Future<void> handleSilentPush(RemoteMessage message) async {
    if (Platform.isIOS && message.data['content-available'] == '1') {
      // 静默推送,需要后台处理
      await _processSilentNotification(message.data);
      final deadline = DateTime.now().add(Duration(seconds: 25));
      // ... 
    }
  }
}

七、总结

至此,Flutter消息推送知识就讲完了,记住以下核心原则:

  • 用户体验永远是第一位,让用户控制通知,提供清晰的设置
  • 消息必须可靠到达,状态必须正确恢复
  • 注意优化电池、流量、内存使用
  • 尊重Android和iOS两端的平台特性

不要简单地把推送通知当成一个功能,要保证每一次推送都应该有价值。


如果觉得文章对你有帮助,别忘了三连支持一下,欢迎评论区留言,我会详细解答! 保持技术热情,持续深度思考!

Swift 6.2 列传(第十二篇):杨不悔的“临终”不悔与 Isolated Deinit

在这里插入图片描述

摘要:当对象的生命走到尽头,是曝尸荒野还是落叶归根?Swift 6.2 引入的 isolated deinit 就像是一道“归元令”,让 Actor 隔离的类在销毁时也能体面地访问隔离状态。本文通过大熊猫侯佩与杨不悔的奇遇,为您揭秘 SE-0371 的奥义。

0️⃣ 🐼 序章:光明顶的内存泄漏

光明顶,Server 机房。

这里是中土明教的代码总坛,无数条线程如蜿蜒的巨龙般穿梭在服务器之间。

大熊猫侯佩正对着一块发烫的屏幕发呆。他摸了摸自己圆滚滚的肚皮,又习惯性地用爪子去探了探头顶——那里的黑毛依然茂密,绝对没有秃。他松了一口气,但这口气还没叹完,屏幕上那红色的 Compiler Error 就像明教的圣火令一样刺眼。

“奇怪,明明是在销毁对象,为什么就像是还没断气就诈尸了一样?”侯佩嘟囔着,嘴里还叼着半截没吃完的量子竹笋。

“因为你不仅是个路痴,还是个法盲。”

一个清脆的声音从机架后方传来。一位绿衣少女缓步走出,她眉目如画,眼神中却透着一股倔强与决绝。她是杨逍与纪晓芙之女,杨不悔

在这里插入图片描述

“不悔妹子!”侯佩眼睛一亮(主要是看到了不悔手里提着的食盒),“你怎么在这?听说你在维护‘倚天屠龙’分布式系统?”

在本次大冒险中,您将学到如下内容:

  • 0️⃣ 🐼 序章:光明顶的内存泄漏
  • 1️⃣ 💔 销魂时刻的尴尬:为何 Deinit 总是“身不由己”?
  • 2️⃣ 🛡️ 绝处逢生:Isolated Deinit 的“归元令”
  • 3️⃣ 🔌 实战演练:脆弱的 Session 与非 Sendable 的状态
  • 4️⃣ 🐼 熊猫的哲学思考与黑色幽默
  • 5️⃣ 🛑 尾声:突如其来的警报

杨不悔冷哼一声,将食盒放在服务器机柜上:“我娘给我取名‘不悔’,便是要我行事无愧于心。可现在的 Swift 代码,对象死的时候(deinit)乱七八糟,连最后一点体面都没有,还怎么谈‘不悔’?我正为此事烦恼。”

侯佩凑过去一看,原来是一个 Actor 隔离的类在 deinit 里试图访问属性时崩溃了。


1️⃣ 💔 销魂时刻的尴尬:为何 Deinit 总是“身不由己”?

在 Swift 的江湖里,对象的诞生(init)通常都有明确的归属,但对象的死亡(deinit)却往往充满了不确定性。

杨不悔指着屏幕上的代码说道:“你看,这是一个被 @MainActor 保护的类。按理说,它的属性都应该在主线程安全访问。但是,当它的引用计数归零时,也就是它该死的时候,系统并不保证 deinit 会在主线程执行。”

侯佩恍然大悟:“你是说,这就像一个人明明是中原人士,死的时候却可能莫名其妙被扔到了西域荒漠,连句遗言都传不回来?”

在这里插入图片描述

“话糙理不糙。”杨不悔叹了口气,“如果没有 Swift 6.2 的新特性,我们在 deinit 里根本无法安全地访问那些被 Actor 隔离的数据。这叫‘死不瞑目’。”

这就引出了 SE-0371:Isolated synchronous deinit

2️⃣ 🛡️ 绝处逢生:Isolated Deinit 的“归元令”

在这里插入图片描述

Swift 6.2 引入了一个关键能力:允许我们将 Actor 隔离类的析构函数(deinitializer)标记为 isolated

这意味着什么?这意味着当对象销毁时,系统会像拥有“乾坤大挪移”一般,确保代码跳转到该 Actor 的执行器(Executor)上运行。

杨不悔敲击键盘,写下了一段范例:

@MainActor
class DataController {
    func cleanUp() {
        // 这里的逻辑需要在大威天龙...哦不,是 MainActor 上执行
        print("正在清理门户...")
    }

    // 注意这个 isolated 关键字,这就是杨不悔的“不悔”令牌
    isolated deinit {
        cleanUp()
    }
}

侯佩瞪大了眼睛:“你是说,加上 isolated,这遗言就能准时传达了?”

在这里插入图片描述

“没错。”杨不悔解释道,“如果没有 isolated 关键字,析构器就不会隔离到 @MainActor。全局 Actor 的工作机制决定了这一点。但一旦加上它,你的代码在运行前就会自动切换到 Actor 的执行器。这才是真正的落叶归根,安全无痛。

3️⃣ 🔌 实战演练:脆弱的 Session 与非 Sendable 的状态

在这里插入图片描述

“光说不练假把式。”侯佩虽然爱吃,但对技术还是很较真的(尤其是涉及到能不能早点下班吃竹笋的问题),“有没有更具体的场景?比如...我也能听懂的?”

杨不悔微微一笑,想起了当年母亲纪晓芙的教诲,那种对誓言的执着。

“假设我们有一个 User 类,它就像武当派的张翠山,虽然正直,但并不是线程安全的(非 Sendable)。我们还有一个管理会话的 Session 类。”

// 一个普通的、非 Sendable 的用户类
// 就像是一个不懂武功的凡人,经不起多线程的撕扯
class User {
    var isLoggedIn = false
}

@MainActor
class Session {
    let user: User

    init(user: User) {
        self.user = user
        // 登录时,我们在 MainActor 上把状态改为 true
        user.isLoggedIn = true
    }

    // 重点来了:必须加上 isolated
    isolated deinit {
        // 销毁会话时,我们要把用户登出。
        // 如果没有 isolated,编译器会认为你在非隔离环境下访问了
        // 属于 MainActor 的 user 属性,直接给你报个错!
        user.isLoggedIn = false
    }
}

侯佩若有所思地点点头:“这下我明白了。Session 是在 @MainActor 上的,它持有的 user 状态也归它管。如果 deinit 随便在哪个后台线程跑,去修改 user.isLoggedIn 就会导致数据竞争,也就是走火入魔!”

在这里插入图片描述

“正是。”杨不悔目光坚定,“加上 isolated,就是告诉编译器:‘即使我要死了,我也要在我该在的地方,干干净净地把事情做完。’ 这便是我杨不悔的道。”

4️⃣ 🐼 熊猫的哲学思考与黑色幽默

侯佩看着屏幕上编译通过的绿色对勾,心中不禁生出一丝感慨。

“其实,写代码和做熊一样。”侯佩抓起一根竹笋,咔嚓咬了一口,“生(Init)的时候要风风光光,死(Deinit)的时候也要体体面面。以前我们为了在 deinit 里做点清理工作,还得用 Task 搞异步,结果对象都销毁了,任务还在那飘着,像孤魂野鬼。”

杨不悔白了他一眼:“你那是代码写得烂。现在的 isolated deinit同步的,它保证了逻辑的原子性和顺序性。”

“同步好啊!”侯佩拍着胸脯,“我就喜欢同步,像我吃饭,必须嘴巴动肚子就饱,要是嘴巴动了三天肚子才饱(异步),那我不饿瘦了?虽然我看起来很壮,但我这是虚胖,而且头绝对不秃,经不起折腾。”

在这里插入图片描述

杨不悔看着这只明明胖得像球、却还在担心秃顶的大熊猫,忍不住噗嗤一笑。这笑声如冰雪消融,让机房里冰冷的服务器都似乎有了温度。

5️⃣ 🛑 尾声:突如其来的警报

就在两人以为 Bug 已除,准备去吃一顿正宗的“明教火锅”时,机房深处突然传来了一阵急促的蜂鸣声!

在这里插入图片描述

🚨 WEE-WOO-WEE-WOO 🚨

“不好!”杨不悔脸色一变,“是‘乾坤大挪移’心法模块的后台任务卡住了!”

侯佩吓得竹笋都掉了:“怎么回事?不是已经 Isolated 了吗?”

杨不悔飞快地敲击着键盘,屏幕上的日志疯狂滚动:“不是线程安全问题!是有个非常重要的清理任务,被系统判定为‘低优先级’,一直被其他杂鱼任务插队,导致卡死在队列里出不来!”

在这里插入图片描述

“那怎么办?”侯佩急得团团转,“有没有办法给它打一针兴奋剂?或者给它一块免死金牌?让它插队先走?”

杨不悔眼神凝重,手指悬在回车键上:“这就需要用到一种禁术了……能够手动提升任务优先级的禁术。”

侯佩咽了口唾沫:“你是说……”

在这里插入图片描述

杨不悔转过头,看着侯佩,嘴角露出一丝神秘的微笑:“准备好了吗?下一章,我们要逆天改命。”

(欲知后事如何,且看下回分解:Task Priority Escalation APIs —— 当熊猫学会了插队)

在这里插入图片描述

2025 年终总结

提笔写下这篇总结的时候,窗外的银杏叶已经落得差不多了,天气也倏忽之间就冷了下来。东京的秋天走得安静,只留下满地金黄,但冬天却来得急躁,提醒人们时间确实在向前流动。 按照往年的惯例,我大概会用“懒癌发作”或者“没什么进取心”作为开场白:一方面习惯性自嘲,另一方面也给自己留点退路,装作好像只要态度足够散淡,变化就追不上来的样子。但回望 2025 年,我发现这些词突然变得不太合适了。并不是我变得勤奋了,而是这个世界的变化速度,已经快到了即使你选择“躺平”,也能清楚地感觉到身下的地板在带着你呼呼向前移动。 如果说前几年我们还在讨论 AI 能不能做事、会不会做事,那么到了 2025 年,这个问题几乎已经失去了继续讨论的意义。我们不再站在岸边观察潮水,而是已经身处浪潮之中。无论情愿与否,这一年,几乎没有人还能置身事外,所有人都成为了这场巨变的直接参与者。 今年的总结,我想试着从几个贯穿全年的主题出发,聊聊在这个智能骤然丰裕的时代拐点上,我的一些观察、困惑、犹豫,以及尚未成型的浅薄思考。 智能膨胀与元学习 两个娃一天天长大,也都进入了小学,姐姐更是为了准备初中考试开始了补习班生涯。她...

Weak AVL Tree

tl;dr: Weak AVL trees are replacements for AVL trees and red-blacktrees.

The 2014 paper Rank-BalancedTrees (Haeupler, Sen, Tarjan) presents a framework using ranksand rank differences to define binary search trees.

  • Each node has a non-negative integer rank r(x). Nullnodes have rank -1.
  • The rank difference of a node x with parentp(x) is r(p(x)) − r(x).
  • A node is i,j if its children have rank differencesi and j (unordered), e.g., a 1,2 node haschildren with rank differences 1 and 2.
  • A node is called 1-node if its rank difference is 1.

Several balanced trees fit this framework:

  • AVL tree: Ranks are defined as heights. Every node is 1,1 or 1,2(rank differences of children)
  • Red-Black tree: All rank differences are 0 or 1, and no parent of a0-child is a 0-child. (red: 0-child; black: 1-child; null nodes areblack)
  • Weak AVL tree (new tree described by this paper): All rankdifferences are 1 or 2, and every leaf has rank 0.
    • A weak AVL tree without 2,2 nodes is an AVL tree.
1
AVL trees ⫋ weak AVL trees ⫋ red-black trees

Weak AVL Tree

Weak AVL trees are replacements for AVL trees and red-black trees. Asingle insertion or deletion operation requires at most two rotations(forming a double rotation when two are needed). In contrast, AVLdeletion requires O(log n) rotations, and red-black deletion requires upto three.

Without deletions, a weak AVL tree is exactly an AVL tree. Withdeletions, its height remains at most that of an AVL tree with the samenumber of insertions but no deletions.

The rank rules imply:

  • Null nodes have rank -1, leaves have rank 0, unary nodes have rank1.

Insertion

The new node x has a rank of 0, changed from the nullnode of rank -1. There are three cases.

  • If the tree was previously empty, the new node becomes theroot.
  • If the parent of the new node was previously a unary node (1,2node), it is now a 1,1 binary node.
  • If the parent of the new node was previously a leaf (1,1 node), itis now a 0,1 binary node, leading to a rank violation.

When the tree was previously non-empty, x has a parentnode. We call the following subroutine with x indicatingthe new node to handle the second and third cases.

The following subroutine handles the rank increase of x.We call break if there is no more rank violation, i.e. weare done.

The 2014 paper isn't very clear about the conditions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Assume that x's rank has just increased by 1 and rank_diff(x) has been updated.

p = x->parent;
if (rank_diff(x) == 1) {
// x was previously a 2-child before increasing x->rank.
// Done.
} else {
for (;;) {
// Otherwise, p is a 0,1 node (previously a 1,1 node before increasing x->rank).
// x being a 0-child is a violation.

Promote p.
// Since we have promoted both x and p, it's as if rank_diff(x's sibling) is flipped.
// p is now a 1,2 node.

x = p;
p = p->parent;
// x is a 1,2 node.
if (!p) break;
d = p->ch[1] == x;

if (rank_diff(x) == 1) { break; }
// Otherwise, x is a 0-child, leading to a new rank violation.

auto sib = p->ch[!d];
if (rank_diff(sib) == 2) { // p is a 0,2 node
auto y = x->ch[d^1];
if (y && rank_diff(y) == 1) {
// y is a 1-child. y must the previous `x` in the last iteration.
Perform a double rotation involving `p`, `x`, and `y`.
} else {
// Otherwise, y is null or a 2-child.
Perform a single rotation involving `p` and `x`.
x is now a 1,1 node and there is no more violation.
}
break;
}

// Otherwise, p is a 0,1 node. Goto the next iteration.
}
}

Insertion never introduces a 2,2 node, so insertion-only sequencesproduce AVL trees.

Deletion

TODO: Describe deletion

Implementation

In 2020, FreeBSD has changed its sys/tree.h to use weakAVL trees instead of red-black trees. https://reviews.freebsd.org/D25480 The rb_prefix remains as it can also indicate Rank-Balanced:)

Here is a C++ implementation with the following operations

  • insert: insert a node
  • remove: remove a node
  • rank: count elements less than a key
  • select: find the k-th smallest element (0-indexed)
  • prev: find the largest element less than a key
  • next: find the smallest element greater than a key

The insertion and deletion operations are inspired by the FreeBSDimplementation, with the insertion further optimized.

We encode rank differences instead of the absolute rank values. Sincevalid rank differences can only be 1 or 2, a single bit suffices toencode each. We take 2 bits from the parent pointer tostore the two child rank differences.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
#include <algorithm>
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <numeric>
#include <random>
#include <vector>
using namespace std;

struct Node {
Node *ch[2]{};
uintptr_t par_and_flg{};
int i{}, sum{}, size{};

Node *parent() const { return reinterpret_cast<Node*>(par_and_flg & ~3UL); }
void set_parent(Node *p) { par_and_flg = (par_and_flg & 3) | reinterpret_cast<uintptr_t>(p); }
uintptr_t flags() const { return par_and_flg & 3; }
bool rd2(int d) const { return par_and_flg & (1 << d); }
void flip(int d) { par_and_flg ^= (1 << d); }
void clr_flags() { par_and_flg &= ~3UL; }

void mconcat() {
sum = i;
size = 1;
if (ch[0]) sum += ch[0]->sum, size += ch[0]->size;
if (ch[1]) sum += ch[1]->sum, size += ch[1]->size;
}

bool operator<(const Node &o) const { return i < o.i; }
};

struct WAVL {
Node *root{};

~WAVL() {
auto destroy = [](auto &self, Node *n) -> void {
if (!n) return;
self(self, n->ch[0]);
self(self, n->ch[1]);
delete n;
};
destroy(destroy, root);
}

Node *rotate(Node *x, int d) {
auto pivot = x->ch[d];
if ((x->ch[d] = pivot->ch[d^1])) x->ch[d]->set_parent(x);
pivot->set_parent(x->parent());
if (!x->parent()) root = pivot;
else x->parent()->ch[x != x->parent()->ch[0]] = pivot;
pivot->ch[d^1] = x;
x->set_parent(pivot);
x->mconcat();
return pivot;
}

void insert(Node *x) {
Node *p = nullptr;
int d = 0;
for (auto tmp = root; tmp; ) {
p = tmp;
d = *p < *x;
tmp = tmp->ch[d];
}
x->par_and_flg = reinterpret_cast<uintptr_t>(p);
x->ch[0] = x->ch[1] = nullptr;
if (!p) return root = x, x->mconcat();
p->ch[d] = x;
auto *x2 = x;
if (p->rd2(d)) {
p->flip(d);
} else {
assert(p->rd2(d^1) == 0);
p->flip(d^1);
int d1 = d;
for (x = p, p = x->parent(); p; x = p, p = x->parent()) {
d = (p->ch[1] == x);
if (p->rd2(d)) {
p->flip(d);
break;
}
p->flip(d^1);
if (!p->rd2(d ^ 1)) {
if ((d^1) == d1) {
assert(!x->rd2(d1) && (x->ch[d1] == x2 || x->ch[d1]->flags() == 1 || x->ch[d1]->flags() == 2));
x->flip(d);
auto y = rotate(x, d^1); // y is previous x
if (y->rd2(d))
x->flip(d^1);
else if (y->rd2(d^1))
p->flip(d);
x = y;
}
x = rotate(p, d);
x->clr_flags();
break;
}
d1 = d;
}
}
for (; x2; x2 = x2->parent()) x2->mconcat();
}

void remove(Node *x) {
auto old = x;
auto p = x->parent();
auto right = x->ch[1];
Node *child;
if (!x->ch[0]) x = child = right;
else if (!right) x = child = x->ch[0];
else {
if (!(child = right->ch[0])) {
child = right->ch[1];
p = x = right;
} else {
do x = child; while ((child = x->ch[0]));
child = x->ch[1];
p = x->parent();
p->ch[0] = child;
old->ch[1]->set_parent(x);
x->ch[1] = old->ch[1];
}
old->ch[0]->set_parent(x);
x->ch[0] = old->ch[0];
x->par_and_flg = old->par_and_flg;
}
if (!old->parent()) root = x;
else old->parent()->ch[old != old->parent()->ch[0]] = x;
if (child) child->set_parent(p);

Node *x2 = p;
if (p) {
x = child;
if (p->ch[0] == x && p->ch[1] == x) {
p->clr_flags();
x = p;
p = x->parent();
}
while (p) {
int d2 = (p->ch[1] == x);
if (!p->rd2(d2)) {
p->flip(d2);
break;
}
if (p->rd2(d2 ^ 1)) {
p->flip(d2 ^ 1);
x = p;
p = x->parent();
continue;
}
auto sib = p->ch[d2^1];
if (sib->flags() == 3) {
sib->clr_flags();
x = p;
p = x->parent();
continue;
}
sib->flip(d2^1);
if (sib->rd2(d2))
p->flip(d2);
else if (!sib->rd2(d2^1)) {
p->flip(d2);
x = rotate(sib, d2);
if (x->rd2(d2^1)) sib->flip(d2);
if (x->rd2(d2)) p->flip(d2^1);
x->par_and_flg |= 3;
}
rotate(p, d2^1);
break;
}
}
for (; x2; x2 = x2->parent()) x2->mconcat();
}

Node *find(int key) const {
auto tmp = root;
while (tmp) {
if (key < tmp->i) tmp = tmp->ch[0];
else if (key > tmp->i) tmp = tmp->ch[1];
else return tmp;
}
return nullptr;
}

Node *min() const {
Node *p = nullptr;
for (auto n = root; n; n = n->ch[0]) p = n;
return p;
}

int rank(int key) const {
int r = 0;
for (auto n = root; n; ) {
if (key <= n->i) n = n->ch[0];
else {
r += 1 + (n->ch[0] ? n->ch[0]->size : 0);
n = n->ch[1];
}
}
return r;
}

int select(int k) const {
auto x = root;
while (x) {
int lsz = x->ch[0] ? x->ch[0]->size : 0;
if (k < lsz) x = x->ch[0];
else if (k == lsz) return x->i;
else k -= lsz + 1, x = x->ch[1];
}
return -1;
}

int prev(int key) const {
int res = -1;
for (auto x = root; x; )
if (key <= x->i) x = x->ch[0];
else { res = x->i; x = x->ch[1]; }
return res;
}

int next(int key) const {
int res = -1;
for (auto x = root; x; )
if (key >= x->i) x = x->ch[1];
else { res = x->i; x = x->ch[0]; }
return res;
}

static Node *next(Node *x) {
if (x->ch[1]) {
x = x->ch[1];
while (x->ch[0]) x = x->ch[0];
} else {
while (x->parent() && x == x->parent()->ch[1]) x = x->parent();
x = x->parent();
}
return x;
}
};

void print_tree(Node *n, int d = 0) {
if (!n) { printf("%*snil\n", 2*d, ""); return; }
print_tree(n->ch[0], d + 1);
printf("%*s%d (%d,%d)\n", 2*d, "", n->i, n->rd2(0) ? 2 : 1, n->rd2(1) ? 2 : 1);
print_tree(n->ch[1], d + 1);
}

int compute_rank(Node *n, bool debug = false) {
if (!n) return -1;
int lr = compute_rank(n->ch[0], debug), rr = compute_rank(n->ch[1], debug);
if (lr < -1 || rr < -1) return -2;
int rank_l = lr + (n->rd2(0) ? 2 : 1);
int rank_r = rr + (n->rd2(1) ? 2 : 1);
if (rank_l != rank_r) {
if (debug) printf("node %d: rank mismatch left=%d right=%d\n", n->i, rank_l, rank_r);
return -2;
}
if (!n->ch[0] && !n->ch[1] && n->flags() != 0) {
if (debug) printf("node %d: leaf must be 1,1 but flags=%lu\n", n->i, n->flags());
return -2;
}
int expected_sum = n->i + (n->ch[0] ? n->ch[0]->sum : 0) + (n->ch[1] ? n->ch[1]->sum : 0);
if (n->sum != expected_sum) {
if (debug) printf("node %d: sum mismatch got=%d expected=%d\n", n->i, n->sum, expected_sum);
return -2;
}
int expected_size = 1 + (n->ch[0] ? n->ch[0]->size : 0) + (n->ch[1] ? n->ch[1]->size : 0);
if (n->size != expected_size) {
if (debug) printf("node %d: size mismatch got=%d expected=%d\n", n->i, n->size, expected_size);
return -2;
}
return rank_l;
}

bool verify_tree(const WAVL &tree, bool verbose = false) {
int rank = compute_rank(tree.root);
if (rank < -1) {
printf("INVALID TREE\n");
compute_rank(tree.root, true);
return false;
}
if (verbose) printf("Tree verified, rank = %d\n", rank);
return true;
}

int main() {
srand(42);
WAVL tree;
int i = 0;
std::vector<int> a(20);
std::iota(a.begin(), a.end(), 1);
std::shuffle(a.begin(), a.end(), std::default_random_engine(42));
for (int val : a) {
auto n = new Node;
n->i = val;
tree.insert(n);
if (i++ < 6) {
printf("-- %d After insertion of %d\n", i, val);
print_tree(tree.root);
}
}
printf("\nSum\tof values = %d\n", tree.root->sum);
verify_tree(tree, true);

for (int val : {5, 10, 15}) {
if (auto found = tree.find(val)) {
tree.remove(found);
delete found;
}
}
printf("After removing 5, 10, 15:\n");
printf("\nSum\tof values = %d\n", tree.root->sum);
verify_tree(tree, true);

std::vector<Node*> ref;
for (auto n = tree.min(); n; n = WAVL::next(n)) ref.push_back(n);

for (int i = 0; i < 100000; i++) {
if (ref.size() < 5 || (ref.size() < 1000 && rand() % 2 == 0)) {
auto n = new Node;
n->i = rand() % 100000;
tree.insert(n);
ref.push_back(n);
} else {
int idx = rand() % ref.size();
tree.remove(ref[idx]);
delete ref[idx];
ref[idx] = ref.back();
ref.pop_back();
}
if (i%100 == 0 && !verify_tree(tree)) {
printf("FAILED at iteration %d\n", i);
return 1;
}
}

while (!ref.empty()) {
tree.remove(ref.back());
delete ref.back();
ref.pop_back();
if (tree.root && !verify_tree(tree)) {
printf("FAILED during final cleanup\n");
return 1;
}
}
printf("Stress test passed\n");

// Test rank, select, prev, next
printf("\nTesting rank/select/prev/next...\n");
std::vector<int> vals = {10, 20, 30, 40, 50};
for (int v : vals) {
auto n = new Node;
n->i = v;
tree.insert(n);
}

// rank tests (number of elements < key)
assert(tree.rank(5) == 0);
assert(tree.rank(10) == 0);
assert(tree.rank(15) == 1);
assert(tree.rank(20) == 1);
assert(tree.rank(25) == 2);
assert(tree.rank(50) == 4);
assert(tree.rank(55) == 5);

// select tests (0-indexed)
assert(tree.select(0) == 10);
assert(tree.select(1) == 20);
assert(tree.select(2) == 30);
assert(tree.select(3) == 40);
assert(tree.select(4) == 50);
assert(tree.select(5) == -1);

// prev tests (largest < key)
assert(tree.prev(10) == -1);
assert(tree.prev(11) == 10);
assert(tree.prev(20) == 10);
assert(tree.prev(21) == 20);
assert(tree.prev(50) == 40);
assert(tree.prev(55) == 50);

// next tests (smallest > key)
assert(tree.next(5) == 10);
assert(tree.next(10) == 20);
assert(tree.next(15) == 20);
assert(tree.next(40) == 50);
assert(tree.next(50) == -1);
assert(tree.next(55) == -1);

printf("rank/select/prev/next tests passed\n");
}

Node structure:

  • ch[2]: left and right child pointers.
  • par_and_flg: packs the parent pointer with 2 flag bitsin the low bits. Bit 0 indicates whether the left child has rankdifference 2; bit 1 indicates whether the right child has rankdifference 2. A cleared bit means rank difference 1.
  • i: the key value.
  • sum, size: augmented data maintained bymconcat for order statistics operations.

Helper methods:

  • rd2(d): returns true if child d has rankdifference 2.
  • flip(d): toggles the rank difference of childd between 1 and 2.
  • clr_flags(): sets both children to rank difference 1(used after rotations to reset a node to 1,1).

Invariants:

  • Leaves always have flags() == 0, meaning both nullchildren are 1-children (null nodes have rank -1, leaf has rank 0).
  • After each insertion or deletion, mconcat is calledalong the path to the root to update augmented data.

Rotations:

The rotate(x, d) function rotates node x indirection d. It lifts x->ch[d] to replacex, and updates the augmented data for x. Thecaller is responsible for updating rank differences.

The paper suggests that each node can use one bit to encode its ownrank difference. However, this representation is not ideal. A null nodecan be either a 1-child (parent is binary) or a 2-child (parent isunary). Retrieving the child rank difference requires probing thesibling node:

int rank_diff(Node *p, int d) { return p->ch[d] ? p->ch[d]->par_and_flg & 1 : p->ch[!d] ? 2 : 1; }

Misc

Visualization: https://tjkendev.github.io/bst-visualization/avl-tree/bu-weak.html

老司机 iOS 周报 #360 | 2025-12-15

老司机 iOS 周报 #360 | 2025-12-15

ios-weekly
老司机 iOS 周报,只为你呈现有价值的信息。

你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。

文章

🌟 Teaching AI to Read Xcode Builds

@zhangferry:Xcode 原始构建日志对人和 AI 都不够友好,仅提供扁平信息输出。作者通过截获 Xcode 与 SWBBuildService 的通信,挖掘出日志之外的结构化数据,包括构建依赖、详细耗时等核心信息,现在随着 swift-build 的开源,可以更系统的了解这些构建信息,利用它们可以实现这些功能:

  • 精准排错:链接错误源于 NetworkKit 模块构建失败,其依赖的 CoreUtilities 正常,问题仅集中在 NetworkKit 本身
  • 慢构建分析:47 秒构建中仅 12 秒是实际编译,其余时间用于等待代码签名;8 核 CPU 仅达成 3.2 倍并行,XX 模块是主要瓶颈
  • 主动提醒:近一个月构建时间上涨 40%,与 Analytics 模块新增 12 个 Swift 文件相关
  • 自然语言查询:支持 “上次构建最慢的模块是什么?”“上周比这周多多少警告?” 等直接提问

利用这项能力对 Wikipedia iOS 应用完成 “体检”,找到多个编译耗时瓶颈,AI 还结合结果给出了模块拆分、并行处理的优化建议。

未来潜力更值得期待:若 Apple 官方支持实时获取构建消息,AI 可在构建中途发现异常(比如 “某模块比平时慢 2 倍,是否暂停检查?”),还能实时监控 CI 构建进度,甚至自动修复问题。作者基于 swift-build 开发了 Argus,已经实现部分实时功能。

🐕 豆包手机为什么会被其他厂商抵制?它的工作原理是什么?

@EyreFree:豆包手机因采用底层系统权限实现 AI 自动化操作,遭微信、淘宝等厂商抵制。其核心工作原理为:通过 aikernel 进程与 autoaction APK 协同,利用 GPU 缓冲区读取屏幕数据、注入输入事件,借助独立虚拟屏幕后台运行,无需截屏或无障碍服务,还能绕过部分应用反截屏限制。AI 操作主要依赖云端推理,本地每 3-5 秒向字节服务器发送 250K 左右图片,接收 1K 左右操作指令。这种模式虽提升自动化效率,但存在隐私安全隐患,易被灰产利用,且冲击现有移动互联网商业逻辑,相关规范与监管仍需完善。感兴趣的同学可以结合视频 【老戴】豆包手机到底在看你什么?我抓到了它的真实工作流程 一起看看。

🐢 How we built the v0 iOS app

@含笑饮砒霜:Vercel 首款 iOS 应用 v0 的移动端负责人 Fernando Rojo 详细分享了应用的构建过程:团队以角逐苹果设计奖为目标,经多轮试验选定 React Native 和 Expo 技术栈,核心聚焦打造优质聊天体验,通过自定义钩子(如 useFirstMessageAnimation、useMessageBlankSize 等)、依赖 LegendList 等开源库实现消息动画、空白区域处理、键盘适配、漂浮作曲家等功能,解决了动态消息高度、滚动异常、原生交互适配等难题;同时在 Web 与原生应用间共享类型和辅助函数,通过自研 API 层保障跨端一致性,优先采用原生元素并针对 React Native 原生问题提交补丁优化;未来团队计划开源相关研究成果,持续改进 React Native 生态。

🐎 Opening up the Tuist Registry

@Kyle-Ye:Tuist Registry 宣布完全开放,无需认证或创建账户即可使用。作为 Swift 生态首个完全开放的 Package Registry,目前已托管近 10,000 个包和 160,000+ 个版本。使用 Registry 的团队可获得高达 91% 的磁盘空间节省(从 6.6 GB 降至 600 MB),CI 缓存恢复时间从 2 分钟缩短至 20 秒以内。开发者只需运行 tuist registry setupswift package-registry set 命令即可配置,支持标准 Xcode 项目、Tuist 生成项目和 Swift Package。未认证用户每分钟可发起 10,000 次请求,对于大多数项目已足够使用。

🐕 Initializing @Observable classes within the SwiftUI hierarchy

@AidenRao:本文探讨了在 SwiftUI 中正确初始化和管理 @Observable 对象的几种模式。作者通过清晰的代码示例,层层递进地讲解了使用 @State 的必要性、如何通过 .task 修饰符避免不必要的初始化开销,以及如何利用 environment 实现跨场景的状态共享。如果你对 @Observable 的生命周期管理还有疑惑,这篇文章会给你清晰的答案。

🐕 Demystifying the profraw format

@david-clang:本文深入剖析了 LLVM 用于代码覆盖率分析的 .profraw 二进制文件格式及其生成机制。其核心原理分为编译时插桩与运行时记录两步:

  1. 编译时,Clang 的 -fprofile-instr-generate 选项会在程序中插入计数器 (__profc_*) 和元数据 (__profd_*) 到特定的 ELF 节中,并在关键执行点插入 Load/Add/Store 指令进行实时计量。
  2. 运行时,LLVM 运行时库通过 atexit() 钩子,在程序终止时自动将内存中这些节的最终数据序列化到 .profraw 文件中。该文件依次包含 Header、Data 段(元数据)、Counters 段(执行次数)和 Names 段,完整记录了代码执行轨迹。

工具

🐕 CodeEdit

@JonyFang:CodeEdit 是一个面向 macOS 的开源代码编辑器项目,使用 Swift/SwiftUI 开发并由社区维护。README 将其定位为纯 macOS 原生编辑器,并以 TextEdit 与 Xcode 作为两端参照:在保持更简洁的使用体验的同时,按需扩展到更完整的编辑与开发能力,并遵循 Apple Human Interface Guidelines。项目目前处于开发中,官方提示暂不建议用于生产环境,可通过 pre-release 版本试用并在 Issues/Discussions 反馈。README 列出的功能包括语法高亮、代码补全、项目级查找替换、代码片段、内置终端、任务运行、调试、Git 集成、代码评审与扩展等。

代码

🐕 mlx-swift-lm

@Barney:MLX Swift LM 是一个面向 Apple 平台的 Swift 包 , 让开发者能够轻松构建 LLM 和 VLM 应用。通过一行命令即可从 Hugging Face Hub 加载数千个模型 , 支持 LoRA 微调和量化优化。提供 MLXLLM、MLXVLM、MLXLMCommon、MLXEmbedders 四个库 , 涵盖语言模型、视觉模型和嵌入模型的完整实现。开发者只需几行代码就能创建对话系统 , 所有功能通过 Swift Package Manager 便捷集成。

内推

重新开始更新「iOS 靠谱内推专题」,整理了最近明确在招人的岗位,供大家参考

具体信息请移步:https://www.yuque.com/iosalliance/article/bhutav 进行查看(如有招聘需求请联系 iTDriverr)

关注我们

我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号,欢迎关注。

关注有礼,关注【老司机技术周报】,回复「2024」,领取 2024 及往年内参

同时也支持了 RSS 订阅:https://github.com/SwiftOldDriver/iOS-Weekly/releases.atom

说明

🚧 表示需某工具,🌟 表示编辑推荐

预计阅读时间:🐎 很快就能读完(1 - 10 mins);🐕 中等 (10 - 20 mins);🐢 慢(20+ mins)

Swift 6.2 列传(第十一篇):梅若华的执念与“浪子回头”的异步函数

在这里插入图片描述

0. 🐼楔子:量子纠缠与发际线的危机

在这个万物互联、元宇宙崩塌又重建的赛博纪元,大熊猫侯佩正面临着熊生最大的危机——不是由于长期熬夜写代码导致的黑眼圈(反正原本就是黑的),而是他引以为傲的头顶毛发密度。

“我再次声明,这不是秃,这是为了让 CPU 散热更高效而进化的‘高性能空气动力学穹顶’!”侯佩一边对着全息镜子梳理那几根珍贵的绒毛,一边往嘴里塞了一根钛合金风味竹笋

在这里插入图片描述

为了修复一个名为“时空并发竞争”的超级 Bug,侯佩启动了最新的 Neural-Link 6.2 沉浸式代码审查系统。光芒一闪,数据流如同瀑布般冲刷而下,侯佩只觉得天旋地转,再睁眼时,已不在那个充满冷气机嗡嗡声的机房,而是一片阴风怒号的荒野。

在本次大冒险中,您将学到如下内容:

    1. 🐼楔子:量子纠缠与发际线的危机
    1. 🌪️ 荒野遇盲女,九阴白骨爪
    1. 🕸️ 默认在调用者的 Actor 上运行非隔离异步函数
    • 📜 曾经的困惑(Swift 6.2 之前)
    1. 🛠️ 新的规矩:浪子回头(SE-0461)
    1. 🚪 逃生舱:如果你非要让他走 (@concurrent)
    1. 🧬 深度解析:为什么这很重要?
    1. 🏁 结局:风沙散去,墓碑显现

这里没有 WiFi 信号,只有遍地的白骨和漫天的黄沙。

在这里插入图片描述


1. 🌪️ 荒野遇盲女,九阴白骨爪

“哎呀,这导航又把我带到哪了?我就说高德地图在四维空间里不靠谱!”侯佩挠了挠头,路痴属性稳定发挥。

忽然,一阵凄厉的破空声传来。

“贼汉子,哪里跑!”

一道黑影如鬼魅般袭来,那是一双惨白如玉的手爪,五指如钩,直取侯佩的天灵盖。侯佩大惊失色,虽然他肉厚抗揍,但这九阴白骨爪的阴寒之气要是抓实了,恐怕不仅发际线不保,连头盖骨都要变成标本。

“女侠饶命!我只是个路过的熊猫,身上只有9元竹笋,没有《九阴真经》啊!”侯佩一个懒驴打滚,堪堪避开。

那黑衣女子长发披肩,双目虽盲,但听声辨位之术已臻化境。她正是被逐出桃花岛、漂泊半生的梅超风(本名梅若华)。

在这里插入图片描述

梅超风停下身形,空洞的眼神望向侯佩的方向,神色凄苦:“你这声音……憨傻中透着一股油腻,不像江南七怪,倒像是一头……很胖的熊?”

“是国宝!而且是很帅的国宝!”侯佩整理了一下领结(虽然没穿衣服),“梅姐姐,你这招式虽然凌厉,但好像总是无法在正确的线程上命中目标啊?是不是觉得内力运转时,总是莫名其妙地‘跳’到了别的地方?”

梅超风身躯一震:“你怎么知道?我苦练九阴真经,每当运功至关键时刻(异步调用),真气便会不受控制地散逸到荒野之外(后台线程),无法与我本体(Actor)合二为一。难道……你是师父派来指点我的?”

在这里插入图片描述

侯佩咬了一口竹笋,推了推并不存在的眼镜:“咳咳,算是吧。今天我就借着 SE-0461(Run nonisolated async functions on the caller's actor by default) 号秘籍,来解开你这半生漂泊的心结。”

2. 🕸️ 默认在调用者的 Actor 上运行非隔离异步函数

侯佩盘腿坐在一堆骷髅头上,开始了他的技术讲座。

“梅姐姐,你现在的武功(代码逻辑),就像 Swift 6.2 之前的情况。”

侯佩在沙地上画了一个架构图:

“在 SE-0461 提案之前,一个 nonisolated async 函数(非隔离异步函数),就像是一个生性凉薄的浪子。不管是谁召唤它,哪怕是位高权重的 MainActor(桃花岛主),这浪子一旦开始干活(执行),就会立刻跳槽,跑到通用的后台线程池里去瞎混。”

“这就是为什么你觉得真气(数据)总是游离在你的掌控之外。”

在这里插入图片描述

📜 曾经的困惑(Swift 6.2 之前)

让我们来看看这段令无数英雄竞折腰的代码:

// 一个负责测量数据的结构体,它没有任何 Actor 隔离,是个自由人
struct Measurements {
    // 这是一个 nonisolated async 函数
    // 就像当年的陈玄风,虽然功夫高,但心不在桃花岛
    func fetchLatest() async throws -> [Double] {
        let url = URL(string: "https://hws.dev/readings.json")!
        // 这里发生了异步等待
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([Double].self, from: data)
    }
}

接着,我们有一个桃花岛气象站,它是被 @MainActor 严格管辖的领地:

@MainActor
struct WeatherStation {
    let measurements = Measurements()

    // 这是一个在主线程(桃花岛)运行的方法
    func getAverageTemperature() async throws -> Double {
        // ⚠️ 重点来了:
        // 在 Swift 6.2 之前,虽然这行代码是在 MainActor 里写的
        // 但 fetchLatest() 会立刻跳出 MainActor,跑去后台线程执行
        let readings = try await measurements.fetchLatest()
        
        let average = readings.reduce(0, +) / Double(readings.count)
        return average
    }
}

let station = WeatherStation()
try await print(station.getAverageTemperature())

梅超风听得入神:“你是说,以前哪怕我在桃花岛(MainActor)召唤陈玄风(fetchLatest),他也会立刻跑到大漠(后台线程)去练功?”

在这里插入图片描述

“没错!”侯佩一拍大腿,“这就是 SE-0338 当年定下的规矩——非隔离异步函数‘不在任何 Actor 的执行器上运行’。这导致了无数的数据竞争逻辑混淆,就像你和陈玄风偷了经书私奔,结果把自己练得人不人鬼不鬼。”

3. 🛠️ 新的规矩:浪子回头(SE-0461)

“但是!”侯佩话锋一转,眼中闪烁着智慧的光芒(也有可能是饿出来的绿光),“Swift 6.2 带来了 SE-0461,一切都变了。”

新的规则是:非隔离异步函数现在默认在“调用者的 Actor”上运行。

“这意味着什么?”侯佩指着天空,“意味着如果你身在桃花岛(MainActor),你召唤的招式(fetchLatest)就会老老实实地呆在桃花岛(MainActor)执行,不再四处乱跑了!”

在 Swift 6.2 及以后:

  • getAverageTemperature@MainActor
  • 它调用了 measurements.fetchLatest()
  • fetchLatest 是非隔离的。
  • 结果: fetchLatest 会自动继承调用者的上下文,直接在 @MainActor 上运行

梅超风空洞的眼中似乎流下了一行清泪:“若当年有此规则,我和师兄便不会离岛,也不会落得如此下场……”

在这里插入图片描述

“是啊,”侯佩感叹道,“这叫上下文亲和性(Context Affinity)。这不仅减少了线程切换的开销(就像省去了跑路的盘缠),还让代码逻辑更符合直觉——你在哪调用的,它就在哪跑。”

4. 🚪 逃生舱:如果你非要让他走 (@concurrent)

梅超风忽然神色一冷:“但若是我真的想让他走呢?若是我为了练就绝世武功,必须让他去极寒之地(后台线程)吸取地气呢?”

“问得好!”侯佩竖起大拇指(如果熊猫有的话),“如果你怀念旧的行为,或者为了性能考虑(比如不想阻塞主线程),想明确地把这个函数‘逐出师门’,你可以使用新的关键字:@concurrent。”

struct Measurements {
    // 加上 @concurrent,就是给了他一封休书
    // 告诉编译器:这个函数必须并发执行,不要粘着调用者!
    @concurrent func fetchLatest() async throws -> [Double] {
        // ... 代码同上
    }
}

“加上 @concurrent,就像是你对他喊了一句:‘滚!’。于是,他又变回了那个在后台线程游荡的浪子。”

在这里插入图片描述

5. 🧬 深度解析:为什么这很重要?

侯佩看着梅超风似懂非懂的样子,决定再深入解释一下(以此展示自己深厚的技术功底):

  1. 直觉一致性:以前开发者在 @MainActor 的 View Model 里写个辅助函数,总以为它是安全的,结果它悄悄跑到了后台,访问 UI 属性时直接 Crash。现在,它乖乖听话了。
  2. 性能优化:少了无谓的 Actor 之间的“跳跃”(Hopping),程序的任督二脉打通了,运行更流畅。
  3. Sendable 检查:由于现在函数可能在 Actor 内部运行,编译器在检查数据安全性(Sendable)时的策略也会更智能。

在这里插入图片描述

6. 🏁 结局:风沙散去,墓碑显现

梅超风听完,仰天长啸,啸声中充满了释然。她枯瘦的手掌缓缓放下,一身戾气似乎消散了不少。

“原来如此,原来是我一直执着于‘非隔离’的自由,却忘了‘隔离’才是归宿。”她喃喃自语,身影逐渐变得透明,仿佛要融入这片虚拟的代码荒原。

“喂!梅姐姐,别走啊!我还没问你《九阴真经》里有没有治疗脱发的方子呢!”侯佩伸手去抓,却抓了个空。

场景开始剧烈震动,荒野崩塌,地面裂开。一座巨大的黑色石碑缓缓升起,挡住了侯佩的去路。石碑上刻着一行闪着红光的代码,散发着危险的气息。

在这里插入图片描述

侯佩凑近一看,只见石碑上写着几个大字:Actor-isolated Deinit

与此同时,梅超风消失的地方,传来最后一句话:“在这个世界,生有时,死亦有时。当一个 Actor 走向毁灭(deinit)时,你该如何安全地处理它的遗产?

在这里插入图片描述

侯佩只觉得背后一凉,因为他看到石碑后伸出了一只手……


欲知后事如何,且看下回分解:

🐼 Swift 6.2 列传(第十二篇):杨不悔的“临终”不悔与 Isolated Deinit (Introducing Isolated synchronous deinit - SE-0371)

下集预告: 当一个 Actor 对象被销毁时,如何确保它能安全地访问内部的数据?如果你在 deinit 里写了并发代码,会不会导致程序直接炸裂?SE-0371 将教你如何给 Actor 的临终遗言加上一把安全的锁。侯佩能从这座“析构之墓”中逃脱吗?敬请期待!

🚫求求你别再手动改类名了!Swift 自动混淆脚本上线,4.3 头发保卫战正式开始!

🚫求求你别再手动改类名了!Swift 自动混淆脚本上线,4.3 头发保卫战正式开始!

最近又被苹果爸爸 4.3 拿捏了吗?
是不是已经习惯了以下这些「灵魂折磨」:

  • 为了上架不得不手动画几个类名 —— 一改一个错
  • Storyboard 和 XIB 的 customClass 总有漏网之鱼
  • project.pbxproj 动了下整个工程都红了
  • 文件名、类名、协议名、初始化名……每个地方都要你自己找
  • 改完辛辛苦苦提交,结果苹果一句「与你的其他 App 相似」:4.3,重来!

这时候的你:

“我到底是开发者,还是重构工具?”

停。醒醒。
2025 年了,我们真不需要再用手改代码来对抗机器审核了。

于是我写了一个脚本,专门拯救被 4.3 折磨得头发都要掉光的开发者:
👉 Swift 全自动混淆脚本(已开源)
github.com/chengshixin…

下面,让我带你看看它到底能帮你做些什么。


🎯脚本能解决你什么痛点?

一句话总结:

你无需手动改任何东西,脚本会自动完成全部差异化工作。

真正的「全自动」是这样的:


🚀1. 自动重命名所有 Swift 类 + Swift 文件名

你的文件原本叫:

HomeViewController.swift
class HomeViewController { ... }

混淆后可能变成:

AuroraVertexNimbus_3f91a2b1.swift
class AuroraVertexNimbus_3f91a2b1 { ... }

词汇随机、长度随机、哈希随机。
不是“换个名字”,是 “换个灵魂”


🚀2. 全工程智能替换(Swift + Storyboard + XIB)

脚本会自动扫描整个项目并替换所有引用到的名字,包括:

  • Swift 里的初始化、继承、泛型、类型声明
  • xib / storyboard 的 customClass
  • StoryboardID
  • reuseIdentifier
  • xml 中的各种类名引用
  • project.pbxproj 里的文件名引用

并且这里有一点很关键 ——

脚本是“根据扫描到的 Swift 文件名”进行匹配与替换的。也就是说:文件名是什么,它就会把这个文件名当作需要混淆的类名来全局替换。

所以如果你的项目是“一个文件一个类,文件名和类名一致”,效果会非常完美、非常自动化。

换句话说:

你能用到类名的地方,它都能自动替换干净。
你不需要查找,也不需要担心漏改。


🚀3. 自动注入“无意义”方法和属性(每个类都不一样)

每个类都会被插入一些随机生成的无意义代码,例如:

private let auroraGlow_12fd98ab: Int = 0
private let nimbusEcho_aa2c1f3d: String = ""

private func metaDrift_99ab12cd() -> Bool {
    return false
}

作用有三:

  • 让类结构看起来更复杂
  • 每次混淆生成的二进制差异更大
  • 形成更多“区分度”,减少 4.3 判重概率

这一步手写你可能累死,但脚本 0.1 秒就搞定。


🚀4. 自动切换 Git 新分支,不污染主工程

脚本会自动:

git checkout -b obfuscate_20251212173345

混淆的所有变更都在单独分支内。
如果你不满意?

git checkout main

走人即可,非常安全。


🚀5. 自动生成混淆映射日志(排查神器)

日志示例:

"HomeViewController": "AuroraNimbusFlux_29f3ab1c"

一旦编译报错,你可以迅速根据日志找到原始类名。
不用再猜哪个文件被重命名、哪个类替换错。


🤖为什么这脚本对 4.3 特别有效?

4.3 的核心不是人工判断,而是机器相似度检测
它会分析:

  • 类名结构
  • 文件名
  • 代码特征向量
  • 方法数量、属性数量
  • 编译后符号表
  • 工程结构
  • Storyboard / XIB 元信息

你手动改几个类名,机器根本不看你一眼。
但脚本这种级别的“深度随机化”:

  • 文件名全换
  • 类名全换(随机词 + hash)
  • 类结构不一样
  • 无意义代码注入
  • 符号表完全不同
  • Storyboard / XIB 全量替换
  • 工程配置随机变化

这才是“真正的差异化”。


🛠️怎么用?很简单

  1. 把脚本放在 .xcodeproj 同级目录
  2. 修改脚本里的项目名:
PROJECT_NAME = "YourProjectName"
  1. 需要排除的文件夹可自行设置:
EXCLUDE_FOLDERS = ['Extension', 'Database', ...]
  1. 执行:
python3 obfuscate_swift.py

喝口水,等 5 秒。

你的项目瞬间完成:

  • 类名全混淆
  • 文件名全重命名
  • Storyboard / XIB 全替换
  • 工程配置同步更新
  • Git 分支自动创建
  • 日志自动生成

你只需要打开 Xcode 编译一下,提交上架。


🌈最后说一句

混淆不是目的。
目的,是减少重复劳动、提高上架成功率,让你多点时间写你想写的代码

以前的你:手动重命名几十个文件,改到怀疑人生。
现在的你:一条命令,脚本帮你完成所有脏活累活。

如果这篇文章对你有帮助,点个赞👍让我知道。


iOS SwiftUI 布局容器详解

SwiftUI 布局容器详解

SwiftUI 提供了多种布局容器,每种都有特定的用途和行为。以下是对主要布局容器的全面详解:

一、基础布局容器

1. VStack - 垂直堆栈

VStack(alignment: .leading, spacing: 10) {
    Text("顶部")
    Text("中部")
    Text("底部")
}
  • 功能:垂直排列子视图
  • 参数
    • alignment:水平对齐方式(.leading, .center, .trailing
    • spacing:子视图间距
  • 布局特性:根据子视图大小决定自身高度

2. HStack - 水平堆栈

HStack(alignment: .top, spacing: 20) {
    Text("左")
    Text("中")
    Text("右")
}
  • 功能:水平排列子视图
  • 参数
    • alignment:垂直对齐方式(.top, .center, .bottom, .firstTextBaseline, .lastTextBaseline
    • spacing:子视图间距

3. ZStack - 重叠堆栈

ZStack(alignment: .topLeading) {
    Rectangle()
        .fill(Color.blue)
        .frame(width: 200, height: 200)
    
    Text("覆盖文本")
        .foregroundColor(.white)
}
  • 功能:子视图重叠排列
  • 参数
    • alignment:对齐方式,控制所有子视图的共同对齐点
  • 渲染顺序:后添加的视图在上层

二、惰性布局容器(Lazy Containers)

4. LazyVStack - 惰性垂直堆栈

ScrollView {
    LazyVStack(pinnedViews: .sectionHeaders) {
        ForEach(0..<1000) { index in
            Text("行 \(index)")
                .frame(height: 50)
        }
    }
}
  • 特性:仅渲染可见区域的视图,提高性能
  • 参数
    • pinnedViews:固定视图(.sectionHeaders, .sectionFooters
  • 使用场景:长列表,性能敏感场景

5. LazyHStack - 惰性水平堆栈

ScrollView(.horizontal) {
    LazyHStack {
        ForEach(0..<100) { index in
            Text("列 \(index)")
                .frame(width: 100)
        }
    }
}

6. LazyVGrid - 惰性垂直网格

let columns = [
    GridItem(.fixed(100)),
    GridItem(.flexible()),
    GridItem(.adaptive(minimum: 50))
]

ScrollView {
    LazyVGrid(columns: columns, spacing: 10) {
        ForEach(0..<100) { index in
            Color.blue
                .frame(height: 100)
                .overlay(Text("\(index)"))
        }
    }
    .padding()
}
  • GridItem类型
    • .fixed(CGFloat):固定宽度
    • .flexible(minimum:, maximum:):灵活宽度
    • .adaptive(minimum:, maximum:):自适应,尽可能多放置

7. LazyHGrid - 惰性水平网格

let rows = [GridItem(.fixed(100)), GridItem(.fixed(100))]

ScrollView(.horizontal) {
    LazyHGrid(rows: rows, spacing: 20) {
        ForEach(0..<50) { index in
            Color.red
                .frame(width: 100)
        }
    }
}

三、特殊布局容器

8. ScrollView - 滚动视图

ScrollView(.vertical, showsIndicators: true) {
    VStack {
        ForEach(0..<50) { index in
            Text("项目 \(index)")
                .frame(maxWidth: .infinity)
                .padding()
                .background(Color.gray.opacity(0.2))
        }
    }
}
  • 滚动方向.vertical, .horizontal
  • 参数
    • showsIndicators:是否显示滚动条

9. List - 列表

List {
    Section(header: Text("第一部分")) {
        ForEach(1..<5) { index in
            Text("行 \(index)")
        }
    }
    
    Section(footer: Text("结束")) {
        ForEach(5..<10) { index in
            Text("行 \(index)")
        }
    }
}
.listStyle(.insetGrouped)  // 多种样式可选
  • 样式.plain, .grouped, .insetGrouped, .sidebar
  • 特性:自带滚动、优化性能、支持分节

10. Form - 表单

Form {
    Section("个人信息") {
        TextField("姓名", text: $name)
        DatePicker("生日", selection: $birthday)
    }
    
    Section("设置") {
        Toggle("通知", isOn: $notifications)
        Slider(value: $volume, in: 0...1)
    }
}
  • 特性:自动适配平台样式,适合设置界面

11. NavigationStack (iOS 16+) - 导航栈

NavigationStack(path: $path) {
    List {
        NavigationLink("详情", value: "detail")
        NavigationLink("设置", value: "settings")
    }
    .navigationDestination(for: String.self) { value in
        switch value {
        case "detail":
            DetailView()
        case "settings":
            SettingsView()
        default:
            EmptyView()
        }
    }
}

12. TabView - 标签视图

TabView {
    HomeView()
        .tabItem {
            Label("首页", systemImage: "house")
        }
        .tag(0)
    
    ProfileView()
        .tabItem {
            Label("我的", systemImage: "person")
        }
        .tag(1)
}
.tabViewStyle(.automatic)  // 或 .page(页面式)

13. Grid (iOS 16+) - 网格布局

Grid {
    GridRow {
        Text("姓名")
        Text("年龄")
        Text("城市")
    }
    .font(.headline)
    
    Divider()
        .gridCellUnsizedAxes(.horizontal)
    
    GridRow {
        Text("张三")
        Text("25")
        Text("北京")
    }
}

四、布局辅助视图

14. Spacer - 间距器

HStack {
    Text("左")
    Spacer()  // 将左右视图推向两端
    Text("右")
}

VStack {
    Text("顶部")
    Spacer(minLength: 20)  // 最小间距
    Text("底部")
}

15. Divider - 分割线

VStack {
    Text("上部分")
    Divider()  // 水平分割线
    Text("下部分")
}

16. Group - 分组容器

VStack {
    Group {
        if condition {
            Text("条件1")
        } else {
            Text("条件2")
        }
    }
    .padding()
    .background(Color.yellow)
}
  • 作用
    • 突破10个子视图限制
    • 统一应用修饰符
    • 条件逻辑分组

17. ViewBuilder - 视图构建器

@ViewBuilder
func createView(showDetail: Bool) -> some View {
    Text("基础")
    if showDetail {
        Text("详情")
        Image(systemName: "star")
    }
}

五、自定义布局容器

18. Layout 协议 (iOS 16+)

struct MyCustomLayout: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        // 计算布局所需大小
        CGSize(width: proposal.width ?? 300, height: 200)
    }
    
    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        // 放置子视图
        var point = bounds.origin
        for subview in subviews {
            subview.place(at: point, proposal: .unspecified)
            point.x += 100
        }
    }
}

19. AnyLayout (iOS 16+)

@State private var isVertical = true

var body: some View {
    let layout = isVertical ? AnyLayout(VStackLayout()) : AnyLayout(HStackLayout())
    
    layout {
        Text("视图1")
        Text("视图2")
    }
}

六、布局修饰符

20. frame - 尺寸约束

Text("Hello")
    .frame(
        maxWidth: .infinity,  // 最大宽度
        minHeight: 50,        // 最小高度
        alignment: .center    // 对齐方式
    )

21. padding - 内边距

Text("内容")
    .padding()                    // 所有方向
    .padding(.horizontal, 10)     // 水平方向
    .padding(.top, 20)           // 顶部
    .padding(EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20))

22. position & offset - 位置调整

Text("绝对定位")
    .position(x: 100, y: 100)  // 相对于父视图
    
Text("相对偏移")
    .offset(x: 10, y: -5)      // 相对当前位置

七、布局优先级

23. 布局优先级

HStack {
    Text("短文本")
        .layoutPriority(1)  // 高优先级,先分配空间
    
    Text("这是一个非常长的文本,可能会被压缩")
        .layoutPriority(0)  // 低优先级
}
.frame(width: 200)

八、布局选择指南

容器 适用场景 性能特点
VStack/HStack 简单布局,子视图数量少 立即布局所有子视图
LazyVStack/LazyHStack 长列表,滚动视图 惰性加载,高性能
List 数据列表,需要交互 高度优化,支持选择、删除等
Grid/LazyVGrid 网格布局,瀑布流 灵活的多列布局
ZStack 重叠布局,层叠效果 适合覆盖、浮动元素
ScrollView 自定义滚动内容 需要手动管理性能

九、最佳实践

  1. 选择合适的容器:根据需求选择最合适的布局容器
  2. 避免过度嵌套:简化布局层级,提高性能
  3. 使用惰性容器:处理大量数据时使用Lazy容器
  4. 利用Spacer:灵活控制视图间距
  5. 组合使用:合理组合多个容器实现复杂布局
  6. 测试多尺寸:在不同设备尺寸和方向上测试布局

这些容器可以灵活组合使用,创造出各种复杂的用户界面。掌握这些布局容器的特性和适用场景,是成为SwiftUI布局专家的关键。

一次弹窗异常引发的思考:iOS present / push 底层机制全解析

这篇文章从一个真实线上问题讲起: 在弹窗VC 里点了一行cell,结果直接跳回了UITabBarController。 借着排查这个 Bug 的过程,我系统梳理了一遍 iOS 中与导航相关的底层机制:present/dismiss、push/pop、“获取顶层 VC(getTopVC)”、以及 UITableView 的选中/取消逻辑。


一、视图控制器层级:Navigation 栈 vs Modal 链

1. 两套完全独立的层级体系

Navigation 栈(push/pop)

  • 结构:UINavigationController.viewControllers = [VC0, VC1, VC2, ...]
  • 行为:
    • pushViewController::追加到数组尾部
    • popViewControllerAnimated::从数组尾部移除
  • 只影响 导航栈 中的顺序,不改变谁 present 了谁。

Modal 链(present/dismiss)

  • 结构:由 presentingViewController / presentedViewController 串联成一条链:
    • A.presentedViewController = B
    • B.presentedViewController = C
  • 行为:
    • presentViewController::在当前 VC 上方展示一个新 VC
    • dismissViewControllerAnimated::从某个 VC 开始,把它和它上面所有通过它 present 出来的 VC 一起收回

记忆方式:

  • push/pop 操作的是 “数组”(导航栈)
  • present/dismiss 操作的是 “链表”(模态链)

2. 组合层级的典型例子

A(Tab 内业务页) └─ present → B(弹窗或二级页,带导航) └─ push → C(B 的导航栈里再 push 出来的 VC)- 导航栈(以 B 的导航控制器为例):[B, C]

  • 模态链:A -(present)-> B

关键结论:

dismiss B ⇒ B 和 B 承载的那棵 VC 树一起消失 ⇒ 导航回到 A(B 的 presentingViewController)。
UIKit 不支持 “只 dismiss B 保留 C” 这种结构。


二、dismissViewControllerAnimated: 的真实含义

[vc dismissViewControllerAnimated:YES completion:nil];核心点:

  1. 这个调用作用在 “vc 所在的模态链” 上,而不是导航栈。
  2. 如果 vc 是被某个 VC 通过 presentViewController: 推出来的,那么:
    • 系统会找到它的 presentingViewController
    • 把从 vc 起到链尾的所有 VC 都 dismiss 掉
    • 显示回到 presentingViewController

1. 谁调用 vs 谁被 dismiss

很多人容易混淆这两种写法:

[self dismissViewControllerAnimated:YES completion:nil];

[[self getTopVC] dismissViewControllerAnimated:YES completion:nil];

只要这两种写法最终作用到的是同一个 VC,它们的行为完全一致。

  • 决定回到哪里的,是「被 dismiss 的那个 VC 的 presentingViewController」,而不是“谁来触发这次调用”。
  • 这也是为什么单纯把 self 改成 [self getTopVC]并不能改变 dismiss 之后的落点。

2. presentingViewController 的生命周期

[parentVC presentViewController:childVC animated:YES completion:nil];
  • 在这行代码执行完成时:
    • childVC.presentingViewController = parentVC 被永久确定
  • 后续不管从哪里、什么时候触发:
    • 只要 dismiss 的对象是 childVC,最终都会回到同一个 parentVC

三、“顶层 VC” 工具(如 getTopVC)的时序问题

很多项目中都会有类似如下工具方法:

@implementation UIViewController(Additions)

- (UIViewController*)getTopVC {
    if (self.presentedViewController) {
        return [self.presentedViewController getTopVC];
    }
    if ([self isKindOfClass:UITabBarController.class]) {
        return [[(UITabBarController*)self selectedViewController] getTopVC];
    }
    else if ([self isKindOfClass:UINavigationController.class]) {
        return [[(UINavigationController*)self visibleViewController] getTopVC];
    }
    return self;
}

@end

@implementation UIApplication (Additions)

+ (UIViewController *)getCurrentTopVC{
    UIViewController *currentVC = [UIApplication sharedApplication].delegate.window.rootViewController;
    return [currentVC getTopVC];
}
@end

关键:这类函数对「调用时机」极度敏感。

情况 1:在“弹窗 VC 还在屏幕上”时调用

比如某个present出来的弹窗 VC 还没有被 dismiss,这时调用 getTopVC(),返回的就是这个弹窗 VC。

情况 2:在“弹窗 VC 已经被 dismiss 掉”之后调用

当 弹窗 VC 已经执行过 dismissViewControllerAnimated:,不再显示在屏幕上,这时再调用 getTopVC(),返回的就是它下面那一层控制器(例如列表页、TabBar 下当前选中的子控制器),而不再是 弹窗 VC 本身。

情况3: 一个典型的 Bug 时序

  1. 子类在 cell 点击时,调了父类的 didSelectRowAtIndexPath:
  2. 父类内部逻辑(伪代码):
 [self dismissViewControllerAnimated:YES completion:^{
     if (self.didSelectedIndex) {
         self.didSelectedIndex(indexPath.row);  // 触发外层 block
     }
 }];

也就是说:先 dismiss 自己,再回调外层 block

  1. 外层 block 中再执行:
 [[UIApplication getCurrentTopVC] dismissViewControllerAnimated:YES completion:nil];

由于这时 弹框VC 已经被 dismiss 掉,getCurrentTopVC() 拿到的是 下层 VC(例如一个筛选页或 TabBar) 于是第二次 dismiss 把下层页面也关掉了 4. 用户看到的效果就是:

点击弹窗里的一个 cell ⇒ 弹窗消失 ⇒ 当前页面也被关闭 ⇒ 直接回到了 TabBar

根本原因:
第二次调用 getTopVC()时机太晚,此时“顶层 VC”已经不是弹窗,而是它下面的页面。


四、UITableView 的选中/取消逻辑

1. 系统接口的作用

  • selectRowAtIndexPath:animated:scrollPosition: 会:

    • 更新 tableView 内部的选中状态;
    • 调用 cell 的 setSelected:YES
    • 触发 tableView:didSelectRowAtIndexPath: 回调。
  • deselectRowAtIndexPath:animated: 会:

    • 清除选中状态;
    • 调用 cell 的 setSelected:NO
    • 触发 tableView:didDeselectRowAtIndexPath: 回调。

也就是说,单靠 deselectRowAtIndexPath:,就已经隐含执行了很多事情,不必再额外手工写 cell.selected = NO

2. 单选列表中的推荐顺序

UITableView 在单选模式下,用户点一个新 row 时,系统内部的默认顺序是:先调用 didDeselectRowAtIndexPath:(旧 row)→ 再调用 didSelectRowAtIndexPath:(新 row)。 所以在自定义 cell 中的选中/取消逻辑时, 推荐顺序调用这2个方法

例如: 你维护了一个 selectedIndex,在代码中手动切换选中行时,可以这样写:

NSIndexPath *oldIndexPath = [NSIndexPath indexPathForRow:self.selectedIndex inSection:0];
NSIndexPath *newIndexPath = indexPath;

// 1. 先取消旧的
[tableView deselectRowAtIndexPath:oldIndexPath animated:YES];

// 2. 再选中新的
[tableView selectRowAtIndexPath:newIndexPath
                       animated:YES
                 scrollPosition:UITableViewScrollPositionNone];这样能确保:
  • 旧 cell 的 setSelected:NO / didDeselect 逻辑先执行;
  • 新 cell 的 setSelected:YES / didSelect 后执行;
  • 对自定义 cell(在 setSelected: 里更换图标、颜色等)尤为友好;
  • 不会出现两个 cell 同时高亮的瞬间状态。

3. 何时可以不再调用父类 tableView:didDeselectRowAtIndexPath:

如果父类的 tableView:didDeselectRowAtIndexPath: 实现只是:

  • (void)tableView:(UITableView *)tableView didDeselectRowAtIndexPath:(NSIndexPath *)indexPath { UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath]; [cell setSelected:NO]; }, 而你在子类里已经调用了 deselectRowAtIndexPath:animated:,那么:

  • 系统内部已经帮你执行了 setSelected:NO

  • 再手动调用父类 didDeselectRowAtIndexPath: 属于重复操作,可以安全省略。


五、“到 C 后不能回 B”:通过修改导航栈实现

用户需求

当前导航栈:A -> B -> C

期望: 在C上点击返回时直接回到 A,不能再回到 B。

代码实现

-- push 到新 VC,并从栈中移除当前 VC
-- 修改 `viewControllers` 数组, 重置导航栈
- (void)deleteCurrentVCAndPush:(UIViewController *)viewController animated:(BOOL)animated {
    UIViewController* top = self.topViewController;
    [self pushViewController:viewController animated:animated];
    NSMutableArray* viewControllers = [self.viewControllers mutableCopy];
    [viewControllers removeObject:top];
    
    [self setViewControllers:viewControllers animated:NO];
}

与 dismiss 的区别

  • 修改导航栈:
    • 仅操作 navigationController.viewControllers 数组;
    • 不改变 modal 链,presentingViewController 关系保持不变;
  • dismiss 某个 VC:
    • 只看 modal 链;
    • 会回到 presentingViewController
    • 无法仅移除 B 而让 C 留在界面上。

六、整体总结

  1. 理解 Navigation 栈与 Modal 链是所有导航问题的基础

    • push/pop 只改数组
    • present/dismiss 只改链表
  2. dismissViewControllerAnimated: 的返回点由 presentingViewController 决定

    • 谁调用不重要,谁被 dismiss 才重要。
  3. “获取顶层 VC” 的工具对调用时机非常敏感

    • 在 VC 被 dismiss 前后调用,返回的完全是不同的对象;
    • 在错误的时机用它再发起一次 dismiss,往往会“多退一层”。
  4. 手动控制 UITableView 的选中状态时,优先使用 select/deselect 接口,并保持“先取消旧选中,再选中新行”的顺序

  5. “到 C 后不能回 B”这类需求,本质是对导航栈的重写,而非 dismiss 某个 VC

    • 正确做法是修改 viewControllers 数组,或使用封装好的 “deleteCurrentVCAndPush” 类方法。

掌握这些底层规则,遇到类似“弹窗关闭顺序错乱”、“页面一点击就跳回根控制器”、“导航上跳过某一层”等问题时,就能更快定位根因,设计出行为可控、易维护的解决方案。

iOS逆向-哔哩哔哩增加3倍速播放(2)-[横屏视频-半屏播放]增加3倍速播放

前言

作为一名 哔哩哔哩的重度用户,我一直期待官方推出 3 倍速播放 功能。然而等了许久,这个功能始终没有上线 😮‍💨。

修改前效果: Screenshot 2025-12-11 at 07.26.05.png

刚好我自己熟悉 iOS 逆向工程,于是决定 亲自动手,为 B 站加入 3 倍速播放 😆。

修改后效果: Screenshot 2025-12-11 at 07.22.57.png

由于整个过程涉及 多处逻辑修改与多个模块的反汇编分析,为了让内容更加清晰易读,我将会分成多篇文章,逐步拆解 如何为 B 站增加 3 倍速播放能力

场景

[横屏视频-半屏播放]的播放页面

CE1C32DB-8B78-4543-844C-5283FA858E86.png

开发环境

  • 哔哩哔哩版本:8.41.0

  • MonkeyDev

  • IDA Professional 9.0

  • 安装IDA插件:patching

  • Lookin

目标

[横屏视频-半屏播放]增加三倍速播放

分析

  • Lookin可以知道,播放速度组件叫做VKSettingView.SelectContent

1D8085C7-9797-435A-A5E2-3D748FE9B097.png

  • Mach-O文件导出的VKSettingView.SelectContentswift文件可以知道,它的model叫做VKSettingView.SelectModel
class VKSettingView.SelectContent: VKSettingView.TitleBaseContent {
  /* fields */
    var model: VKSettingView.SelectModel ?
    var lazy selecter: VKSettingView.VKSelectControl ?
}
  • VKSettingView.SelectModel有个items属性,有可能是播放速度数组。我们从IDA依次查看方法的实现,找到itemssetter方法叫做sub_10D8ACB88
import Foundation

class VKSettingView.SelectModel: VKSettingView.BaseModel {
  /* fields */
    var icon: String
    var items: [String]
    var reports: [String]
    var selectedIndex: Int
    var dynamicSelectedString: String?
    var enableRepeatSelect: Swift.Bool
    var selectChangeCallback: ((_:_:))?
    var preferScrollPosition: VKSettingView.VKSelectControlScrollPosition
  /* methods */
    func sub_10d8aca08 // getter (instance)
    func sub_10d8acac4 // setter (instance)
    func sub_10d8acb20 // modify (instance)
    func sub_10d8acb70 // getter (instance)
    func sub_10d8acb88 // setter (instance)
    func sub_10d8acb94 // modify (instance)
    func sub_10d8acc48 // getter (instance)
    func sub_10d8acd10 // setter (instance)
    func sub_10d8acd68 // modify (instance)
    func sub_10d8acf6c // getter (instance)
    func sub_10d8acff8 // setter (instance)
    func sub_10d8ad040 // modify (instance)
    func sub_10d8ad138 // getter (instance)
    func sub_10d8ad234 // setter (instance)
    func sub_10d8ad2a0 // modify (instance)
    func sub_10d8ad328 // getter (instance)
    func sub_10d8ad3b4 // setter (instance)
    func sub_10d8ad3fc // modify (instance)
}

5C5C3435-8A3B-47BB-8689-E56D31E2617E.png

  • 我们在Xcode添加符号断点sub_10D8ACB88,看到底谁设置了items的值

E7D376B3-3226-4C6E-AFA4-F9F94058CE32.png

  • sub_10D8ACB88断点触发,我们打印参数的值,证明items确实是播放速度数组
(lldb) p (id)$x0
(_TtGCs23_ContiguousArrayStorageSS_$ *) 0x00000001179c8370
(lldb) expr -l Swift -- unsafeBitCast(0x00000001179c8370, to: Array<String>.self)
([String]) $R4 = 6 values {
  [0] = "0.5"
  [1] = "0.75"
  [2] = "1.0"
  [3] = "1.25"
  [4] = "1.5"
  [5] = "2.0"
}
  • 我们打印方法的调用堆栈,发现是sub_10A993E14修改了items的值
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 3.1
  * frame #0: 0x000000010de78b88 bili-universal`sub_10D8ACB88
    frame #1: 0x000000010af5fea0 bili-universal`sub_10A993E14 + 140
    frame #2: 0x000000010af5f15c bili-universal`sub_10A992320 + 3644
    frame #3: 0x000000010af5db20 bili-universal`sub_10A9916B4 + 1132
    frame #4: 0x000000010af6714c bili-universal`sub_10A99B130 + 28
    frame #5: 0x000000010af6859c bili-universal`sub_10A99C1A0 + 1020
    frame #6: 0x000000010af67128 bili-universal`sub_10A99B118 + 16
...
  • 我们从IDA看下sub_10A993E14的伪代码实现
_QWORD *__fastcall sub_10A993E14(void *a1, id a2)
{
...

  v3 = a2;
  if ( a2 && (v4 = v2, v6 = type metadata accessor for SelectModel(0LL), (v7 = swift_dynamicCastClass(v3, v6)) != 0) )
  {
    v9 = (_QWORD *)v7;
    v10 = sub_107C8B79C(&unk_116BB42E8, v8);
    inited = swift_initStaticObject(v10, &unk_116E60370);
    v12 = *(void (__fastcall **)(__int64))((swift_isaMask & *v9) + 0x1C0LL);
    v13 = objc_retain(v3);
...
  • 我们直接搜索sub_10A993E14的伪代码,看是否有直接调用sub_10D8ACB88,很遗憾并没有
  • 我们添加sub_10A993E14符号断点,断点触发后打印方法的参数,发现x1的值是_TtC13VKSettingView11SelectModel,也就是VKSettingView.SelectModel
(lldb) p (id)$x0
(BAPIPlayersharedSettingItem *) 0x0000000282c0f880
(lldb) p (id)$x1
(_TtC13VKSettingView11SelectModel *) 0x0000000283b51790
  • 我们打印x1(VKSettingView.SelectModel)(0x0000000283b51790)的items的值,发现是个空数组
(lldb) p (id)$x1
(_TtC13VKSettingView11SelectModel *) 0x0000000283b51790
(lldb) p [(_TtC13VKSettingView11SelectModel *) 0x0000000283b51790 items]
(_TtCs19__EmptyArrayStorage *) 0x00000001dd92e978
(lldb) expr -l Swift -- unsafeBitCast(0x00000001dd92e978, to: Array<String>.self)
([String]) $R2 = 0 values {}
  • 我们在sub_10A993E14方法返回之前添加一个断点,看下x1(VKSettingView.SelectModel)(0x0000000283b51790)的items的值

16093E5E-4BD2-4281-9945-297047044F27.png

(lldb) register read 
General Purpose Registers:
        x0 = 0x0000000283b51790
        x1 = 0x00000002819eb700
        x2 = 0x0000000000000003
...
       x23 = 0x0000000283b51790
       x24 = 0x0000000283b51790
       x25 = 0x0000000116e17f28  (void *)0x00000001173e6b88: OBJC_METACLASS_$__TtC16BBUGCVideoDetail13VDUGCMoreBloc
       x26 = 0x00000001142906d8  bili-universal`type_metadata_for_ToolCell + 784
       x27 = 0x000000010a552534  bili-universal`sub_109F86534
       x28 = 0x0000000116718000  "badge_control"
        fp = 0x000000016f832610
        lr = 0x000000010af5f15c  bili-universal`sub_10A992320 + 3644
        sp = 0x000000016f832510
        pc = 0x000000010af5ffe4  bili-universal`sub_10A993E14 + 464
      cpsr = 0x60000000
  • 因为x0的值是0x0000000283b51790,所以打印x0的值,看到x0(VKSettingView.SelectModel)(0x0000000283b51790)的items有值了,就是播放速度数组,这也证明sub_10A993E14修改了VKSettingView.SelectModelitems的值。x0通常拿来存放函数的返回值。
(lldb) p (id)$x0
(_TtC13VKSettingView11SelectModel *) 0x0000000283b51790
(lldb) p [(_TtC13VKSettingView11SelectModel *) 0x0000000283b51790 items]
(_TtCs22__SwiftDeferredNSArray *) 0x0000000280743120 6 values
(lldb) po 0x0000000280743120
<Swift.__SwiftDeferredNSArray 0x280743120>(
0.5,
0.75,
1.0,
1.25,
1.5,
2.0
)
  • 我们将sub_10A993E14的伪代码,参数a1的类型是BAPIPlayersharedSettingItema2的类型是VKSettingView.SelectModel一起给chatgpt分析,chatgpt叫我们查看 swift_initStaticObject 的参数 &unk_116E60370的值是什么。
    • 如果chatgpt的分析结果没用,我们就自己打断点,看是哪些汇编代码更改了items的值,再看汇编代码对应的伪代码是怎样的。
检查 inited = swift_initStaticObject(...) 对象
inited 很可能是 SelectModel 或其内部配置对象(例如某个静态配置结构体或 Swift 字典/数组)被初始化。你可以在反汇编中查看 swift_initStaticObject 的参数 &unk_116E60370 看看该静态对象是什么,它可能携带 items 的初始数据。若你在数据段或只读段中找到与 “items” 相关的字符串数组、常量字符串列表、NSStringPointer 等,那可能就是 items 的来源。
  • 查看&unk_116E60370的值,发现是在数据段(__data)中

AD08C629-FC16-405A-B8EC-D4F866C62775.png

  • 查看&unk_116E60370的值的16进制视图,发现它旁边的地址存放着播放速度,所以&unk_116E60370保存着播放速度数组

0DF1A4B8-7DCF-4F83-A4DB-6C484DF53598.png

  • 我们知道数据段(__data)存放着全局变量,所以播放速度数组应该是放在一个全局变量里面,类似:
var playbackRates = ["0.5", "0.75", "1.0", "1.25", "1.5", "2.0"]

说明

比如0000000116E789B0,保存的值是0.75

0000000116E789B0  30 2E 37 35 00 00 00 00  00 00 00 00 00 00 00 E4  0.75............

各个字节的解析如下,特别是最后一个字节E4,代表要读取4个字节的数据,如果是E3代表要读取3个字节的数据

30 : 0
2E : .
37 : 7
35 : 5
E4 : 读取四个字节的数据

越狱解决方案

  • 修改下面地址存储的值
0000000116E60390  30 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  0.5.............
0000000116E603A0  30 2E 37 35 00 00 00 00  00 00 00 00 00 00 00 E4  0.75............
0000000116E603B0  31 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.0.............
0000000116E603C0  31 2E 32 35 00 00 00 00  00 00 00 00 00 00 00 E4  1.25............
0000000116E603D0  31 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.5.............
0000000116E603E0  32 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  2.0.............
  • 具体代码
/// 将速度写入到内存地址
/// - Parameters:
///   - dest_addr: 目标内存地址
///   - str: 速度字符串,比如"1.0"
static int write_rate_string_to_address(uintptr_t dest_addr, NSString *str) {
    if (str == nil) {
        return -1;
    }

    // UTF8 字符串
    const char *utf8Str = [str UTF8String];
    size_t strLength = strlen(utf8Str);   // 字符数(不含 \0)

    if (strLength > (NJ_RATE_BLOCK_SIZE - 1)) {
        // 只能容纳前15字节 + 最后一字节用于 E0+strLength
        strLength = NJ_RATE_BLOCK_SIZE - 1;
    }

    uint8_t block[NJ_RATE_BLOCK_SIZE];
    memset(block, 0, NJ_RATE_BLOCK_SIZE);

    // 前 strLength 字节写入字符串
    memcpy(block, utf8Str, strLength);

    // 最后一个字节写入:E0 + 长度
    block[NJ_RATE_BLOCK_SIZE - 1] = 0xE0 + (uint8_t)strLength;

    // 将 block 写到目标地址
    memcpy((void *)dest_addr, block, NJ_RATE_BLOCK_SIZE);

    return 0;
}


/// 将速度写入到内存地址
/// - Parameter baseAddress: 起始内存地址
static void write_rate_to_address(uintptr_t baseAddress) {
    NSArray<NSString *> *playbackRates = @[@"0.5", @"1.0", @"1.25", @"1.5", @"2.0", @"3.0"];
    NSInteger count = playbackRates.count;
    for (NSInteger i = 0; i < count; i++) {
        uintptr_t currentAddress = baseAddress + i * NJ_RATE_BLOCK_SIZE;
        write_rate_string_to_address(currentAddress, playbackRates[i]);
    }
}

// [横屏视频-半屏播放]的播放速度
static void changePlaybackRates_LandscapeVideo_HalfScreenPlayback() {
    /*
     0000000116E60390  30 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  0.5.............
     0000000116E603A0  30 2E 37 35 00 00 00 00  00 00 00 00 00 00 00 E4  0.75............
     0000000116E603B0  31 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.0.............
     0000000116E603C0  31 2E 32 35 00 00 00 00  00 00 00 00 00 00 00 E4  1.25............
     0000000116E603D0  31 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.5.............
     0000000116E603E0  32 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  2.0.............
     */
    uintptr_t baseAddress = g_slide + 0x116E60390;
    write_rate_to_address(baseAddress);
}

非越狱解决方案

修改Mach-O文件的汇编指令

目标

  • 修改下面地址存储的值
0000000116E60390  30 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  0.5.............
0000000116E603A0  30 2E 37 35 00 00 00 00  00 00 00 00 00 00 00 E4  0.75............
0000000116E603B0  31 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.0.............
0000000116E603C0  31 2E 32 35 00 00 00 00  00 00 00 00 00 00 00 E4  1.25............
0000000116E603D0  31 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.5.............
0000000116E603E0  32 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  2.0.............

示例

比如修改0000000116E603A0

0000000116E603A0  30 2E 37 35 00 00 00 00  00 00 00 00 00 00 00 E4  0.75............
  • 鼠标点击0000000116E603A0
  • IDA->Edit->Patch program->Change byte

9FFF3DBB-69F3-49EB-9A32-8CA94207D159.png

  • 显示Patch Bytes弹框

D3949C2A-9560-4D76-A710-ADAAE4E75A81.png

  • Origin value
    • 30 2E 37 35 00 00 00 00 00 00 00 00 00 00 00 E4
  • 修改 Values 为:
    • 31 2E 30 00 00 00 00 00 00 00 00 00 00 00 00 E3
  • 点击OK,真正修改

修改结果

  • 当前的值:
0.530 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3
0.7530 2E 37 35 00 00 00 00  00 00 00 00 00 00 00 E4
1.031 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3
1.2531 2E 32 35 00 00 00 00  00 00 00 00 00 00 00 E4
1.531 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3
2.032 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3
  • 新的播放速度对应的值:
0.530 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3
1.031 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3
1.2531 2E 32 35 00 00 00 00  00 00 00 00 00 00 00 E4
1.531 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3
2.032 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3
3.033 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3
  • 全部修改完后
0000000116E60390  30 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  0.5.............
0000000116E603A0  31 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.0.............
0000000116E603B0  31 2E 32 35 00 00 00 00  00 00 00 00 00 00 00 E4  1.25............
0000000116E603C0  31 2E 35 00 00 00 00 00  00 00 00 00 00 00 00 E3  1.5.............
0000000116E603D0  32 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  2.0.............
0000000116E603E0  33 2E 30 00 00 00 00 00  00 00 00 00 00 00 00 E3  3.0.............

保存

保存到Mach-O文件

  • IDA->Edit->Patch program->Apply patches to input file->OK

9D1A3899-9871-4DF3-BC43-6D3109085192.png

29737F6A-DF9E-48AB-A46A-77363CA8546B.png

  • 保存后,底部会显示log

    Patch successful: /Users/touchworld/Documents/iOSDisassembler/hook/bilibili/IDA_max_0/bili-universal
    

    F1E26F23-6F74-4DEC-B1CD-1256372F5FBE.png

效果

73D09925-1B81-42A9-8392-8EFB88B9884D.png

代码

BiliBiliMApp-无广告版哔哩哔哩

❌