普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月20日iOS

Compositional layout in iOS

作者 songgeb
2026年3月20日 17:28

Compositional layout是在2019年为UICollectioinView引入的一个新布局

Compositional layout是什么

  • Compositional layout是一套针对UICollectionView新的布局方法
  • 对应的核心类是UICollectionViewCompositionalLayout(macOS上是NSCollectionViewCompositionalLayout)
  • 其目的是让UICollectionView可以更容易地支持更灵活布局UI的开发

Compositional layout布局的三大设计哲学:

  1. Composable:可组合,强调用简单的组件组合出复杂的内容
  2. Flexible:灵活(官方说,You can write any layout with Compositional layout)
  3. Fast

几个例子感受一下Compositional layout能做什么

  1. 如下示意图展示了一个纵向滚动的UICollectionView,其中有上中下三部分,上部分看上去像两列UITableView,各部分的布局和样式各不相同

image.png

  1. 如下示意图展示了纵向滚动的UICollectionView,其中横向上有多个可以横向滚动的组(App Store应用大量使用该布局)
    • 看到这里我立马想到了:可能再也不用多个UICollectionView嵌套了

IMG_1035.PNG

四个核心概念

Compositional layout由四个最核心的概念组成

Item > Group > Section > Layout

  • ItemLayout,表示的范围依次扩大
  • 任何一个Compositional layout都从左到右组合而成

image.png

如何使用Compositional layout

Compositional layout的核心类是UICollectionViewCompositionalLayout,其初始化方法有两类,如下代码所示:

  • 一类是直接提供section,另一类是通过provider动态的提供section
class UICollectionViewCompositionalLayout : UICollectionViewLayout {

    public init(section: NSCollectionLayoutSection)

    public init(section: NSCollectionLayoutSection, configuration: UICollectionViewCompositionalLayoutConfiguration)

    public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider)

    public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider, configuration: UICollectionViewCompositionalLayoutConfiguration)
}

创建UICollectionViewCompositionalLayout的过程就是上小节提到的

Item > Group > Section > Layout

let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),
                                     heightDimension: .fractionalHeight(1.0))

let item = NSCollectionLayoutItem(layoutSize: itemSize)

let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                      heightDimension: .fractionalWidth(0.2))

let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                 subitems: [item])

let section = NSCollectionLayoutSection(group: group)

let layout = UICollectionViewCompositionalLayout(section: section)

OrthogonalScrolling

再介绍一个官方Demo中提到的稍微复杂一点的Compositional layout案例

我们希望最终效果如下图所示:

Screenshot 2026-03-20 at 16.33.00.png

首先进行设计:

  • 整体纵向滚动,横向上有多行,每一行也是可以滚动的,每一行我们可以看做是一个Section
  • 关注到每一行中的元素,每一行中基本的滚动单元是:左侧的一个大块+右侧两个小块,滚动单元可以认为是Group
  • 具体的大小块则可以认为是Item

关于“左侧的一个大块+右侧两个小块”的示意图如下所示:

image.png

以下是Compositional layout代码,我们对照注释看一下创建过程:

// 1. 左侧大块Item的创建
// - 宽度:希望占容器(group)宽度的70%
// - 高度:希望和容器一样高
let leadingItem = NSCollectionLayoutItem(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.7),
                                      heightDimension: .fractionalHeight(1.0)))
leadingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

// 2. 右侧任意的一个小块
// - 宽度:和容器一样宽
// - 高度:占容器高度的一半
let trailingItem = NSCollectionLayoutItem(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                      heightDimension: .fractionalHeight(0.5)))
trailingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

// 3. 创建一个容器group,这是一个纵向的容器,容纳右侧的两个小块Item。宽度占该group所在容器的30%;高度和容器一致
let trailingGroup = NSCollectionLayoutGroup.vertical(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.3),
                                       heightDimension: .fractionalHeight(1.0)),
    repeatingSubitem: trailingItem,
    count: 2)
    
// 4. 创建一个横向容器group,容纳1个大块+2个小块。宽度占其容器的85%,高度占40¥
let containerGroup = NSCollectionLayoutGroup.horizontal(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.85),
                                      heightDimension: .fractionalHeight(0.4)),
    subitems: [leadingItem, trailingGroup])
// 5. 创建section,包含最外层的横向容器group
let section = NSCollectionLayoutSection(group: containerGroup)
section.orthogonalScrollingBehavior = .continuous
// 6. 创建layout,使用默认configuration,默认是纵向滚动
return UICollectionViewCompositionalLayout(section: section)

再看一下数据源代码:

var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
var identifierOffset = 0
let itemsPerSection = 30
for section in 0..<5 {
    snapshot.appendSections([section])
    let maxIdentifier = identifierOffset + itemsPerSection
    snapshot.appendItems(Array(identifierOffset..<maxIdentifier))
    identifierOffset += itemsPerSection
}
  • 整体数据是一个二维数组,类似这样:[[0,1...29], [30...59], [...], [...], [...]]
  • 创建了5个Section,每个Section
  • 每个Section中有30个数字
  • 30个数字拆分成了10个Group,每个Group有三个Item。如果对应着数据源,则依次是[0,1,2], [3,4,5].....
  • 每个数字表示一个Item

其他

UICollectionLayoutListConfiguration.Appearance

orthogonalScrollingBehavior

orthogonal(发音:/ôrˈTHäɡən(ə)l/):正交。但并非数学上的概念,而是指,与指定方向是正交方向的另一个方向。说白了,如果制定的滚动方向是垂直,则orthogonalScrolling(正交滚动方向)就是水平方向

问题

1. 如何横向滚动

Demo中都是纵向滚动的,Compositional layout是否支持横向滚动?

当然,如下所示,不过要注意一下写法

  • 自定义UICollectionViewCompositionalLayoutConfiguration,设置scrollDirection即可

如果尝试修改因UICollectionViewCompositionalLayout(section: section)而自动创建的UICollectionViewCompositionalLayoutConfiguration.scrollDirection可能不起作用

let configuration = UICollectionViewCompositionalLayoutConfiguration()
configuration.scrollDirection = .horizontal
let layout = UICollectionViewCompositionalLayout(section: section, configuration: configuration)

1. iOS 26.3中带count参数的Group初始化方法有bug

  • 通过horizontal(layoutSize:repeatingSubitem:count:)创建的group,无法做到按照count对item等分布局
  • 但通过horizontal(layoutSize:subitems:)或者已经废弃的horizontal(layoutSize:subitem:count:)可以正确实现

按照如下代码中所示的:

let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3)

2. 官方Demo-AdaptiveSections的bug

  • 官方Demo中,AdaptiveSections部分,会根据容器宽度决定一行显示的列数
  • 但在iOS 26.3中测试,旋转到横屏后,列数并没有按照预期增加
  • 未定位到原因

参考

iOS动画浅谈

作者 UTF_8
2026年3月20日 14:51
  1. 从什么开始?

当我开始写这个文档的时候,我就想如何开始这个话题。因为是团队内分享,还是有必要简单介绍一下iOS开发框架内的一些前置知识,或许能让不同背景的同学也能够理解一些基本概念。

客户端开发工程师日常做的大部分工作都是在根据设计的UI稿来写代码完成特定的需求,如果类比成画家的话,你写的代码和Apple提供的基础框架,就是画家手中的笔和颜料。

从最简单的开始,当你要在屏幕画出一个红色背景的矩形。Apple提供了一些最基本的元素的类来做这个事情,它就是 Class UIView,Apple官方对它的描述如下:

UIView
An object that manages the content for a rectangular area on the screen.

那么你可以这样实现创建一个View,创建成功之后,就可以把它添加到你的UI层级种。

UIView *customView = [[UIView alloc] init];
customView.frame = CGRectMake(50, 200, 200, 200);
customView.backgroundColor = UIColor.redColor;

在手机屏幕上的显示如下:

在App界面上,定位一个UI元素需要它的 positionsize,也就是在当前基于点的坐标系下的矩形左上角的点的位置,宽和高的大小。

往下看一点

在上面我们看到使用 UIView 这个 AppKit 框架提供的 class,我们就可以画出一个红色背景的矩形。但其实在底层iOS使用的是 Core Animation 这个框架来实现的,其架构示意图如下:

Core Animation 是 iOS 和 macOS 平台上的图形渲染和动画基础架构,可用于为应用的视图和其他视觉元素添加动画效果。Core Animation 会自动完成绘制动画每一帧所需的绝大部分工作。

Core Animation

Core Animation 提供了一个通用的系统,用于为应用程序中的视图和其他视觉元素添加动画效果。这个功能是由 Class CALayer实现的,Apple官方对它的描述如下:

Class
CALayer
An object that manages image-based content and allows you to perform animations on that content.

An object that manages image-based content and allows you to perform animations on that content. (a layer captures the content your app provides and caches it in a bitmap)

CALayer 和 UIView 之间的关系

在 iOS 中,每个UIView都由一个对应的CALayer对象支持,视图只是图层对象的一个简单封装,因此对图层进行的任何操作通常都能正常工作。但在 macOS 中,您必须决定哪些UIView应该使用CALayer。如果是代码表示的话,可以理解为 Class UIView 有一个类型为 CALayer 的属性。

class UIView {
    CALayer *layer;
}

如果是使用了CALayer支持的View,则称为 layer-backed viewlayer-backed view 则由系统负责创建底层CALayer对象,并保持该CALayer与UIView同步。所有 iOS 视图都是图层支持视图,OS X 中的大多数视图也是如此。在 Mac App 开发中,如果需要使用 layer 作为 backen store,需要做如下设置。

NSView *customView = [[NSView alloc] init];
[customView setWantsLayer:YES];

既然CALayer可以绘制内容,为什么还需要UIView呢。图层不处理事件、不绘制内容、不参与响应链,所以在我们的应用仍然需要一个或多个视图来处理这些类型的交互。

一颗树

在我们的app,每一个View都有其superView 和 subViews(如果有的话)。这样就构成了app的视图层树(view tree)。

class UIView {
    CALayer *layer;
    UIView *superView;
    NSArray<UIView *> subViews;
}

上面我们说到,在iOS上面,每一个UIView有其底层的Layer,所以实际上是由对应的图层树(layer tree)。

三棵树

实际上在使用 Core Animation 的界面中,有三组不同的 Layer Tree。每组图层对象在使应用程序内容显示在屏幕上方面都扮演着不同的角色。分别为:

  • model layer tree :用于存储所有动画的目标值。每当您更改图层的属性时,都会用到这些对象。
  • presentation layer tree:表示当前动画 layer 的实时状态。
  • render tree:用户真正执行动画,并且是私有的。

只有动画在播放时,才能够访问到 presentation tree 中的layer对象,presenter tree 中的layer对象表示的是动画的实时值。这和 model layer tree 上的不同,它上面的对象反应的是代码设置的最后一个值。

从上面我们可以大概勾勒出 Class CALayer的定义:

Class Layer {
    // public
    // Returns a copy of the presentation layer object that 
    // represents the state of the layer as it currently appears onscreen.
    - (instancetype) presentationLayer;
    
    // public
    // Returns the model layer object associated with the receiver, if any.
    - (instancetype) modelLayer;
    
    // private
    - (instance) renderLayer;
}

如下代码展示了当你设置 view 的属性时,其实此时设置的是 model layer 对象的值。此时看如下代码的打印结果:

UIView *customView = [[UIView alloc] init];
customView.frame = CGRectMake(20, 20, 20, 20);

NSLog(@"---------- custom.layer ----------");
    NSLog(@"customView.layer address = %p", customView.layer);
    NSLog(@"customView.layer.frame = %@", @(customView.layer.frame));
    
    NSLog(@"---------- custom.layer.modelLayer ----------");
    NSLog(@"customView.layer.modulLaye.address = %p", customView.layer.modelLayer);
    NSLog(@"customView.layer.modelLayer.frame = %@", @(customView.layer.modelLayer.frame));
    
    NSLog(@"---------- customView.layer.presentationLayer ----------");
    NSLog(@"customView.layer.presentationLayer.address = %p", customView.layer.presentationLayer);
    NSLog(@"customView.layer.presentationLayer.frame = %@", @(customView.layer.presentationLayer.frame));

从上面的打印结果可以看出:

  • 当你设置 view 的 frame 时,实际上就是设置了backed store 的 layer 的属性。
  • modelLayer 返回的对象就是 layer 本身。
  • presentationLayer 为nil,当没有动画被添加到 layer 时,就不会创建它。
  1. 动画

一个简单的动画

Apple 提供了框架能够使得开发人员方便的执行动画。首先介绍一下使用 CABaseAnimation 来创建一个简单的位移动画,并且观察一下 presentationLayer 的状态值。

代码如下:

  • 创建一个动画
- (CABasicAnimation *)basicAnimation {
    CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"position.x"];
       
       // 设置动画属性
    animation.fromValue = @(25.0f);      // 起始值
    animation.toValue = @(300.0f);        // 结束值
    animation.duration = 5.0f;          // 持续时间5秒
    animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
       
    // 保持动画结束后的状态
    animation.fillMode = kCAFillModeForwards;
    animation.removedOnCompletion = NO;
       
    return animation;
}
  • 创建一个 view,并添加位移动画。
// 创建一个view,设置其frame
 self.customView = [[UIView alloc] init];
 self.customView.frame = CGRectMake(0, 400, 50, 50);
 CABasicAnimation *animation = [self basicAnimation];
 animation.delegate = self;
 [self.customView.layer addAnimation:animation forKey:@"animation"];
  • 创建定时器,打印出状态信息
- (void)startTimer {
    // 创建定时器,每秒打印一次View的值
    self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0f
                                                  target:self
                                                selector: @selector(printViewInfo)
                                                userInfo:nil
                                                 repeats:YES];
}

- (void)printViewInfo {
    NSLog(@"---------- 第%@次开始打印 ----------", @(self.printCount + 1).stringValue);
    NSLog(@"---------- custom.layer ----------");
    NSLog(@"customView.layer address = %p", self.customView.layer);
    NSLog(@"customView.layer.frame = %@", @(self.customView.layer.frame));
    
    NSLog(@"---------- custom.layer.modelLayer ----------");
    NSLog(@"customView.layer.modulLaye.address = %p", self.customView.layer.modelLayer);
    NSLog(@"customView.layer.modelLayer.frame = %@", @(self.customView.layer.modelLayer.frame));
    
    NSLog(@"---------- customView.layer.presentationLayer ----------");
    NSLog(@"customView.layer.presentationLayer.address = %p", self.customView.layer.presentationLayer);
    NSLog(@"customView.layer.presentationLayer.frame = %@", @(self.customView.layer.presentationLayer.frame));
    
    NSLog(@"---------- 第%@次结束打印 ----------", @(self.printCount + 1).stringValue);
    NSLog(@"----------------------------------");
    self.printCount += 1;
}

一个简单的位移动画如下:

modelLayer 和 presentationLayer 的打印结果如下:

中间状态 结束状态
å

从上面可以看出,动画过程中,presentationLayer 的状态就是动画展示的值。因为代码中没有重新设置modelLayer 的状态,所以frame仍然是初始状态。

model layer 还是 presentation layer

一个动画可以分为三个状态,开始,激活和结束。

A表示添加动画到layer (可以设置动画延后执行的时长)

B表示动画真正开始执行

C表示动画执行完成

暂时无法在飞书文档外展示此内容

当使用 CABaseAnimation 设置不同的参数,决定了动画开始和结束画面呈现使用的是 model layer 还是 presenta layer。也就是 fillModelremovedOnCompletion 字段。

重新回到设置 animation 的地方

- (CABasicAnimation *)basicAnimation {
    CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"position.x"];
       
       // 设置动画属性
    animation.fromValue = @(25.0f);      // 起始值
    animation.toValue = @(300.0f);        // 结束值
    animation.duration = 5.0f;          // 持续时间5秒
    animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
    
    // 动画执行的开始时间
    animation.beginTime = CACurrentMediaTime() + 3;
    // 保持动画结束后的状态
    animation.fillMode = kCAFillModeForwards;
    animation.removedOnCompletion = NO;
       
    return animation;
}

参数不同,影响的是A,B,C三个点使用的 layer,以及layer的状态值

当你没有 removeOnCompletion = NO 时(默认为YES),动画结束后的都会恢复到 model layer 值的状态。

动画效果是,开始状态为在屏幕左边缘,动画是从屏幕中间一段位置的x方向上的位移。动画延后3s执行,执行时长为 5 s。

image.png

如果你是Apple开发人员

基于 Core Animation 绘制内容

如何在屏幕上展示出内容,Core Animation 基于客户端开发人员写出的代码,来计算出当前页面layer的状态,最后由硬件处理,渲染在屏幕上。再回到下面这张图:

界面上的UI元素可以看作是Layer Tree,当你要获取Layer Tree所有结点的状态,需要遍历Layer Tree。

- (void)traverseLayer:(CALayer *)root {
    handleLayer(root);
    for (CALayer *subLayer in root.subLayers) {
        handleLayer(subLayer);
        traverseLayer(subLayer);
    }
}

- (void)handleLayer:(CALayer *)layer {
    for (CABaseAnimation *animation in layer.allAnimations) {
        handleLayer(layer, animation);
    }
}

- (void)handleLayer(CALayer *)layer withAnimation:(CABaseAnimation *)animation {
    if (动画未开始执行) {
            根据 fillMode,设置 presentation layer 状态。
        } else if (动画执行中) {
            // 根据 animation keyPath 更新 presentation layer 状态
            // 假设是 position.x,位移动画。
            // 当前动画执行的时间t,线性变换
            rate = (animation.toValue - animation.fromValu) / 动画设定的执行时间;
            x = rate * t + animation.fromValue;
            layer.frame.origin.x = x;
        } else if (动画执行完毕) {
            根据 fillMode,设置 presentation layer 状态。
        } else {
            // 未知状态
        }
}

3. # 优化一点点

history

之前做过一个 PK 动画,如下:

可以看出上面的动画左右两边的组件有一种“突变”的效果。后面又看了下其他app做的PK动画。

具体实现代码是如下:

根据双方投票PK人数,计算出占总人数的比例。

然后根据比例画出对应的图形,使用 UIBezierPath,是iOS 中用于绘制 2D 矢量图形的核心类。

 UIBezierPath *path = [self getPathWithPercent:percent];
 CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"path"];
 animation.fromValue = ( __bridge id)layer.path;
 animation.toValue = ( __bridge id)path.CGPath;
 animation.duration = 0.3;
 animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
 [layer addAnimation:animation forKey:@"animation"];

在我们创建的动画中,keyPath 是 “path”。表示执行的动画可以从一个图形变换到另外一个图形,也就是我们上面看到的PK动效。

但是这种方式是不可控的,比如这篇文章提到使用 path 作为 CABaseAnimation 的 keyPath,会达不到预期的效果。

// Core Animation 内部大致实现:
void displayLinkCallback() {
    // 1. 计算时间进度 (0.0 ~ 1.0)
    CGFloat progress = (currentTime - startTime) / duration;
    
    // 2. 应用时间函数(timingFunction)
    progress = timingFunction(progress);
    
    // 3. 插值计算
    // 这里的插值运算没法精准的计算出当前的 UIBezierPath path 的值
    id currentValue = interpolate(fromValue, toValue, progress);
    
    // 4. 更新表现层
    [presentationLayer setValue:currentValue forKeyPath:keyPath];
    
    // 5. 触发重绘
    [presentationLayer setNeedsDisplay];
}

now

既然使用 path 做动画达不到预期的效果。

可以重写 - (void)drawInContext:(CGContextRef)ctx方法,来自定义Layer的内容。

自定义 CustomPKLayer

 @interface CustomPKLayer : CALayer

@property (nonatomic , assign) CGFloat progress;

@end
 @implementation CustomPKLayer

// 让 Core Animation 的属性动画系统来管理这个属性
@dynamic progress;

// 用于指定哪些键值改变时需要自动重绘视图
// 更改属性值的动画也会触发重新显示
+ (BOOL)needsDisplayForKey:(NSString *)key {
    // 当对某个keyPath,这里使用 progress
    if ([key isEqualToString:@"progress"]) {
        return YES;
    }

    return [super needsDisplayForKey:key];
}

// 自定义图层的内容绘制
- (void)drawInContext:(CGContextRef)ctx {
    CGFloat rate = self.progress;
    CGFloat width = rate == 1 ? 329 : 329 * rate;
    CGFloat height = self.frame.size.height;
    CGFloat radius = 18;
    UIBezierPath *path = [UIBezierPath bezierPath];
    [path addArcWithCenter:CGPointMake(18, 18) radius:radius startAngle:M_PI_2 endAngle:3 * M_PI_2 clockwise:YES];
    
    if (rate == 1) {
        [path addLineToPoint:CGPointMake(width - 18, 0)];
        [path addArcWithCenter:CGPointMake(width - 18, 18) radius:radius startAngle:-M_PI_2 endAngle:M_PI_2 clockwise:YES];
        [path addLineToPoint:CGPointMake(18, height)];
    } else {
        [path addLineToPoint:CGPointMake(width - 3, 0)];
        [path addQuadCurveToPoint:CGPointMake(width - 1.5, 3) controlPoint:CGPointMake(width , 0)];
        [path addLineToPoint:CGPointMake(width - 15, height - 2)];
        [path addQuadCurveToPoint:CGPointMake(width - 19, height) controlPoint:CGPointMake(width - 16, height)];
        [path addLineToPoint:CGPointMake(18, 36)];
    }
    CGContextAddPath(ctx, path.CGPath);
    CGContextStrokePath(ctx);
    CGContextSetFillColorWithColor(ctx, [UIColor redColor].CGColor);
    
    NSDate *now = [NSDate date];
    NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
    [formatter setDateFormat:@"yyyy-MM-dd HH:mm:ss"];
    NSString *formattedDate = [formatter stringFromDate:now];
    
    NSLog(@"---------- drawInContex address ----------");
    NSLog(@"[%@]: call drawInContext modelLayer address = %p", formattedDate, self.modelLayer);
    NSLog(@"[%@]: call drawInContext presentationLayer address = %p", formattedDate, self.presentationLayer);
    NSLog(@"---------- drawInContex address ----------");
}

执行动画:

CABasicAnimation *leftAnimation = [CABasicAnimation animationWithKeyPath:@"progress"];
leftAnimation.fromValue = @(0.5f);
leftAnimation.toValue = @(0.7f);
leftAnimation.duration = 3;
leftAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
leftAnimation.removedOnCompletion = NO;
leftAnimation.fillMode = kCAFillModeForwards;
    
[self.leftMaskLayer addAnimation:leftAnimation forKey:@"leftProgress"];
    
CABasicAnimation *rightAnimation = [CABasicAnimation animationWithKeyPath:@"progress"];
rightAnimation.fromValue = @(0.5f);
rightAnimation.toValue = @(0.3f);
rightAnimation.duration = 3;
rightAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
rightAnimation.removedOnCompletion = NO;
rightAnimation.fillMode = kCAFillModeForwards;
    
[self.rightMaskLayer addAnimation:rightAnimation forKey:@"rightProgress"];

这样执行动画,就能精准控制动画的执行,并且画出自定义的Path。这里时间有限,仅仅是测试了一个能正确展示PK动效的路径图形。

打印一些信息

前面说到我们重写了 - (void)drawInContext:(CGContextRef)ctx,在这个方法中,动画结束之后,在其中打印地址是不是可以判断当前是用 presentationLayer 还是 modelLayer 来绘制内容。

动画结束后打印:

- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag {
    if (anim && flag) {        
        NSDate *now = [NSDate date];
        NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
        [formatter setDateFormat:@"yyyy-MM-dd HH:mm:ss"];
        NSString *formattedDate = [formatter stringFromDate:now];
        
        NSLog(@"---------- animation did stop ----------");
        NSLog(@"[%@]: leftMasklayer.modelLayer address = %p", formattedDate, self.leftMaskLayer.modelLayer);
        NSLog(@"[%@]: leftMasklayer.presentationLayer address = %p", formattedDate, self.leftMaskLayer.presentationLayer);
        NSLog(@"---------- animation did stop ----------");
    }
}

动画结束后:- (void)drawInContext:(CGContextRef)ctx 打印

- (void)drawInContext:(CGContextRef)ctx {
    // 省去绘制代码
    
    NSDate *now = [NSDate date];
    NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
    [formatter setDateFormat:@"yyyy-MM-dd HH:mm:ss"];
    NSString *formattedDate = [formatter stringFromDate:now];
    
    NSLog(@"---------- drawInContex address ----------");
    NSLog(@"[%@]: call drawInContext modelLayer address = %p", formattedDate, self.modelLayer);
    NSLog(@"[%@]: call drawInContext presentationLayer address = %p", formattedDate, self.presentationLayer);
    NSLog(@"---------- drawInContex address ----------");
}

animation 的设置不同

animation.removedOnCompletion
animation.fillMode
removedOnCompletion 打印结果
NO
YES
  1. 总结

  • 当没有动画添加到 layer 时,对应的 presentationLayer 不会被创建。
  • 当设置 layer (modelLayer)的相关属性时,如果 presentationLayer 不会空,则其值保持和 modelLayer 一致。
  • 设置的动画参数,决定了动画开始执行前,动画执行中,动画执行完成后,使用的是 presentationLayer 还是 modelLayer。
  • 可以通过自定义 animation keyPath,来绘制 layer 的内容,从而实现更加精准的动画。
  1. 参考

  1. Loading动画外篇:圆的不规则变形
  2. Core Animation Programming Guide

穿透内容审查与阻断:基于 DNS TXT 记录的动态服务发现与客户端安全加固实践

作者 eleven4096
2026年3月20日 13:55

✍️ 引言

在开发面向全球或特定复杂网络环境的 App(如 XXX、跨境电商、海外加速等)时,最大的痛点往往不是业务逻辑,而是服务端的生存能力。为了对抗域名污染 (DNS Poisoning)SNI 阻断 以及 证书审查,我们通常需要一套极其灵活的「备用链路」与「动态发现」机制。

本文将结合在 iOS/Swift 项目中的实际落地经验,深度剖析一套基于 DNS TXT 记录 派发动态入口域名双向 mTLS 证书(p12)基码 以及 原生 TCP 直连 IP 的高可用架构,并详解其间的技术难点与避坑指南。


🛠 一、 核心架构设计

我们的目标是:哪怕主 Base 域名完全死锁,客户端只要能向公用 DNS 发一个查询,就能满血复活。

1. 数据如何藏在 DNS TXT 里?

由于一台域名的 A记录 只能存 IP,且极其容易被封锁,我们选择将配置加密后塞入 DNS 的 TXT 记录。 我们使用了多级子域名来承载不同的模块(由于 TXT 字符长度限制,需要分片):

子域名 (Subdomain) 承载内容特征 安全措施
root.yourbase.com 加密后的后备 HTTPS 业务 API 域名列表 AES-128-ECB 加密 + Base64
1.yourbase.com mTLS 客户端证书 P12 文件的 Base64 前半段 纯文本分片拼装
2.yourbase.com mTLS 客户端证书 P12 文件的 Base64 后半段 纯文本分片拼装
ip.yourbase.com 绕过 SNI 审查的裸 TCP 直连 IP 点对点通道 纯文本

🧠 二、 技术难点与避坑指南

难点 1:iOS 系统 API 无法直接发起原生 UDP DNS 查询

🚨 问题背景:  iOS 的 getaddrinfo 或者 NWHostResolver 是高层级 API,它们往往只返回处理好的 IP 地址(A/AAAA 记录),极难直接读取到 TXT、SRV 记录。如果调用系统的 res_nquery(属于 C 层的 libresolv),在弱网下容易造成线程死锁,且容易触发 iOS 严格的后台审计。

💡 解决方案:使用 Network 框架手工构建 UDP 53 端口查询 我们在 Swift 中封装了一个 DNSResolver,通过 NWConnection(to: 53, using: .udp) 手工下发标准 DNS 报文(RFC 1035)

  1. 构造 DNS 查询帧

    swift
    var data = Data()
    let id = UInt16.random(in: 1...65535)
    data.append(contentsOf: id.bigEndianBytes)
    data.append(contentsOf: UInt16(0x0100).bigEndianBytes) // Flags: 标准查询
    data.append(contentsOf: UInt16(1).bigEndianBytes)      // Question 数量 1
    // ... 拼接子域名 QNAME、QTYPE 为 16 (TXT)
    
  2. 并发查询优化: 由于国内 DNS 偶尔会有运营商后门或缓存污染,我们使用 withTaskGroup 并发地向四个公共 DNS 服务器发送请求 (223.5.5.5114.114.114.1148.8.8.81.1.1.1),谁最快返回合法的 TXT 内容,就直接 cancelAll() 结束任务


难点 2:UDP 的截断陷阱 (Truncated) 与 TCP 回退

🚨 问题背景:  由于拼装了庞大的客户端 p12 证书 Base64 字符串,TXT 记录往往会合在一起超过 512 字节。 在标准的 DNS UDP 查询中,如果响应超过 512 字节,包头部的 TC (Truncated) 标志位会被置为 1,代表数据被截断。

💡 解决方案:标志位侦测与 TCP Fallback 我们在 UDP 接收处做了一层守卫:

swift
if (data[2] & 0x02) != 0 {  // TC Flag is set!
    // UDP 遭遇截断,降级使用 TCP 53 端口进行可靠全量查询
    return await queryTCP(domain: domain, server: server)
}

进入 queryTCP 时,会在帧最前面补上 2 字节的大端序长度头,直接利用 NWConnection.tcp 握手拿到绝对完整的几千字节 TXT 加密串,完美解决大文件丢失问题。


难点 3:防劫持的 “端到端解密” 校验

🚨 问题背景:  如果中间人(Mitm)故意把你的 TXT 记录篡改成钓鱼网站或错误信息,即便配置下发了,APP 也会崩溃或中招。

💡 解决方案:AES + TCP 握手活性测试

  1. 对称加密:对 root 的分流域名进行 AES-128-ECB 加密。中间人即使拿到了,没有客户端的硬编码 Key 也无法篡改。

  2. TCP 通信握手探测活性: 在真正切换配置前,Manager 会多跑一遍 tcpTest。由于有些域名可能已经“挂了”,客户端会在后台静默并发跑:

    swift
    let connection = NWConnection(to: host, using: .tcp)
    connection.stateUpdateHandler = { state in
        if state == .ready { finish(true) } // 代表服务器可通达,不是死域名
    }
    

难点 4:动态 mTLS 证书灌入 (Security Manager)

经过 AES 解密和两片 TXT (1.txt + 2.txt) 拼装后,我们得到了完整的证书 Base64 编码。 我们要实实现本地无感知实例化,不需要把证书文件落地写死到沙盒里(防止反编译静态检查):

  1. 直接在内存中将组合好的 Base64 数据转为 Data
  2. 使用 SecPKCS12Import 函数,并将空密码(或者约定的暗号)传入,从内存里动态吐出 SecIdentity 和关联的 SecCertificate
  3. 把 Identity 灌入全局 SessionDelegate。当走 HTTPS 握手时,若触发 .clientCertificate 的 URLAuthenticationChallenge,直接从 cache 提取该 Identity 给系统使用。

难点 5:SNI 阻断应急方案 —— 18字节头部纯裸 TCP 定制通道

对于国内在极限阻断(如 SNI 嗅探)下的特殊业务,HTTPS 甚至会被阻断。我们追加了 ip.yourbase.com 提取裸 IP:

  • 业务无感降级:当 HTTPS 全灭,NetworkChannelManager 自动引导流量降级到我们自己用原生 NWConnection 敲出来的裸 TCP 直连。
  • 自定义封包协议:由于对端没有 TLS 证书做阻断,我们在应用层通过自研非对称二进制报头([18字节头部][Path][Hdr][Body] 及 响应 14字节头部)在服务端和客户端穿梭自如,极大增强了业务的可达率骨干。

📈 三、 业务安全成效

通过这套机制的上线,我们成功做到了:

  1. 云端无感知脱壳切换:后台可随意增减高防域名、甚至随时全量更替 TLS 的客户端校验私钥,对老版本客户端保持完美兼容。
  2. 零阻断时长:冷启动到成功跑通业务 HTTPS 的时间通过 TaskGroup 的竞赛机制下降到了 平均 0.3 秒以内。

💡 总结

服务高防链路的最佳伴侣不是冗余服务器,而是灵活、弹性的 发现机制。 利用 DNS 53 这个处于网络信任基座的协议,将 分片加密数据 优雅地回传至 iOS 客户端并发解码,不仅安全可靠,更筑起了一道无法轻易折断的强硬长廊。


提示:  在使用 114 / 223 等大陆 DNS 查询时,注意频率控制以心跳避免被运营商拉入恶意解析黑名单。对于更深层的防污染,甚至可搭配 DNS over HTTPS (DoH) 来取代 53 端口查询。

# Flutter 语音房礼物下载方案(完整版)

作者 忆江南
2026年3月20日 13:40

Flutter 语音房礼物下载方案(完整版)

场景:语音房礼物资源下载,文件类型为 mp4(~10MB)和 webp(~1MB)
核心能力:网络自适应 · 多文件并行 · 单文件分片 · 断点续传 · 智能调度


目录


一、整体架构

┌──────────────────────────────────────────────────────────────┐
│                        礼物业务层                              │
│              (礼物列表展示、播放渲染、用户触发)                    │
├──────────────────────────────────────────────────────────────┤
│                       下载调度引擎                              │
│  ┌────────────┐  ┌────────────┐  ┌──────────────────────┐   │
│  │  网络探测器  │  │  优先级队列  │  │  并发度/分片策略控制   │   │
│  └────────────┘  └────────────┘  └──────────────────────┘   │
├──────────────────────────────────────────────────────────────┤
│                       分片下载层                                │
│  ┌────────────┐  ┌────────────┐  ┌──────────────────────┐   │
│  │  分片管理器  │  │  断点续传    │  │ 分片合并(Isolate)+校验│   │
│  └────────────┘  └────────────┘  └──────────────────────┘   │
├──────────────────────────────────────────────────────────────┤
│                     网络优化层(第十章)                         │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐  │
│  │ HTTPDNS  │ │ HTTP/2   │ │ 连接预热  │ │ TLS Session   │  │
│  │ + 预解析  │ │ 多路复用  │ │ TCP预连接 │ │ 复用 + 1.3    │  │
│  └──────────┘ └──────────┘ └──────────┘ └───────────────┘  │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐  │
│  │ 弱网自适应│ │ Dio 专用  │ │ 流式传输  │ │ 自适应超时     │  │
│  │ + 降级   │ │ 实例+拦截 │ │ Stream   │ │ + 速率检测    │  │
│  └──────────┘ └──────────┘ └──────────┘ └───────────────┘  │
├──────────────────────────────────────────────────────────────┤
│                        传输层                                  │
│  ┌──────────────────────┐  ┌─────────────────────────────┐  │
│  │ HTTP Range 请求管理    │  │ CDN 签名 URL 管理 + 刷新     │  │
│  └──────────────────────┘  └─────────────────────────────┘  │
├──────────────────────────────────────────────────────────────┤
│                        存储层                                  │
│  ┌────────────┐  ┌─────────────┐  ┌────────────────────┐    │
│  │ 完成文件缓存 │  │ 元数据 SQLite │  │  临时分片文件       │    │
│  └────────────┘  └─────────────┘  └────────────────────┘    │
└──────────────────────────────────────────────────────────────┘

数据流

用户触发送礼 / 预加载触发
        ↓
检查本地缓存是否已有文件 ── 命中 → 直接使用
        ↓ 未命中
检查是否有未完成的分片 ── 有 → 断点续传流程
        ↓ 无
探测网络质量 → 决定并发参数
        ↓
进入优先级队列 → 调度引擎分配连接
        ↓
HEAD 请求获取文件信息(大小/ETag/是否支持Range)
        ↓
计算分片方案 → 多分片并行下载
        ↓
所有分片完成 → 合并 → 校验 MD5 → 存入缓存目录
        ↓
通知业务层 → 播放/渲染礼物

二、网络质量探测

2.1 探测维度

指标 采集方式 作用
带宽估算 用一个小文件(~50KB 探测文件)计算实际下载速率 决定并发数和分片大小
RTT 延迟 每次 HTTP 请求的首字节时间(TTFB) 延迟高时减少分片并发数(每个分片都有握手开销)
网络类型 Connectivity 插件获取 WiFi / 5G / 4G / 3G 粗粒度初始策略
丢包率/抖动 连续多次小请求的成功率和耗时方差 判断网络稳定性

2.2 网络质量分级

等级 判定条件(参考值) 标签
优秀 带宽 > 5MB/s,RTT < 50ms WiFi / 5G 稳定
良好 带宽 2-5MB/s,RTT 50-150ms WiFi / 4G 正常
一般 带宽 500KB-2MB/s,RTT 150-300ms 4G 弱信号
带宽 < 500KB/s,RTT > 300ms 3G / 弱网

2.3 探测时机

时机 方式 说明
进入语音房前 主动探测 冷启动做一次完整探测
下载过程中 搭便车采样 取最近 5 个分片的平均速率做滑动窗口,实时修正参数
网络切换时 被动触发 WiFi ↔ 蜂窝切换后立即重新探测

核心原则:不要频繁主动探测(浪费流量),主要依赖"搭便车"——从实际分片下载行为中采集真实速率。


三、下载调度引擎

3.1 两层并发模型

第一层:文件级并发 —— 同时下载几个文件
   ├── 文件 A (mp4, 10MB)
   │     └── 第二层:分片并发 —— 这个文件分几片同时下
   │           ├── chunk 0  [0, 2MB)   │           ├── chunk 1  [2MB, 4MB)   │           ├── chunk 2  [4MB, 6MB)   │           ├── chunk 3  [6MB, 8MB)   │           └── chunk 4  [8MB, 10MB)   ├── 文件 B (webp, 1MB)   │     ├── chunk 0  [0, 512KB)   │     └── chunk 1  [512KB, 1MB)   └── 文件 C (mp4, 8MB) → 等待调度...

3.2 参数根据网络质量动态调整

网络等级 文件并发数 单文件分片并发数 分片大小 总连接数上限
优秀 3-4 4-5 2MB 16
良好 2-3 3-4 1MB 10
一般 1-2 2-3 512KB 6
1 1-2 256KB 3

总连接数上限的意义:所有文件的分片并发数总和不超过此值。防止在弱网下开太多连接反而互相抢带宽。

3.3 分片大小的取舍

分片过小(< 256KB) 分片过大(> 4MB)
HTTP 头部 + TCP 握手开销占比过高 单片失败时重试成本高
请求次数太多 弱网下容易超时
频繁的 DB 状态更新 断点续传粒度太粗

计算公式

chunkSize = clamp(估算带宽 × 目标单片下载时间, 256KB, 4MB)

目标单片下载时间 = 3-5 秒(平衡响应性和效率)

举例:
- 带宽 4MB/s → 4MB/s × 4s = 16MB → clamp → 4MB
- 带宽 1MB/s → 1MB/s × 4s = 4MB → clamp → 4MB
- 带宽 200KB/s → 200KB/s × 4s = 800KB → clamp → 800KB → 取 512KB 对齐

3.4 优先级调度

优先级权重公式
W = α × 紧急度 + β × (1 / 文件大小) + γ × 热度 + δ × 已完成比例

α=0.5  β=0.15  γ=0.15  δ=0.2
因子 含义 设计目的
紧急度 用户正在触发 = 1.0,预加载 = 0.2 用户触发的礼物必须最快展示
1/文件大小 webp(1MB) 得分高于 mp4(10MB) 小文件优先完成,用户更快看到效果
热度 房间内高频赠送的礼物得分高 高概率被用到的优先
已完成比例 已下载 90% 的文件得分高 避免所有文件都半成品,优先收尾
抢占机制
  • 用户触发的礼物直接置顶,权重设为最大
  • 可以借用低优先级文件的分片连接数
  • 被抢占的文件暂停排队,不丢失已下载进度

3.5 带宽分配策略

不是简单平分带宽,而是通过控制分片并发数间接分配:

文件类型 分配策略 实现方式
用户正在触发的礼物 60-70% 带宽 分配 4 个分片并发
预加载礼物 30-40% 带宽 限制 1-2 个分片并发
网络变差时 全部让给紧急文件 暂停所有预加载

四、分片下载

4.1 前提:CDN 是否支持 Range 请求

断点续传和分片下载的基础是 HTTP Range 请求。主流 CDN 全部支持:

CDN 厂商 支持 Range 默认开启
阿里云 CDN 支持
腾讯云 CDN 支持
AWS CloudFront 支持
Cloudflare 支持
七牛云 支持

验证方法

# 1. 确认是否支持 Range
curl -I https://your-cdn.com/gift/001.mp4
# 响应头包含 Accept-Ranges: bytes → 支持

# 2. 实际请求一个范围
curl -H "Range: bytes=0-1023" -o /dev/null -w "%{http_code}" https://your-cdn.com/gift/001.mp4
# 返回 206 → 支持
# 返回 200 → 不支持(忽略了 Range)

必须满足的完整链路

Flutter 客户端 ──Range 请求──→ CDN 节点 ──→ 源站(OSS/S3/Nginx)
     ↑                          ↑                  ↑
  你的代码                    全部支持            这里也必须支持

三个环节任意一个不支持 Range,分片下载就退化为整文件单连接下载。

4.2 分片下载完整流程

┌─ 1. HEAD 请求 ─────────────────────────────────────────────────┐
│  GET https://cdn.xxx.com/gift/001.mp4                          │
│  → 响应:                                                      │
│    Content-Length: 10485760  (文件大小 10MB)                     │
│    Accept-Ranges: bytes     (支持分片)                          │
│    ETag: "a1b2c3d4e5"       (文件版本标识)                      │
│    Content-Type: video/mp4                                     │
└────────────────────────────────────────────────────────────────┘
                              ↓
┌─ 2. 判断是否需要分片 ──────────────────────────────────────────┐
│  文件 < 1MB → 不分片,单连接下载                                 │
│  文件 >= 1MB 且支持 Range → 按策略分片                           │
│  不支持 Range → 退化为单连接整文件下载                            │
└────────────────────────────────────────────────────────────────┘
                              ↓
┌─ 3. 计算分片方案 ──────────────────────────────────────────────┐
│  示例:10MB 文件,网络良好,分片大小 2MB                          │
│                                                                │
│  chunk 0: Range: bytes=0-2097151        (0~2MB)                │
│  chunk 1: Range: bytes=2097152-4194303  (2~4MB)                │
│  chunk 2: Range: bytes=4194304-6291455  (4~6MB)                │
│  chunk 3: Range: bytes=6291456-8388607  (6~8MB)                │
│  chunk 4: Range: bytes=8388608-10485759 (8~10MB)               │
└────────────────────────────────────────────────────────────────┘
                              ↓
┌─ 4. 并行下载分片 ──────────────────────────────────────────────┐
│                                                                │
│  [并发槽1] chunk 0 ████████████ done ✅                         │
│  [并发槽2] chunk 1 ████████░░░░ 75%                            │
│  [并发槽3] chunk 2 ██████░░░░░░ 55%                            │
│  [等待中]  chunk 3 ░░░░░░░░░░░░ pending                        │
│  [等待中]  chunk 4 ░░░░░░░░░░░░ pending                        │
│                                                                │
│  chunk 0 完成 → 并发槽1 立即启动 chunk 3                         │
│  实时记录每个分片的下载进度到 DB                                  │
└────────────────────────────────────────────────────────────────┘
                              ↓
┌─ 5. 合并分片 ──────────────────────────────────────────────────┐
│  按 chunkIndex 顺序读取临时文件 → 流式追加写入最终文件             │
│  (不是一次性全部加载进内存)                                     │
└────────────────────────────────────────────────────────────────┘
                              ↓
┌─ 6. 完整性校验 ────────────────────────────────────────────────┐
│  计算最终文件 MD5 → 与服务端提供的 hash 比对                      │
│  通过 → 删除临时分片,标记完成                                    │
│  失败 → 清理所有文件,重新下载                                    │
└────────────────────────────────────────────────────────────────┘

4.3 具体分片举例

场景 文件大小 网络 分片大小 分片数 并发数 预估耗时
mp4 + 优秀网络 10MB 5MB/s 2MB 5 4 ~2.5s
mp4 + 一般网络 10MB 1MB/s 512KB 20 2 ~10s
mp4 + 差网络 10MB 200KB/s 256KB 40 1 ~50s
webp + 优秀网络 1MB 5MB/s 不分片 1 1 ~0.2s
webp + 差网络 1MB 200KB/s 512KB 2 1 ~5s

4.4 分片状态数据模型

每个分片在 SQLite 中持久化一行记录:

字段 类型 说明
fileId String 礼物文件唯一标识
fileUrl String 下载地址(不含签名参数)
fileSize int 文件总大小(字节)
fileETag String 文件版本标识(ETag)
fileMd5 String 文件 MD5(用于最终校验)
chunkIndex int 分片序号
rangeStart int 分片起始字节
rangeEnd int 分片结束字节
downloadedBytes int 该分片已下载字节数
status enum pending / downloading / done / failed
retryCount int 已重试次数
tempFilePath String 分片临时文件路径
createdAt int 创建时间戳
updatedAt int 最后更新时间戳

五、断点续传

5.1 断点续传完整流程

App 重启 / 网络恢复
        ↓
从 SQLite 查询所有 status != done 的文件
        ↓
对每个文件执行续传检查:

┌─ 步骤 1:签名 URL 检查 ──────────────────────────────────┐
│                                                          │
│  CDN URL 通常带签名:                                      │
│  https://cdn.xxx.com/gift/001.mp4?token=abc&expire=xxx   │
│                                                          │
│  检查 expire 是否过期                                      │
│  ├── 未过期 → 继续使用                                     │
│  └── 已过期 → 调业务接口获取新的签名 URL                     │
│       (文件没变,只是签名换了,Range 请求依然有效)            │
└──────────────────────────────────────────────────────────┘
        ↓
┌─ 步骤 2:文件版本校验 ───────────────────────────────────┐
│                                                          │
│  发送 HEAD 请求,检查 ETag 是否与记录的一致                  │
│  ├── ETag 一致 → 文件没变,可以续传                        │
│  └── ETag 变了 → 文件已被更新,废弃所有分片,重新下载         │
│                                                          │
│  或者用 If-Range 头自动处理:                               │
│  请求头: If-Range: "旧ETag"                               │
│  └── 文件没变 → 服务端返回 206,续传                        │
│  └── 文件变了 → 服务端返回 200,整文件重新下载               │
└──────────────────────────────────────────────────────────┘
        ↓
┌─ 步骤 3:逐个分片恢复 ──────────────────────────────────┐
│                                                          │
│  chunk 0: status=done       → 跳过 ✅                     │
│  chunk 1: status=done       → 跳过 ✅                     │
│  chunk 2: status=downloading, downloadedBytes=800KB       │
│           → 从 rangeStart + 800KB 处继续                   │
│           → Range: bytes=4994304-6291455                  │
│  chunk 3: status=pending    → 正常下载                     │
│  chunk 4: status=failed     → 重置 retryCount,重新下载    │
└──────────────────────────────────────────────────────────┘

5.2 分片内的断点续传

不只是分片之间可以续传,每个分片内部也支持续传:

  • 每个分片的 downloadedBytes 实时更新(每接收 64KB 数据更新一次 DB,不要太频繁影响性能)
  • 分片恢复时,实际请求的 Range 起始位置 = rangeStart + downloadedBytes
  • 分片临时文件以 append 模式写入

5.3 必须处理的三个工程问题

问题一:签名 URL 过期
时间线:
  T0: 开始下载,URL 有效期 30 分钟
  T0+15min: 下载了 50%,App 切后台
  T0+40min: 用户回到 App,URL 已过期

处理:
  1. 每次续传前检查 URL 中的 expire 参数
  2. 过期 → 调用业务接口 /api/gift/url?giftId=xxx 获取新签名 URL
  3. 用新 URL + 旧的 Range 参数继续下载
  4. 注意:新旧 URL 的路径和文件必须相同,只是签名参数不同
问题二:文件版本变更
风险场景:
  T0: 下载 gift_001.mp4 前 5MB
  T1: 运营更换了 gift_001.mp4 的内容(同 URL 不同内容)
  T2: 续传后面的 5MB → 前后内容不匹配 → 文件损坏

防御:
  1. 首次 HEAD 请求时记录 ETag
  2. 续传前 HEAD 请求比对 ETag
  3. ETag 变了 → 废弃所有已下载分片 → 完全重新下载
  4. 最终的 MD5 校验作为最后防线
问题三:CDN 压缩干扰
极少出现但需要防御:
  如果 CDN 对文件启用了 gzip 压缩(响应头 Content-Encoding: gzip)
  → 压缩后的字节流无法按 Range 精确切分
  → 分片下载的数据拼接后解压失败

检测:
  HEAD 请求时检查 Content-Encoding
  如果是 gzip/br → 退化为单连接整文件下载

实际情况:
  CDN 默认不压缩 mp4/webp 等已压缩格式,只压缩 HTML/CSS/JS
  所以几乎不会遇到

六、失败重试与容错

6.1 分片级重试

策略 细节
最大重试次数 单分片 3 次
退避策略 指数退避 + 随机抖动:1s ± 0.3s → 2s ± 0.6s → 4s ± 1.2s
连接超时 10 秒
读超时 动态计算:分片大小 / 最低预期速率 × 2(最少 15 秒)
局部失败 单分片失败不影响其他分片继续下载

6.2 文件级容错

场景 处理
单分片重试 3 次仍失败 标记该分片 failed,继续下载其他分片
超过 50% 的分片失败 暂停该文件,重新探测网络,调整策略后整体重试
所有分片重试耗尽仍失败 标记文件为 failed,上报监控,移出队列
用户再次触发该礼物 重新进入队列,清理旧的失败记录,从头开始

6.3 网络中断处理

网络状态监听(Connectivity 插件)

网络断开:
  1. 暂停所有正在进行的 HTTP 请求
  2. 保留所有分片进度(已持久化在 DB 中)
  3. UI 层可展示"网络已断开,将在恢复后继续下载"

网络恢复:
  1. 等待 2 秒稳定期(避免网络抖动导致频繁重启)
  2. 重新探测网络质量 → 可能要调整并发参数
  3. 按优先级恢复下载队列
  4. 每个文件走断点续传流程(检查 URL、ETag)

网络切换(WiFi → 蜂窝):
  1. 弹窗提示"当前使用移动数据,是否继续下载?"(可配置)
  2. 用户同意 → 降低并发参数,继续下载
  3. 用户拒绝 → 暂停所有下载,等 WiFi 恢复

6.4 异常边界处理

异常 处理
磁盘空间不足 下载前检查剩余空间 ≥ 文件大小 × 1.5(分片 + 合并需要额外空间),不足则清理缓存或提示用户
下载中 App 被杀 下次启动时自动从 DB 恢复未完成的任务
服务端 5xx 错误 按重试策略处理,3 次后标记失败
服务端 403/404 不重试,直接标记失败,上报异常
MD5 校验失败 删除所有分片和合并文件,重新下载

七、存储管理

7.1 目录结构

app_sandbox/
└── gift_cache/
    ├── meta.dbSQLite 数据库
    │   ├── table: download_tasks          文件级任务信息
    │   ├── table: chunk_records           分片级记录
    │   └── table: network_stats           网络质量历史记录
    │
    ├── completed/                       ← 已完成的文件(最终使用)
    │   ├── gift_001.mp4
    │   ├── gift_002.webp
    │   ├── gift_003.mp4
    │   └── ...
    │
    └── temp/                            ← 下载中的分片临时文件
        ├── gift_004_chunk_0.tmp
        ├── gift_004_chunk_1.tmp
        ├── gift_004_chunk_2.tmp
        └── ...

7.2 缓存淘汰策略

维度 策略
总缓存上限 200MB(可通过服务端配置下发)
淘汰算法 LRU + 热度权重
保护机制 最近 24 小时内使用过的文件不淘汰
清理时机 每次新文件下载完成后检查总大小;App 启动时检查
临时文件清理 超过 24 小时未更新的分片临时文件自动清理
淘汰顺序 最久未使用 → 文件最大 → 热度最低

7.3 文件完整性保障(四层校验)

1 层(下载前):服务端接口返回文件的 MD5 和大小
                   ↓
第 2 层(下载中):每个分片验证 Content-Length 匹配
                   ↓
第 3 层(下载后):合并后整文件 MD5 校验
                   ↓
第 4 层(使用前):播放/渲染前快速校验文件头魔数
                  mp4 → 检查 ftyp box
                  webp → 检查 RIFF 头 + WEBP 标识

八、预加载策略

8.1 预加载时机

时机 行为 优先级
进入语音房 拉取房间礼物列表 → 按热度排序 → 预加载 Top N
房间空闲期 WiFi + 前台 + 无用户操作 → 后台预加载更多
礼物列表更新 服务端推送新礼物 → 差量预加载新增的
蜂窝网络 降低或完全不预加载(节省流量) 跳过

8.2 智能预加载

策略 依据
用户偏好 用户历史送礼记录 → 优先预加载常送的礼物类型
房间场景 PK 房 → 预加载 PK 礼物;生日房 → 预加载生日礼物
文件类型 webp 优先于 mp4(体积小,完成快)

8.3 预加载与按需下载的冲突处理

场景:文件 A 正在预加载(低优先级,1 个分片并发)
      ↓
用户触发了礼物 A
      ↓
处理:
  1. 不中断、不重新下载
  2. 直接提升文件 A 的优先级为最高
  3. 增加其分片并发数(从 144. 抢占其他预加载文件的连接数
  5. 已完成的分片保留,只加速未完成的部分

九、监控与埋点

9.1 核心指标

指标 计算方式 告警阈值
文件下载成功率 成功数 / 总请求数 < 95%
分片失败率 失败分片数 / 总分片数 > 5%
平均下载耗时 按网络等级分桶统计 P99 > 30s
首帧展示时间 用户触发 → 礼物开始播放 P95 > 5s
缓存命中率 命中次数 / 总请求次数 < 70%
断点续传成功率 续传成功 / 续传尝试 < 90%
MD5 校验失败率 校验失败 / 下载完成数 > 0.1%

9.2 每次下载的埋点数据

字段 说明
giftId 礼物 ID
fileType mp4 / webp
fileSize 文件大小
networkLevel 网络等级
networkType WiFi / 4G / 5G
chunkCount 分片数
concurrency 并发数
totalTime 总耗时
retryCount 总重试次数
isResumed 是否断点续传
result success / fail / cancelled
failReason 失败原因

十、Flutter 网络优化深度

本章将 Flutter 网络优化的知识体系融入礼物下载场景,覆盖从 DNS 解析到字节写入磁盘的全链路。

10.1 网络请求全链路耗时分析

一个分片下载请求从发出到数据落盘,经历的完整链路:

┌──────────────────────────────────────────────────────────────────────┐
│                        一次分片下载的耗时拆解                          │
├──────────┬──────────┬──────────┬──────────┬──────────┬──────────────┤
│ DNS 解析  │ TCP 握手  │ TLS 握手  │ 请求发送  │ 首字节等待 │ 数据传输     │
│ (TTDNS)  │ (TCP RTT) │ (TLS RTT) │          │ (TTFB)   │ (Transfer)  │
│ 50-200ms1 RTT    │ 1-2 RTT   │  <1ms10-50ms  │ 与大小成正比  │
└──────────┴──────────┴──────────┴──────────┴──────────┴──────────────┘

优化目标:尽量消除或缩短前面几个阶段,让时间集中在有效的数据传输上

关键认识:对于一个 2MB 的分片,在良好网络下传输本身只需 ~0.4s,但 DNS + TCP + TLS 握手可能就要 200-500ms。分片越小,这种"固定税"的占比越高,这也是分片不能太小的根本原因。


10.2 DNS 优化

10.2.1 问题
  • 系统 DNS 解析依赖运营商 LocalDNS,可能被劫持、污染、解析慢
  • 每次冷启动后首次请求都要等 DNS 解析
  • 不同运营商解析到不同 CDN 节点,可能不是最优节点
10.2.2 HTTPDNS
维度 传统 LocalDNS HTTPDNS
解析方式 UDP 递归查询 HTTP 直接向 DNS 服务商请求
劫持风险 高(运营商劫持) 低(HTTPS 加密)
解析精度 运营商粒度 可精确到客户端 IP
缓存控制 运营商控制 TTL 客户端可控
Flutter 方案 系统默认 阿里云/腾讯云 HTTPDNS SDK

在礼物下载中的应用

  • 进入语音房时,提前通过 HTTPDNS 解析 CDN 域名,缓存 IP
  • Dio 请求时直接用 IP + Host 头,跳过系统 DNS
  • 缓存多个 IP,主 IP 不通时自动切换备用 IP
10.2.3 DNS 预解析
时机:App 启动 / 进入语音房

预解析域名列表:
  ├── cdn.xxx.com        ← 礼物资源 CDN
  ├── api.xxx.com        ← 业务接口
  └── static.xxx.com     ← 其他静态资源

结果缓存到内存 Map<String, List<String>>:
  cdn.xxx.com → [1.2.3.4, 5.6.7.8]
  
TTL 管理:
  ├── 默认缓存 5 分钟
  ├── 解析失败时使用上次缓存结果(兜底)
  └── 网络切换时清空缓存重新解析

10.3 连接层优化

10.3.1 HTTP/2 多路复用
HTTP/1.1 下载 4 个分片:
  连接1 ──── chunk0 ────────────────────
  连接2 ──── chunk1 ────────────────────
  连接3 ──── chunk2 ────────────────────
  连接4 ──── chunk3 ────────────────────
   4  TCP 连接,4  TLS 握手

HTTP/2 下载 4 个分片:
  连接1 ──┬─ stream1: chunk0 ──────────
          ├─ stream2: chunk1 ──────────    同一条 TCP 连接
          ├─ stream3: chunk2 ──────────    复用 TLS 会话
          └─ stream4: chunk3 ──────────
   1  TCP 连接,1  TLS 握手
维度 HTTP/1.1 HTTP/2
连接数 每个分片一个连接(或连接池复用) 单连接多路复用
头部开销 每次完整发送 HPACK 压缩,增量发送
握手次数 N 次 TCP+TLS 1 次
队头阻塞 HTTP 层有 HTTP 层无(TCP 层仍有)
CDN 支持 全部 主流全部支持

在礼物下载中的收益

  • 同一个 CDN 域名的所有分片请求复用一条连接
  • 省去大量重复的 TCP + TLS 握手时间
  • 特别适合分片并发场景——不需要真的开 4 条 TCP 连接也能 4 片并行

Dio 开启 HTTP/2:使用 dio_http2_adapter 替换默认适配器,或使用 cronet_http(基于 Chromium 网络栈)。

10.3.2 连接池管理

即使使用 HTTP/1.1,也要合理管理连接池:

参数 建议值 说明
maxConnectionsPerHost 6-8 同一域名最大连接数(HTTP/1.1 场景)
idleTimeout 15 秒 空闲连接保持时间
connectionTimeout 10 秒 建立连接超时

关键点

  • 所有下载请求共享同一个 Dio 实例(共享连接池)
  • 不要每次下载 new Dio(),否则连接池无法复用
  • 连接池对象在 Isolate 间不可共享——如果用 Isolate 做下载,每个 Isolate 需要自己的 Dio 实例
10.3.3 TLS 会话复用(Session Resumption)
首次 TLS 握手:
  Client → ServerHello   ┐
  Server → Certificate   ├ 2 RTT(TLS 1.2)或 1 RTT(TLS 1.3Client → Finished      ┘

后续请求复用 Session:
  Client → SessionTicket  ┐
  Server → Finished       ┘  1 RTT(TLS 1.2)或 0 RTT(TLS 1.3
  • TLS 1.3 的 0-RTT 恢复:首次握手后客户端缓存 PSK(Pre-Shared Key),后续连接发送 Early Data,不需要等待服务端响应就开始传数据
  • Dio 底层的 dart:io HttpClient 默认支持 TLS Session 缓存
  • 前提:CDN 服务端启用 TLS 1.3(主流 CDN 默认已启用)
10.3.4 证书锁定(Certificate Pinning)
  • 防止中间人攻击篡改下载文件
  • 在 Dio 中通过 SecurityContext 设置可信证书
  • 或使用证书公钥 Pin(更灵活,证书轮换时只换证书不换公钥)
  • 注意:证书锁定会导致抓包调试困难,需要 Debug 模式下关闭

10.4 Dio 配置优化(针对礼物下载)

10.4.1 专用 Dio 实例

为礼物下载创建独立的 Dio 实例,与业务 API 请求隔离:

全局 Dio 实例规划:
├── apiDio        ← 业务接口(JSON 短连接,超时短)
├── downloadDio   ← 礼物下载(大文件长连接,超时长,不同拦截器)
└── uploadDio     ← 上传场景(如果有)

为什么隔离?

  • 下载的超时时间、重试策略与 API 请求完全不同
  • 避免大文件下载占满连接池,影响 API 请求响应速度
  • 拦截器不同(下载不需要 token 刷新、不需要 JSON 解析)
10.4.2 超时策略
阶段 API 请求 分片下载
connectTimeout 10s 10s
sendTimeout 10s 不限
receiveTimeout 15s 动态计算

分片下载的 receiveTimeout 计算:

receiveTimeout = max(分片大小 / 最低可接受速率, 15秒)

示例:
  2MB 分片 / 100KB/s 最低速率 = 20s → receiveTimeout = 20s
  256KB 分片 / 100KB/s = 2.5s → receiveTimeout = 15s(取最小值)
10.4.3 拦截器设计
downloadDio 拦截器链:
  ├── LogInterceptor          ← 仅 Debug 模式开启,记录请求/响应头
  ├── RetryInterceptor        ← 自动重试(指数退避)
  ├── NetworkQualityInterceptor ← 采集 TTFB、传输速率,更新网络质量模型
  ├── SignUrlInterceptor       ← 请求前检查 URL 签名是否过期,过期则刷新
  └── ProgressInterceptor      ← 采集下载进度,更新 DB

NetworkQualityInterceptor 的细节

  • onResponse 时记录:响应时间 - 请求时间 = TTFB
  • 在 onReceiveProgress 回调中计算:已接收字节 / 耗时 = 实时速率
  • 每完成一个分片,将该分片的速率加入滑动窗口
  • 窗口大小为最近 5 个分片的平均速率
  • 速率显著变化时通知调度引擎调整并发参数
10.4.4 ResponseType 选择

分片下载必须使用 ResponseType.stream,而非 ResponseType.bytes

ResponseType 行为 内存占用
bytes 等全部数据接收完再返回 Uint8List 整个分片大小(2MB → 内存峰值 2MB)
stream 返回 ResponseBody.stream,数据流式到达 缓冲区大小(~64KB)

stream 模式的好处

  • 内存占用从 O(分片大小) 降到 O(缓冲区大小)
  • 可以边接收边写入磁盘
  • 可以实时上报进度
  • 4 个分片并发时,bytes 模式可能占 8MB 内存,stream 模式只占 ~256KB

10.5 Isolate 与线程模型

10.5.1 Flutter 的单线程问题
Flutter 主 Isolate(UI 线程):
  ├── Widget 构建和渲染     ← 不能被阻塞,否则掉帧
  ├── 动画更新(60/120fps)  ← 16ms/8ms 内必须完成
  ├── 事件处理
  └── 异步任务调度

如果在主 Isolate 做这些事:
  ├── MD5 计算(10MB 文件 → ~100-200ms 阻塞)    ← 会掉帧!
  ├── 分片合并(多次文件读写)                      ← 会掉帧!
  ├── SQLite 大量写入                              ← 可能卡顿
  └── gzip 解压缩                                  ← 会掉帧!
10.5.2 需要放到 Isolate 的操作
操作 耗时 是否需要 Isolate
网络请求本身 异步 IO,不阻塞 不需要(Dart 异步即可)
流式写入磁盘 异步 IO 不需要
MD5 计算 CPU 密集,10MB ~200ms 需要
分片合并 IO 密集,可能 100ms+ 需要(大文件时)
文件头校验 读几个字节,<1ms 不需要
SQLite 写入 通常 <5ms 不需要(sqflite 已在后台线程)
数据压缩/解压 CPU 密集 需要
10.5.3 Isolate 使用策略
方案一:compute() —— 简单一次性任务
  适合:MD5 计算、文件合并
  特点:每次创建新 Isolate,有启动开销(~50-100ms)
  
方案二:长驻 Isolate + SendPort/ReceivePort
  适合:需要频繁调用的场景
  特点:Isolate 常驻,通过消息传递任务,避免重复创建
  
方案三:IsolatePool(自定义线程池)
  适合:大量分片并行下载时的 CPU 密集操作
  特点:预创建 N 个 Isolate,任务队列分发

本方案推荐:
  ├── MD5 计算 → compute()(一次性任务,不频繁)
  ├── 分片合并 → compute()(同上)
  └── 如果同时下载 10+ 文件都在做 MD5 → IsolatePool
10.5.4 Isolate 间数据传递的注意事项
  • Isolate 间不共享内存,通过 SendPort 传递消息
  • 大数据传递(如文件字节数组)会有拷贝开销
  • 优化:传递文件路径(String)而非文件内容(Uint8List),让目标 Isolate 自己读文件
  • TransferableTypedData:零拷贝传递 TypedData(Dart 2.15+),传递后原 Isolate 不再持有

10.6 数据传输优化

10.6.1 请求头优化

减少不必要的请求头,每个字节在弱网下都很珍贵:

精简后的分片下载请求头:
  GET /gift/001.mp4 HTTP/2
  Host: cdn.xxx.com
  Range: bytes=2097152-4194303
  If-Range: "a1b2c3d4e5"
  Accept-Encoding: identity        ← 明确告诉服务端不要压缩(mp4/webp 已压缩)
  
不需要的头:
  ✗ Cookie(CDN 不需要)
  ✗ Authorization(签名在 URL 参数中)
  ✗ Accept-LanguageUser-Agent(除非 CDN 做了 UA 校验)
10.6.2 Accept-Encoding: identity

对于 mp4/webp 这种已压缩的文件格式,必须告诉服务端不要做额外压缩:

  • 设置 Accept-Encoding: identity
  • 如果服务端返回了 Content-Encoding: gzip,Range 请求会失效
  • CDN 通常不会压缩媒体文件,但加上这个头作为显式保障
10.6.3 响应数据流式处理
传统方式(内存不友好):
  网络数据 → 全部加载到内存(Uint8List) → 一次性写入磁盘
  峰值内存:= 分片大小

流式处理(推荐):
  网络数据 → 64KB 缓冲区 → 立即写入磁盘 → 缓冲区复用
  峰值内存:≈ 64KB

实现关键:
  Dio 设置 ResponseType.stream
  → 获取 ResponseBody.stream(Stream<Uint8List>)
  → stream.listen() 逐块接收
  → 每块立即 file.writeAsBytes(chunk, mode: FileMode.append)
  → 同时更新下载进度
10.6.4 TCP 窗口与缓冲区
  • TCP 接收窗口:操作系统层面自动调节(TCP Window Scaling),Flutter 层面不需要手动调整
  • Socket 缓冲区:Dart 的 dart:io HttpClient 默认缓冲区大小通常够用
  • 但要注意:如果分片并发数太多,每个 Socket 都有接收缓冲区,总内存占用 = 并发数 × 缓冲区大小

10.7 弱网优化专项

10.7.1 弱网场景的特征
弱网不只是"慢",还包括:
  ├── 高延迟:RTT > 300ms,握手时间长
  ├── 高丢包:TCP 频繁重传,有效吞吐量远低于带宽
  ├── 抖动大:速率忽快忽慢,超时阈值难以设定
  ├── 连接不稳定:TCP 连接频繁断开
  └── DNS 解析慢:可能 > 1s
10.7.2 弱网下的特殊策略
优化项 措施 原理
减少连接数 文件并发 1,分片并发 1-2 连接少 → 每个连接分到的带宽多 → 减少超时
缩小分片 256KB 单片失败成本低,重试快
增加超时 connectTimeout 15s,receiveTimeout 动态上调 弱网下握手和传输都慢
优先完成小文件 webp 优先于 mp4 让用户尽快看到部分礼物效果
降级策略 显示静态图替代动画 网络极差时不下载 mp4,用 webp 占位
预热连接 提前建立 TCP 连接(不发数据) 下载时省去握手时间
HTTPDNS 跳过系统 DNS 弱网下 DNS 解析可能特别慢
10.7.3 自适应超时

固定超时在弱网下不合理:

自适应超时计算:

baseTimeout = 分片大小 / 当前估算速率 × 22 倍余量)
minTimeout = 15maxTimeout = 120timeout = clamp(baseTimeout, minTimeout, maxTimeout)

动态调整:
  如果连续 2 个分片都接近超时 → 下一个分片超时再延长 50%
  如果连续 3 个分片都很快完成 → 可以适当缩短超时
10.7.4 速率检测与自动降级
下载过程中持续监控速率:

速率 > 2MB/s    → 维持当前策略
速率 1-2MB/s    → 正常
速率 500KB-1MB  → 降低并发数
速率 < 500KB    → 降到最低配置(1文件×1分片×256KB)
速率 < 50KB     → 暂停下载,提示用户网络极差
                   对于用户触发的礼物 → 显示静态占位图

速率回升时自动恢复(但不立即恢复到最高配置,渐进式提升):
  50KB → 200KB  → 恢复到"差"配置
  200KB → 500KB → 恢复到"一般"配置
  有 2 秒滞后期,避免速率抖动导致频繁切换

10.8 连接预热与预建连

10.8.1 TCP 预连接
进入语音房时的预热流程:

1. DNS 预解析 cdn.xxx.com → 1.2.3.4
2. TCP 预连接 1.2.3.4:443(SYN → SYN-ACK → ACK)
3. TLS 预握手(完成 TLS 握手,但不发送业务数据)
4. 保持连接在池中等待

用户触发礼物下载时:
  → 跳过 DNS + TCP + TLS → 直接发送 GET Range 请求
  → 省去 200-500ms
10.8.2 HTTP/2 连接预热

HTTP/2 下只需要预热一条连接,后续所有分片都复用这条连接:

预热时机:
  ├── 进入语音房时(最佳)
  ├── 礼物列表 API 返回后(如果礼物 CDN 域名和 API 域名不同)
  └── 首个预加载任务启动时

预热方式:
  向 CDN 发一个极小的 HEAD 请求(获取某个文件信息)
  目的不是获取数据,而是建立 TCP + TLS 连接
  后续所有分片请求都能立即使用这条连接

10.9 内存管理优化

10.9.1 下载过程的内存控制
内存消耗点:
  ├── 网络接收缓冲区:并发数 × ~64KB = 256KB(4并发)
  ├── 文件写入缓冲区:并发数 × ~64KB = 256KB
  ├── Dio Response 对象:并发数 × ~1KB
  ├── SQLite 缓存:< 100KB
  ├── 分片元数据:每文件 < 10KB
  └── 总计:< 1MB(流式处理下)

如果不用流式处理(ResponseType.bytes):
  ├── 4 个 2MB 分片 → 8MB 内存峰值
  ├── 加上 Dart GC 的内存碎片 → 可能触发 10MB+ 的内存波动
  └── 语音房本身已有音频缓冲区和 UI 渲染开销,这很危险
10.9.2 大文件合并的内存控制
错误做法:
  chunk0_bytes = File(chunk0).readAsBytesSync();  // 2MB 进内存
  chunk1_bytes = File(chunk1).readAsBytesSync();  // 又 2MB
  finalFile.writeAsBytesSync(chunk0_bytes + chunk1_bytes); // 4MB 临时拼接

正确做法(流式合并):
  final sink = finalFile.openWrite();
  for (chunk in sortedChunks) {
    await chunk.openRead().pipe(sink);     // 流式传输,内存只占缓冲区大小
  }
  await sink.close();

内存差异:
  错误做法:10MB 文件 → 峰值 ~20MB(原始分片 + 合并后文件同时在内存)
  正确做法:10MB 文件 → 峰值 ~128KB(读写缓冲区)
10.9.3 下载完成后的内存释放
  • 分片下载完成后,立即关闭 Stream 和 File Handle
  • 合并完成后,立即删除临时分片文件(释放磁盘空间)
  • Dio Response 不要持有引用,用完即丢
  • 如果使用了 Uint8List 做中间处理,及时置为 null(帮助 GC)

10.10 网络安全

10.10.1 传输安全
措施 说明
HTTPS 强制 所有请求必须 HTTPS,拒绝 HTTP 降级
证书锁定 防止中间人攻击替换文件
URL 签名 CDN URL 带时效签名,防止盗链
MD5 校验 防止传输过程中数据被篡改
TLS 1.3 比 TLS 1.2 更安全、更快
10.10.2 防篡改链路
服务端:
  1. 文件上传时计算 MD5,存入数据库
  2. 礼物列表 API 返回 fileUrl + fileMd5 + fileSize
  3. API 响应本身通过 HTTPS + Token 认证保障

客户端:
  1. API 请求带 Token → 确保获取的 MD5 是真实的
  2. CDN 下载走 HTTPS → 传输不被篡改
  3. 下载完校验 MD5 → 确保文件完整
  4. 使用前校验文件头 → 确保文件格式正确

攻击者要成功篡改文件,需要同时:
  ✗ 突破 HTTPS → 替换 API 返回的 MD5
  ✗ 突破 HTTPS → 替换 CDN 传输的文件
  ✗ 或者攻破服务端 → 那已经是另一个层面的安全问题了

10.11 Flutter 特有的网络相关注意事项

10.11.1 Platform Channel 开销
  • 如果使用原生插件做下载(如 flutter_downloader),每次进度回调都是一次 Platform Channel 调用
  • Platform Channel 有序列化/反序列化开销,频率太高会卡 UI
  • 建议:进度回调做节流(throttle),最多每 100ms 回调一次 UI
10.11.2 后台下载
Flutter App 切后台时的下载行为:

iOS:
  ├── 默认:App 切后台约 30s 后暂停所有网络请求
  ├── Background Fetch:最多 30s 执行时间
  ├── Background URLSession(NSURLSession):
  │   系统托管下载,App 被杀也能继续
  │   需要通过原生代码实现,Flutter 层做 Platform Channel 桥接
  └── 如果不做后台下载,进度保存在 DB,前台恢复时断点续传

Android:
  ├── 前台服务(Foreground Service)+ 通知栏进度条
  ├── WorkManager:适合不紧急的预加载
  └── 直接在 Service 中用 OkHttp/HttpURLConnection 下载

语音房场景的特殊性:
  语音房通常有前台服务(音频播放),App 切后台不会立即被杀
  可以继续下载,但建议降低并发数(让出资源给音频流)
10.11.3 网络状态监听
connectivity_plus 插件:
  ├── 获取当前网络类型:WiFi / Mobile / None
  ├── 监听网络变化:onConnectivityChanged
  └── 局限:只知道有没有网,不知道网络质量

进一步探测:
  ├── WiFi 有信号但无法上网 → 需要实际请求才能发现
  ├── 检测方式:向已知 CDN 发一个 HEAD 请求,超时则认为无法上网
  └── 不要用 ping(某些网络环境禁止 ICMP)

网络变化时的处理:
  WiFi → 蜂窝:
    1. 暂停下载
    2. 弹窗询问用户(可配置是否自动切换)
    3. 用户同意 → 重新探测网络质量 → 降低并发 → 继续
    
  蜂窝 → WiFi1. 重新探测网络质量
    2. 提升并发参数
    3. 恢复被暂停的预加载任务
    
  有网 → 断网:
    1. 暂停所有下载
    2. 保留进度
    3. 监听网络恢复
    
  断网 → 有网:
    1. 等待 2s 稳定期
    2. 探测网络质量
    3. 断点续传流程
10.11.4 Dart 异步模型与下载的配合
Dart 是单线程事件循环模型:

Event Queue:
  ├── UI 事件
  ├── Timer 事件
  ├── IO 完成事件     ← 网络数据到达、文件写入完成
  └── Microtask 事件

网络 IO 本身不阻塞事件循环(底层由操作系统异步处理)
但以下操作会阻塞:
  ├── 同步文件读写(readAsBytesSync)      ← 避免使用
  ├── 大量数据处理(MD5、压缩)             ← 放到 Isolate
  ├── JSON 序列化大对象                    ← 放到 Isolate
  └── 复杂的集合操作                       ← 量大时注意

最佳实践:
  ├── 所有文件操作用异步版本(readAsBytes, writeAsBytes)
  ├── CPU 密集操作 → compute() / Isolate
  ├── 进度更新不要太频繁 → setState 做节流
  └── Stream.listen 的回调中不要做重操作

10.12 网络优化效果量化

优化项 优化前 优化后 收益
DNS 预解析 首次请求 +100-200ms 0ms 省去 DNS 等待
HTTPDNS 可能被劫持到远端节点 解析到最近节点 延迟可降 50%+
HTTP/2 复用 4 分片 = 4 次 TLS 握手 (800ms) 1 次 TLS (200ms) 省 600ms
连接预热 首次下载 +200-500ms 0ms 省去握手时间
流式写入 2MB 分片峰值内存 2MB 峰值 64KB 内存降 97%
自适应并发 固定 4 并发弱网超时 弱网 1 并发成功 弱网成功率提升
分片级续传 中断后从头下载 从中断点继续 省流量省时间
Isolate MD5 10MB MD5 阻塞 UI 200ms UI 无感知 消除卡顿

十一、关键设计决策汇总

决策点 选择 为什么
分片 vs 整文件 大文件(≥1MB)分片,小文件不分片 大文件分片提升并发利用率和容错性;小文件分片得不偿失
并发度动态 vs 固定 动态调整 网络波动大,固定值无法适应
分片大小固定 vs 动态 动态(256KB-4MB) 兼顾弱网容错和强网效率
网络探测方式 搭便车实时采样为主 减少额外流量浪费
优先级策略 可抢占的优先级队列 用户触发的礼物必须最快展示
元数据持久化 SQLite 可靠、支持复杂查询、事务性保证分片状态一致
分片临时存储 独立临时文件 便于管理和清理,合并时流式读写不占内存
文件版本校验 ETag + If-Range HTTP 标准机制,CDN 天然支持
签名 URL 处理 续传前检查过期并刷新 防止长时间断点后 URL 过期
缓存淘汰 LRU + 热度 + 24h 保护 平衡存储空间和用户体验
校验方式 四层校验(接口→分片→整文件→文件头) 层层防御,从概率上杜绝文件损坏
DNS 方案 HTTPDNS + 预解析 避免 DNS 劫持,减少解析延迟
HTTP 协议 优先 HTTP/2 单连接多路复用,省去重复握手
连接管理 独立 Dio 实例 + 连接预热 与业务 API 隔离,预热省去首次握手时间
响应处理 ResponseType.stream 流式写入 内存从 O(分片大小) 降到 O(64KB)
CPU 密集操作 compute() / Isolate 避免 MD5 计算、文件合并阻塞 UI 线程
弱网策略 自适应降级 + 静态图兜底 极差网络下也能给用户反馈
TLS 版本 TLS 1.3 更安全 + 支持 0-RTT 恢复

附:关键交互时序

用户点击送礼
     │
     ▼
[业务层] 检查 completed/ 目录 ── 有文件 → 直接播放 ✅
     │ 无文件
     ▼
[业务层] 检查 DB 有无未完成任务 ── 有 → 走断点续传
     │ 无
     ▼
[业务层] 调接口获取: fileUrl(带签名) + fileSize + fileMd5
     │
     ▼
[调度引擎] 设置优先级=最高,入队
     │
     ▼
[调度引擎] 分配连接数,抢占低优先级任务
     │
     ▼
[分片层] HEAD 请求 → 获取 Content-Length + ETag + Accept-Ranges
     │
     ▼
[分片层] 计算分片方案 → 写入 DB → 启动并行下载
     │
     ├── chunk 0: GET + Range → 206 → 写入 temp 文件 → 更新 DB
     ├── chunk 1: GET + Range → 206 → 写入 temp 文件 → 更新 DB
     ├── chunk 2: GET + Range → 206 → 写入 temp 文件 → 更新 DB
     └── ...
     │ 全部完成
     ▼
[分片层] 流式合并分片 → 写入 completed/ 目录
     │
     ▼
[分片层] MD5 校验 ── 通过 → 清理 temp → 通知业务层
     │                失败 → 清理所有 → 重新下载
     ▼
[业务层] 播放礼物动画 🎁

SwiftUI中修饰符的顺序直接影响视图最终效果

作者 WaywardOne
2026年3月20日 09:54

事情是这样的,我希望图片的底部有一条带背景的文字,大致像这个图

image.png

但是我写的控件一直只有文字有背景,不能铺满一整条,代码如下

var body: some View {

        VStack(alignment: .leading, spacing: 5) {

            ZStack(alignment: .bottomLeading) {

                KFImage(URL(string: product.coverUrl))

                    .resizable()

                    .aspectRatio(1/1.2, contentMode: .fit)

                    .frame(maxWidth: .infinity)

                    .cornerRadius(4)

                Text("结束时间 2999-99-99")

                    .padding(3)

                    .foregroundColor(.white)

                    .background(Color.black.opacity(0.5))

                    .frame(maxWidth: .infinity, alignment: .leading)
                    

            }
      }
}

实际效果:

image.png

然后就去问AI,AI给的结果如下

image.png

但是就算我把AI代码原原本本拷过来,任然是我截图的效果,为了这个问题一直问AI浪费了好一阵子时间。 然后我突然想到官方关于padding()前后作用效果不同,突然想到我这也是同样的道理。

实际上这就是我按着OC思维惯性来开发UI,不理解 SwiftUI 中至关重要的知识点:

修饰符的顺序直接影响视图最终效果
修饰符的顺序直接影响视图最终效果
修饰符的顺序直接影响视图最终效果

只要我替换.frame和.background顺序就能实现我需要的效果了

image.png

并且AI虽然好用,但也不能太过于依赖,多思考。

NativeScript iOS 平台开发技巧

作者 sp42_frank
2026年3月20日 09:35

升级到 NativeScript 8.7 后出现 APPLE is not defined 错误

出现了__APPLE__ is not defined 错误,是在你将 @nativescript/core, @nativescript/ios, @nativescript/android 升级到 ^8.7.0 版本后可能遇到的一个烦人错误。

官方推荐所有人都升级到 NativeScript 8.7,因为它包含了许多错误修复和改进,例如 devtool 以及恢复了从 8.4 版本开始中断的网络检查功能。然而有些人可能会遇到像下面这样的奇怪错误:

System.err: ReferenceError: __APPLE__ is not defined
System.err:
System.err: StackTrace:
System.err: ./node_modules/@nativescript/core/accessibility/font-scale-common.js(file: src/webpack:/FarmOps/node_modules/@nativescript/core/accessibility/font-scale-common.js:1:7)

原因

__APPLE__ is not defined 错误是由于 NativeScript 在他们的构建代码中引入了一些新的占位符。这些占位符依赖 Webpack 在构建时进行替换。而这个逻辑是在 @nativescript/webpack 5.0.19 中引入的。所以关键是确保你使用的 @nativescript/webpack 至少是 5.0.19 版本,才能成功使用 NativeScript 8.7 构建你的项目。

解决方案

所以基本上,解决 __APPLE__ is not defined 错误的方法是确保两件事:

  1. 首先,确保 @nativescript/webpack 的版本在你的 package.json 中没有被限制,像这样是最好的:^5.0.0
  2. 其次,确保你的 npm 已经知晓了 @nativescript/webpack 的最新可用版本,并且没有任何缓存。对我而言,我会执行 rm -rf node_modulesrm package-lock.json 然后再重新运行 npm i 来确保。或者更简单地,执行 ns clean 然后重新运行。

你总是可以尝试查看 package-lock.json找到 @nativescript/webpack 部分。如果它看起来像这样: 在这里插入图片描述

这表明实际安装的版本是 5.0.18,这是不行的。需要用我上面提到的任一种方法来解决。

在确保 @nativescript/webpack 版本没问题后,你现在可以再次运行 ns run 来继续你的 NativeScript 开发工作。

附言:如果你正在经历常见的 NativeScript 问题,并且需要一些快速修复或解决方法,请务必查看我们的“快速修复”部分。在这一部分,你会发现我在 NativeScript 之旅中收集的技巧和窍门,以及解决常见问题的解决方法。希望能帮助到许多像我一样的人。

NativeScript iOS: 无法启动模拟器

作为一名 iOS 开发者,最令人沮丧的事情莫过于 iOS 模拟器突然停止工作。这个工具对于在受控环境中测试和调试你的应用程序至关重要。当它失效时,你的工作流就会戛然而止,打乱工作效率并造成不必要的压力。

问题:无法启动模拟器

模拟器就是不工作,拒绝启动。并且一直说“无法启动模拟器”。 在这里插入图片描述

解决方案:

这个修复方法非常简单。

对于 Mac Ventura 13.0 及更高版本的操作系统 -> 点击 Mac 左上角的苹果图标 > 系统设置 > 搜索存储空间 > 等待加载,然后点击开发者 (Developer)。

在这里插入图片描述

在下一个屏幕中,选择删除 Xcode 缓存 (deleting Xcode Caches)。

删除完成后。尝试重新启动你的模拟器,现在它应该又能正常工作了。

如何正确修复:Info.plist 键 'BGTaskSchedulerPermittedIdentifiers' 必须包含一个标识符列表在这里插入图片描述

对于一个 NativeScript 应用,这个错误通常出现在 iOS 应用开发的上下文中,具体来说,当你或你安装的某些插件试图使用后台任务时,就会出现这个问题。

解决方法

  1. 打开你的应用的 App_Resources/iOS/Info.plist 文件。
  2. 如果尚不存在,添加键 BGTaskSchedulerPermittedIdentifiers
  3. 将其类型设置为 Array(数组)。
  4. 对于每个后台任务,在此数组中添加一项。每一项都应该是一个字符串,代表一个后台任务的唯一标识符。

使用示例

<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>

如果你有任何自定义的后台任务,你也需要将其 ID 列入上面的数组中。除此之外,你可以直接使用上面这段代码。

$(PRODUCT_BUNDLE_IDENTIFIER) 将被解析为你在 nativescript.config.ts 中定义的应用 Bundle ID,例如:com.newbiescripter.myawesomeapp

请记住,在 iOS 中使用后台任务有一些限制和准则,因为苹果旨在优化电池续航和性能。请确保你使用后台任务的方式符合这些准则。

iOS 内存管理

作者 Yannik
2026年3月19日 23:24

一、内存管理方式

iOS 中主要通过引用计数(Reference Counting) 来管理对象的内存。开发者可以通过两种方式操作引用计数:

  • MRC(Manual Reference Counting) :手动管理,需要开发者调用 retainreleaseautorelease 等方法。
  • ARC(Automatic Reference Counting) :自动引用计数,由编译器在编译时自动插入内存管理代码,是现在的主流方式。

二、引用计数原理

  • 每个对象内部都有一个引用计数器,表示当前有多少个地方持有该对象。

  • 当引用计数变为 0 时,对象会被立即销毁,内存被回收。

  • 操作对应关系:

    • 引用计数 +1:alloc/new/copy/mutableCopy(产生对象时引用计数为 1),retain(MRC)或强引用(ARC)
    • 引用计数 -1:release(MRC)或强引用超出作用域/置 nil(ARC)
    • 延迟释放:autorelease 将对象放入自动释放池,池子被 drain 时统一 release

三、ARC 规则

ARC 下编译器会在合适的位置自动插入 retain/release 代码,开发者必须遵循所有权修饰符:

  • __strong:默认,强引用,持有对象,引用计数 +1
  • __weak:弱引用,不持有对象,对象销毁时自动置为 nil
  • __unsafe_unretained:类似 weak,但不会自动置 nil,不安全
  • __autoreleasing:用于传递间接指针,常用于 NSError 等参数传递

四、循环引用及解决方案

循环引用是内存泄漏的主要原因,即两个或多个对象相互强引用,导致无法释放。
常见场景及解决方法:

  1. 父子关系:父对子用强引用,子对父用弱引用(weak)。
  2. Block 循环引用:Block 内部直接或间接使用了 self,且 Block 被 self 持有。解决方案是在 Block 外部使用 __weak typeof(self) weakSelf = self;,Block 内部使用 weakSelf;如果 Block 内需要 strongSelf 保证执行期间不被释放,可以在 Block 开头加 __strong typeof(weakSelf) strongSelf = weakSelf;
  3. Delegate:delegate 属性一般用 weak,避免循环引用。
  4. NSTimer:timer 会强引用 target,容易造成循环引用。解决方案:在合适的时机(如 viewWillDisappear)主动 invalidate timer,或者使用中间代理对象

五、自动释放池(Autorelease Pool)

  • 用于存放标记为 autorelease 的对象,通常在主线程的 RunLoop 每个循环开始前创建自动释放池,结束后销毁,从而释放池中对象。
  • 手动创建自动释放池:@autoreleasepool { ... },适用于循环中创建大量临时对象的情况,可以及时释放内存,避免峰值过高。

六、内存警告处理

  • 当系统内存紧张时,会向 App 发送内存警告通知。UIViewController 会收到 didReceiveMemoryWarning,App Delegate 会调用 applicationDidReceiveMemoryWarning
  • 常见做法:释放可重建的缓存对象、图片资源,清理无用数据。

七、优化技巧

  • 避免使用过多的单例,除非确实需要全局共享。
  • 尽量使用轻量级对象,如使用 struct 代替简单的类。
  • 图片加载使用适当的方法,如 imageNamed: 有缓存,适合反复使用的小图;imageWithContentsOfFile: 无缓存,适合一次性大图。
  • 使用 Instruments 的 Leaks 和 Allocations 工具检测内存泄漏和内存分配。

八、@autoreleasepool 实现原理

1. 先一句话说明 @autoreleasepool 的作用

@autoreleasepool 是 Objective-C 中用于管理自动释放对象的语法结构,它包裹的代码块中产生的自动释放对象(通过 autorelease 方法添加的对象)会在块结束时收到 release 消息,从而及时释放内存,避免内存峰值。

2. 底层实现原理(核心部分)

2.1 基于 AutoreleasePoolPage 的栈结构

  • 自动释放池的底层由 C++ 类 AutoreleasePoolPage 实现,它是一个双向链表节点,每个线程(Thread)拥有自己的自动释放池栈。
  • AutoreleasePoolPage 内部有一个 next 指针,指向下一个可存放对象的内存位置,还有一个 parent 和 child 指针,用于链接多个 page(当当前 page 存满时,会创建新的 page)。
  • 每个 AutoreleasePoolPage 的大小通常是 4096 字节(一页内存),除了存储对象指针,还包含一些元数据。

2.2 哨兵对象(POOL_SENTINEL)

  • 当遇到 @autoreleasepool { 时,编译器会在当前线程的自动释放池栈中压入一个哨兵对象(nil 或特殊标记),作为这个 pool 的边界。
  • 所有在该块内调用 autorelease 的对象,其指针会被依次追加到当前 page 中 next 指向的位置。
  • 当执行到 } 时,会向池中的所有对象发送 release 消息,一直释放到上一个哨兵对象的位置,然后销毁这个池(弹出栈顶的哨兵及之后添加的对象)。

2.3 嵌套实现

  • 多个 @autoreleasepool 嵌套时,每次进入都会压入一个新的哨兵,退出时弹出到对应的哨兵,因此嵌套池是“后进先出”的栈式管理。

2.4 与 RunLoop 的关联

  • 主线程的 RunLoop 默认会在每次事件循环(如触摸、定时器)开始时创建自动释放池,事件结束后销毁。这保证了大多数临时对象能及时释放,也解释了为什么我们通常不需要手动创建 pool。

3. 代码层面如何体现

  • 调用 [obj autorelease] 时,实际会调用 AutoreleasePoolPage::autorelease(obj),将对象指针添加到当前线程的自动释放池中。
  • 当 pool 销毁时,会调用 AutoreleasePoolPage::pop(哨兵地址),遍历从当前 page 到哨兵之间的所有对象,并发送 release

4. 实际使用场景与优化

  • 避免内存峰值:在循环中大量创建临时对象时(例如读取图片数据),用 @autoreleasepool 包裹循环体,可以使每次迭代产生的对象及时释放,防止内存暴涨。
  • 子线程中的自动释放池:如果子线程不开启 RunLoop,则需要手动添加 @autoreleasepool 来管理自动释放对象。

5. 可能的追问及应对

  • AutoreleasePoolPage 的具体结构是怎样的?
    :它是一个 C++ 类,包含 magic(校验用)、next(指向下一个空闲位置)、thread(绑定的线程)、parent/child(链表指针)以及一个对象指针数组(用于存储 autorelease 的对象)。
  • :为什么自动释放池能够嵌套?
    :因为每个池用哨兵标记边界,栈式存储,出池时根据哨兵定位到正确的释放范围,所以嵌套是安全的。
  • :ARC 下 autorelease 对象什么时候会被释放?
    :ARC 下编译器会自动插入 autorelease 调用,对象最终由最近的自动释放池在释放时处理。如果在主线程且没有手动创建池,则由 RunLoop 创建的池在事件循环结束时释放。

总结回答话术

@autoreleasepool 的实现本质是一个基于栈的双向链表结构。每个线程都有一个自动释放池栈,每个池通过压入一个哨兵对象作为边界。当对象调用 autorelease 时,它的指针被添加到当前栈顶。当 pool 作用域结束时,会从栈顶向下一路释放对象,直到遇到哨兵。这种设计支持嵌套,并且和 RunLoop 紧密配合,让开发者能方便地控制内存。在写循环或子线程时,我们手动添加 @autoreleasepool 可以及时回收内存,避免峰值。”

这样的回答既涵盖了原理,也联系了实际使用,能够体现对内存管理的深入理解。

昨天以前iOS

iOS26适配之UIBarButtonItem

作者 Andy_GF
2026年3月18日 18:12

iOS26 UINavigationBar 导航栏返回按钮, 被系统默认加了 Liquid glass 效果, 个人认为这个效果并不好看, 主要是为了保证App整体风格 和 减少工作量,所以需要去掉这个所谓的液态玻璃效果.经过一下午的研究,终于找到一个最简单直接的方法.就是直接将 UIBarButtonItem 的属性 hidesSharedBackground 设置为 YES, 就神奇般的被隐藏了.

液态玻璃效果

image.png

修改属性 hidesSharedBackground

UIButton *btn = [[UIButton alloc] init];
UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:btn];
if (@available(iOS 26.0, *)) {
    item.hidesSharedBackground = YES;
}

隐藏效果 - 恢复

image.png

黑魔法全局禁用

创建一个分类UIBarButtonItem, 在分类中用OC黑魔法方法交换,自动禁用液态玻璃效果.如分类名称为 DefaultHideBackground.

  • 头文件UIBarButtonItem+DefaultHideBackground.h

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface UIBarButtonItem (DefaultHideBackground)

@end

NS_ASSUME_NONNULL_END

  • 实现文件UIBarButtonItem+DefaultHideBackground.m

#import "UIBarButtonItem+DefaultHideBackground.h"
#import <objc/runtime.h>

@implementation UIBarButtonItem (DefaultHideBackground)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [self class];
        
        // 需要交换的初始化方法列表
        SEL selectors[] = {
            @selector(initWithTitle:style:target:action:),
            @selector(initWithImage:style:target:action:),
            @selector(initWithBarButtonSystemItem:target:action:),
            @selector(initWithCustomView:),
            @selector(initWithCoder:), // 处理从 Interface Builder 创建
            @selector(init)             // 处理直接使用 init 的情况
        };
        
        for (NSUInteger i = 0; i < sizeof(selectors) / sizeof(SEL); i++) {
            SEL originalSelector = selectors[i];
            NSString *swizzledName = [@"swizzled_" stringByAppendingString:NSStringFromSelector(originalSelector)];
            SEL swizzledSelector = NSSelectorFromString(swizzledName);
            
            Method originalMethod = class_getInstanceMethod(class, originalSelector);
            Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
            
            if (originalMethod && swizzledMethod) {
                method_exchangeImplementations(originalMethod, swizzledMethod);
            } else {
                NSLog(@"Failed to swizzle method: %@", NSStringFromSelector(originalSelector));
            }
        }
    });
}

#pragma mark - Swizzled Initializers

- (instancetype)swizzled_initWithTitle:(NSString *)title style:(UIBarButtonItemStyle)style target:(id)target action:(SEL)action {
    UIBarButtonItem *item = [self swizzled_initWithTitle:title style:style target:target action:action]; // 实际调用原方法
    [item applyDefaultHidesSharedBackground];
    return item;
}

- (instancetype)swizzled_initWithImage:(UIImage *)image style:(UIBarButtonItemStyle)style target:(id)target action:(SEL)action {
    UIBarButtonItem *item = [self swizzled_initWithImage:image style:style target:target action:action];
    [item applyDefaultHidesSharedBackground];
    return item;
}

- (instancetype)swizzled_initWithBarButtonSystemItem:(UIBarButtonSystemItem)systemItem target:(id)target action:(SEL)action {
    UIBarButtonItem *item = [self swizzled_initWithBarButtonSystemItem:systemItem target:target action:action];
    [item applyDefaultHidesSharedBackground];
    return item;
}

- (instancetype)swizzled_initWithCustomView:(UIView *)customView {
    UIBarButtonItem *item = [self swizzled_initWithCustomView:customView];
    [item applyDefaultHidesSharedBackground];
    return item;
}

- (instancetype)swizzled_initWithCoder:(NSCoder *)coder {
    UIBarButtonItem *item = [self swizzled_initWithCoder:coder];
    [item applyDefaultHidesSharedBackground];
    return item;
}

- (instancetype)swizzled_init {
    UIBarButtonItem *item = [self swizzled_init];
    [item applyDefaultHidesSharedBackground];
    return item;
}

#pragma mark - Helper Method

- (void)applyDefaultHidesSharedBackground {
    if (@available(iOS 26.0, *)) {
        self.hidesSharedBackground = YES;
    }
}

@end

CDE:一次让 Core Data 更像现代 Swift 的尝试

作者 Fatbobman
2026年3月18日 22:00

在上一篇文章中,我聊了聊 Core Data 在当下项目中的一些现实处境。在本文中,我将介绍我的一个实验性项目 Core Data Evolution,探索能不能让 Core Data 在现代 Swift 项目中以一种更自然的方式继续存在下去?

全面解析WhatsApp Web抓包:原理、工具与安全

2026年3月18日 14:56

“全面解析WhatsApp Web抓包:原理、工具与安全”

1. WhatsApp Web的基本原理与架构

WhatsApp Web是WhatsApp的一个网页版应用,它允许用户通过浏览器与手机端的WhatsApp进行同步,实现信息的发送与接收。其基本原理主要依赖于二维码扫描和端到端加密技术。

首先,WhatsApp Web的工作流程始于用户在浏览器中访问WhatsApp Web的官方网站。用户会看到一个二维码,接下来需要用手机上的WhatsApp应用进行扫描。通过扫描二维码,手机端的WhatsApp应用与网页版建立了一个安全的连接。这个二维码实际上包含了一个临时的会话令牌,确保只有经过授权的设备才能访问用户的消息。

WhatsApp Web的架构基于客户端-服务器模型。用户的手机是主要的客户端,负责处理消息的接收和发送,而浏览器则充当另一个客户端。两者之间通过WebSocket协议进行实时通信。WebSocket是一种在单个TCP连接上进行全双工通信的协议,允许数据在客户端与服务器之间快速传输。这种设计的优势在于可以实现即时消息推送,确保用户在网页版上能够及时接收到来自手机端的消息。

在安全性方面,WhatsApp Web采用了端到端加密技术。这意味着消息在发送时会被加密,只有发送者和接收者能够解密查看。这种加密机制确保了即使数据在传输过程中被截获,第三方也无法读取消息内容。此外,WhatsApp Web的连接是基于HTTPS协议的,进一步增强了数据传输的安全性。

除了消息的发送与接收,WhatsApp Web还支持多种功能,包括查看联系人、发送图片和文件、以及进行语音和视频通话等。这些功能的实现同样依赖于与手机端的实时同步,确保用户在不同设备上获得一致的使用体验。

总的来说,WhatsApp Web通过二维码扫描实现设备间的快速连接,利用WebSocket协议实现实时数据传输,并通过端到端加密保障消息的安全性。这种设计不仅提升了用户体验,也确保了用户的隐私安全,为现代通讯方式提供了一个创新的解决方案。

2. 抓包工具的选择与使用方法

抓包工具的选择与使用方法是进行WhatsApp Web分析的关键步骤。首先,常用的抓包工具有Fiddler、Charles Proxy和Wireshark等,这些工具能够帮助用户捕获网络请求并分析数据。 Sniffmaster作为一款全平台抓包工具,支持HTTPS、TCP和UDP协议,可在iOS/Android/Mac/Windows设备上实现无需代理、越狱或root的抓包操作,特别适合移动应用如WhatsApp Web的分析。

在选择工具时,用户需考虑其操作系统的兼容性以及工具的功能特点。例如,Fiddler和Charles Proxy对HTTP/HTTPS流量的支持非常好,适合初学者使用,而Wireshark则适合需要深入分析网络流量的用户,Sniffmaster好上手。

在实际使用过程中,用户需要先配置代理,确保抓包工具能够捕获到WhatsApp Web的流量。在此过程中,注意要能上外网,以便能够顺利连接到WhatsApp的服务器。安装和配置工具后,用户可以通过浏览器访问WhatsApp Web,抓包工具将实时显示网络请求和响应数据,用户可以根据需要分析特定的请求,查看其中的参数和数据内容。这对理解WhatsApp Web的工作原理以及数据传输方式非常有帮助。在进行数据分析时,用户还需关注隐私安全问题,确保不泄露个人信息或敏感数据。在抓包过程中,建议保持对数据的保密性,避免将抓取的数据公开或分享给不信任的第三方。

3. 数据分析与隐私安全的考虑

在进行WhatsApp Web的抓包分析时,数据的隐私安全问题是一个不可忽视的重要方面。随着网络安全威胁的不断增加,用户的个人信息和通信内容面临着潜在的风险。因此,在进行数据分析时,我们需要充分考虑以下几个方面。

首先,抓包过程中获取的数据通常包含用户的消息内容、联系人信息以及媒体文件等敏感数据。这些数据如果落入不法分子之手,可能会导致用户隐私泄露、身份盗窃等严重后果。因此,在使用抓包工具时,务必遵循相关法律法规,确保只在合法范围内进行数据分析,并且要获得相关人员的同意。

其次,进行数据分析时,使用的工具和方法也需谨慎选择。当前市场上存在多种抓包工具,如Fiddler、Charles等,这些工具虽功能强大,但若不加以妥善使用,可能会引发安全隐患。例如,某些抓包工具可能会在未授权的情况下存储用户数据,或是通过不安全的网络传输数据,从而导致数据被窃取。因此,选择可靠的工具,并定期更新其安全补丁,是保护数据安全的有效措施。

然后,在数据分析过程中,建议对敏感信息进行脱敏处理。通过对数据进行加密、匿名化或是只提取必要信息,可以在一定程度上降低数据泄露的风险。同时,分析结果应仅限于内部使用,避免将敏感数据以任何形式公开或分享,确保用户隐私不被侵犯。

此外,用户自身在使用WhatsApp Web时,也应加强安全意识。定期更新密码、启用双重身份验证、避免在公共网络环境下使用WhatsApp Web等都是提升个人信息安全的有效手段。用户应时刻保持警惕,关注账户的异常活动,一旦发现可疑行为,应立即采取措施保护自己的账户安全。

最后,数据分析的结果应有助于提升WhatsApp Web的安全性。通过对抓取的数据进行深入分析,可以识别潜在的安全漏洞和风险点,从而为开发者提供改进产品安全性的依据。这不仅有助于保护用户的隐私安全,也能增强用户对平台的信任,推动整个社交网络环境的健康发展。

综上所述,在进行WhatsApp Web的抓包与数据分析时,隐私安全问题不容忽视。通过合法合规的方式、选择合适的工具、进行数据脱敏处理以及增强用户安全意识,我们能够在享受便捷通信服务的同时,有效保护个人隐私与信息安全。

独立开发了一个睡眠记录 App:SleepDiary / 睡眠声音日记

作者 Flutter笔记
2026年3月18日 14:50

最近一直在弄一个睡眠记录 App,名字叫

SleepDiary / 睡眠声音日记

已经上架 App Store 了,直接搜名字可以搜到。

这东西我其实做了挺久了,中间一直在反复改。
最近才慢慢觉得,差不多到了一个能发出来聊聊的阶段。

最开始想做,原因也不复杂。
就是我自己之前看过一些睡眠类产品,总觉得不太顺手。
要么广告很多,要么功能特别杂。
本来只是想看看晚上有没有鼾声、睡得怎么样,结果经常还得被一些很重的流程打断。

所以我做这个的时候,想法一直挺简单的:
打开就能录,醒来能看,别整太复杂。

我自己这段时间已经连续用了很多晚,边用边改。
首页、历史、单晚详情这些地方都来回调了很多次。
尤其是单晚详情,我自己会比较在意。
因为如果只是告诉你“昨晚有鼾声”,其实没什么意思。
但如果能大概看到什么时间段更明显,和整晚记录能对起来,那这个东西至少会更像个能参考的工具。

还有就是,这类 App 本来就比较私人。
毕竟会涉及睡眠、声音这些东西。
所以我自己做的时候,鼾声识别都是用的端侧模型,不会上传。
不一定一步到位,但至少会一直往这个方向抠。

发出来主要也是想看看,这里会不会有人对这种东西有兴趣。
不是想听“看起来不错”这种话,主要还是想知道更实际一点的:

你会不会真的用睡眠记录类 App?
你到底会看什么?
是看单晚,还是看一段时间的变化?
如果是鼾声、梦话这种信息,你会希望它怎么给你看,才算有用?

有想法都可以聊聊。

# iOS 动态库与静态库全面解析

作者 忆江南
2026年3月17日 18:25

iOS 动态库与静态库全面解析

一、基本概念

静态库 (Static Library)

同一个库,动态库 vs 静态库,谁更大?绝大多数情况下,动态库会让包体积更大。 原因如下:

核心差异:能不能 Strip 未使用代码,静态库 — 链接器只拉入你实际用到的 .o,没用到的直接丢弃: 静态库是在编译链接阶段被完整拷贝到可执行文件中的代码集合。链接完成后,静态库文件本身不再被需要。

文件格式:

  • .a — 传统静态库(archive 文件,本质是 .o 目标文件的打包)
  • .framework — 可以是静态 framework(Xcode 从 iOS 8 起支持)

动态库 (Dynamic Library)

动态库在运行时由动态链接器(dyld)加载到进程地址空间中,不会被拷贝到可执行文件里,而是以独立文件形式存在于 App Bundle 中。

文件格式:

  • .dylib — 传统动态库(系统库使用,第三方不可提交 App Store)
  • .tbd — 动态库的文本描述文件(text-based stub),Xcode 链接系统库时使用
  • .framework — 可以是动态 framework(iOS 8+ 支持嵌入式动态 framework)

注意.framework 本身只是一种打包格式(目录结构),它既可以是静态的也可以是动态的,取决于内部二进制的 Mach-O 类型。


二、编译链接原理

静态库的链接过程

源代码 (.m/.swift)
       ↓ 编译器 (clang/swiftc)
目标文件 (.o)
       ↓ 归档工具 (ar)
静态库 (.a)
       ↓ 链接器 (ld) 将用到的 .o 拷贝进最终二进制
可执行文件 (Mach-O executable)

关键点:

  • 链接器做符号解析,只将被引用到的 .o 文件链接进来(粒度是 .o,不是函数)
  • 使用 -ObjC flag 时会链接所有包含 ObjC 类的 .o(解决 Category 不生效的问题)
  • 使用 -all_load 会强制链接所有 .o
  • 使用 -force_load <path> 可以对特定静态库强制全部链接
  • 静态库的代码最终融合进主二进制,运行时已不存在"库"的概念

动态库的链接过程

源代码 (.m/.swift)
       ↓ 编译 + 链接
动态库 (.dylib / .framework)
       ↓ 嵌入 App Bundle 的 Frameworks/ 目录
       ↓ 运行时 dyld 加载
进程地址空间

关键点:

  • 编译时只做符号检查,不拷贝代码,主二进制只记录"我依赖了哪个动态库"
  • dyld 在 App 启动时(或按需 dlopen)将动态库映射到进程地址空间
  • 动态库有独立的 install_name,指示 dyld 去哪里找它
  • 嵌入式 framework 的 install_name 通常是 @rpath/XXX.framework/XXX
  • 动态库在运行时保持独立,拥有自己的符号表和地址空间

核心区别图示

┌─────────────────────────────────────────────────────┐
│                    编译期                             │
│                                                     │
│  静态库:代码被拷贝 ──────→ 合并到主二进制               │
│  动态库:只记录依赖关系 ──→ 主二进制仅保存引用            │
│                                                     │
├─────────────────────────────────────────────────────┤
│                    运行期                             │
│                                                     │
│  静态库:不存在了,代码已在主二进制中                     │
│  动态库:dyld 加载 → rebase → bind → 映射到进程空间     │
│                                                     │
└─────────────────────────────────────────────────────┘

三、Mach-O 文件结构

无论静态库还是动态库,最终都与 Mach-O 格式密切相关。

Mach-O 文件结构:
┌──────────────────────┐
│      Header          │  ← 魔数、CPU 类型、文件类型
│                      │     MH_EXECUTE (可执行文件)
│                      │     MH_DYLIB   (动态库)
│                      │     MH_OBJECT  (目标文件,静态库内的 .o)
├──────────────────────┤
│   Load Commands      │  ← 描述 segment 布局、依赖的动态库列表、入口点等
│                      │     LC_LOAD_DYLIB 记录依赖的动态库
│                      │     LC_RPATH 指定运行时搜索路径
├──────────────────────┤
│   __TEXT Segment      │  ← 只读:机器码、字符串常量、Swift metadata
├──────────────────────┤
│   __DATA Segment      │  ← 可读写:全局变量、ObjC 元数据、GOT (全局偏移表)
├──────────────────────┤
│   __LINKEDIT Segment  │  ← 符号表、字符串表、代码签名信息
└──────────────────────┘

静态库(.a)的本质:不是 Mach-O 文件,而是多个 .o(Mach-O Object)的归档包。链接器从中提取需要的 .o 合并到最终的 Mach-O 可执行文件中。

动态库的本质:是一个完整的 Mach-O 文件(类型为 MH_DYLIB),有自己的 Header、Load Commands、Segments,运行时被 dyld 独立加载。


四、全面对比

维度 静态库 动态库
链接时机 编译期,链接器完成 运行期,dyld 完成
代码位置 拷贝进主 Mach-O 独立文件,位于 .app/Frameworks/
主二进制大小 更大(包含库代码) 更小(只记录依赖引用)
App Bundle 总大小 通常更小(Strip 掉未用代码) 可能更大(整个库都打包)
启动速度 快,无额外加载开销 慢,dyld 需要 load → rebase → bind
内存 每个引用者各有一份拷贝 系统库多进程共享;嵌入式库不共享
符号可见性 合并到主二进制的全局符号表 保持独立符号表,符号隔离
符号冲突风险 高,容易 duplicate symbols 低,各库符号空间独立
ObjC Category -ObjC flag 才能加载 自动加载
链接时优化 (LTO) 支持,编译器可跨库优化 不支持,库边界是优化屏障
增量编译 改库需重新链接整个 App 改库只需重编该库
代码签名 无需单独签名 每个动态库需独立签名
Xcode 配置 Do Not Embed Embed & Sign

五、优缺点详解

静态库的优点

  1. 启动速度快 — 不增加 dyld 加载数量,pre-main 阶段零额外开销
  2. 链接时优化 (LTO) — 编译器可以跨静态库边界做死代码消除、函数内联、常量折叠
  3. 包体积可控 — 链接器只拉入被引用的 .o,未用代码不会进入最终二进制
  4. 部署简单 — 最终只有一个 Mach-O,不需要管 Embed & Sign
  5. 无运行时依赖 — 不会出现 dylib not found / image not found 崩溃

静态库的缺点

  1. 代码重复 — 若主 App 和 Extension 都静态链接同一个库,代码各存一份
  2. 符号冲突 — 多个静态库包含同名符号时报 duplicate symbol 错误
  3. 编译链接耗时 — 主二进制越大,链接阶段越慢;改库后需重新链接整个 App
  4. 无法独立更新 — 库的任何改动都要重新编译发版

动态库的优点

  1. 系统库共享内存 — UIKit、Foundation 等系统动态库被所有 App 共享,节省内存
  2. 符号隔离 — 各动态库有独立命名空间,同名符号不冲突
  3. 增量编译友好 — 修改动态库只需重编该库,不影响主二进制链接
  4. 跨 Target 共享 — 主 App 和 Extension 可共用同一份动态 framework,避免代码重复
  5. 热替换理论可行 — 替换 .framework 文件即可更新逻辑(App Store 不允许,仅企业包/调试可用)

动态库的缺点

  1. 启动变慢 — 每个动态库在 pre-main 阶段都增加 dyld 加载耗时
  2. 包体积膨胀 — 动态库无法 Strip 未使用符号,整个库的代码都会打入 Bundle
  3. 签名复杂 — 每个动态 framework 需独立代码签名
  4. 沙盒限制 — iOS 不允许 dlopen App Bundle 外的动态库
  5. 运行时崩溃风险 — 库缺失或版本不匹配,启动时直接 crash
  6. 无法跨库 LTO — 编译器优化止步于动态库边界

六、启动性能原理 (dyld)

dyld 加载全流程

App 进程创建
  ↓
1. Load dylibs           递归加载主二进制依赖的所有动态库(及其传递依赖)
  ↓                       每个库:mmap 到虚拟内存 → 验证签名 → 注册
2. Rebase                 ASLR (地址空间布局随机化) 导致实际加载地址与编译地址不同
  ↓                       遍历所有内部指针,加上随机偏移量 (slide)
3. Bind                   解析跨库的外部符号引用
  ↓                       lazy binding: 首次调用时才解析(大部分函数)
  ↓                       non-lazy binding: 启动时立即解析(ObjC 元数据、C++ 虚表)
4. ObjC Runtime Setup     注册所有 ObjC 类到 runtime
  ↓                       插入 Category 的方法到类的方法列表
  ↓                       确保 selector 唯一性
5. Initializers           执行 +load 方法
  ↓                       执行 C/C++ __attribute__((constructor))
  ↓                       执行 Swift 全局变量的初始化器
  ↓
main() 被调用

每一步为什么耗时

阶段 耗时原因 与动态库数量的关系
Load 磁盘 I/O + 签名验证 线性正相关,库越多越慢
Rebase 遍历 __DATA 段所有内部指针 与库的数据段大小相关
Bind 符号查找(哈希表查询) 与跨库符号引用数量相关
ObjC Setup 类注册 + Category 合并 与 ObjC 类/Category 总数相关
Initializers 执行用户代码 +load 和 constructor 数量相关

dyld 2 vs dyld 3

特性 dyld 2 (iOS 12 及以前) dyld 3 (iOS 13+)
解析时机 每次启动都在进程内完整解析 首次解析后缓存为 launch closure
安全性 在 App 进程内解析(可被攻击) 解析移到进程外守护进程
缓存 closure 缓存后,后续启动跳过解析
冷启动 首次略慢(多了写缓存),后续显著加速
热启动 中等 直接读取 closure,非常快

性能数据参考

动态库数量 大致额外 pre-main 耗时 (iPhone 8 级别)
1-5 个 ~5-20ms
10-20 个 ~50-150ms
50+ 个 ~300ms+
100+ 个 可能超过 400ms watchdog 阈值 (冷启动)

Apple 官方建议:嵌入式动态 framework 控制在 6 个以内。

静态库为什么不影响启动

静态库的代码在编译期已经合并进主二进制:

  • 不增加 Load dylibs 的数量
  • 不增加跨库 Bind 的符号数量
  • ObjC 类直接注册在主二进制中,无额外开销
  • 唯一的影响:主二进制变大 → mmap 主二进制的时间微增(可忽略)

七、符号解析原理

静态链接的符号解析

主程序引用 _doSomething (未定义符号 U)
         ↓
链接器在静态库中搜索
         ↓
找到 MyModule.o 中定义了 _doSomething (符号类型 T)
         ↓
将整个 MyModule.o 拷贝进主二进制
         ↓
符号变为已定义 (resolved)
  • 粒度是 .o 文件:即使只用了 .o 中的一个函数,整个 .o 都会被链接
  • 这就是为什么 SDK 开发者会把每个函数/类放在单独的 .m 文件中,以减少无用代码

动态链接的符号解析

主程序引用 _doSomething (标记为 external, lazy)
         ↓
编译时:链接器确认动态库中存在该符号 → 通过
         ↓
运行时:首次调用 _doSomething
         ↓
dyld 在动态库的符号表中查找 → 写入 GOT/lazy pointer
         ↓
后续调用直接走 GOT,无需再次查找
  • Lazy Binding:大部分函数调用使用,首次调用时才解析,分散了启动开销
  • Non-Lazy Binding:ObjC 类引用、__DATA 段指针等在启动时立即解析

符号冲突对比

静态库:同名符号 → duplicate symbol 编译错误(严格)

ld: duplicate symbol '_MyFunction' in:
    libA.a(module.o)
    libB.a(module.o)

动态库:同名符号 → 运行时 "先加载者胜"(flat namespace)或各自独立(two-level namespace,iOS 默认)

Two-Level Namespace (iOS 默认):
  调用 libA 的 _MyFunction → 解析到 libA 内部
  调用 libB 的 _MyFunction → 解析到 libB 内部
  不会混淆

八、内存与体积影响

内存模型对比

┌─────────── 静态库场景 ──────────────┐
│                                    │
│  App 进程内存:                      │
│  ┌──────────────────┐              │
│  │ 主二进制 (__TEXT)   │ ← 含库A代码  │
│  │ 主二进制 (__DATA)   │ ← 含库A数据  │
│  └──────────────────┘              │
│                                    │
│  Extension 进程内存:                │
│  ┌──────────────────┐              │
│  │ Extension (__TEXT) │ ← 又一份库A  │
│  │ Extension (__DATA) │ ← 又一份库A  │
│  └──────────────────┘              │
│                                    │
│  → 库A代码存在两份 (磁盘 + 内存)       │
└────────────────────────────────────┘

┌─────────── 动态库场景 ──────────────┐
│                                    │
│  App 进程内存:                      │
│  ┌──────────────────┐              │
│  │ 主二进制           │              │
│  │ 库A.framework     │ ←──┐ __TEXT  │
│  └──────────────────┘    │ 页共享   │
│                          │         │
│  Extension 进程内存:      │         │
│  ┌──────────────────┐    │         │
│  │ Extension         │    │         │
│  │ 库A.framework     │ ←──┘ 同一物理页│
│  └──────────────────┘              │
│                                    │
│  → __TEXT 段可跨进程共享物理内存页      │
│  → __DATA 段每个进程各自 copy-on-write│
└────────────────────────────────────┘

体积影响对比

因素 静态库 动态库
未使用代码 链接器丢弃未引用的 .o 整个库都打进 Bundle
LTO 死代码消除 支持,可消除未使用的函数 不支持跨库消除
多 Target 场景 代码重复(每个 Target 一份) 代码只存一份
Strip 链接后可全局 Strip 只能 Strip 库自身的调试符号
压缩 (App Thinning) 主二进制参与整体压缩 每个 framework 独立压缩

九、总结

维度 胜出方 说明
启动速度 静态库 不增加 dyld 加载开销
包体积 (单 Target) 静态库 死代码消除 + LTO 优化
包体积 (多 Target) 动态库 代码共享避免重复
编译速度 动态库 增量编译不影响主二进制
符号安全 动态库 Two-Level Namespace 隔离
运行时稳定性 静态库 无 image not found 风险
部署复杂度 静态库 无需管签名和 Embed
代码优化程度 静态库 支持跨库 LTO

核心原则:除非有明确的跨 Target 代码共享需求(如 App Extension),否则优先选择静态库。iOS 嵌入式动态库不具备系统级共享优势,带来的启动开销往往得不偿失。

Matrix获取卡顿堆栈 (Point Stack)

作者 KangJX
2026年3月17日 17:23

matrix 是腾讯微信团队开源的一套移动端性能监控与分析框架,核心目标是帮助开发者定位、解决移动端(iOS/Android)应用的性能问题,是微信内部大规模验证过的成熟工具,本文通过阅读源码,详细介绍了针对卡顿日志获取的核心原理。

Matrix 通过周期性采集主线程堆栈并保存在循环数组中,在检测到卡顿时,使用 Point Stack 算法找出最有可能导致卡顿的堆栈。

核心思想

时间流逝
   ↓
每 50ms 采集一次主线程堆栈
   ↓
保存到循环数组(固定大小,如 20 个)
   ↓
检测到卡顿时
   ↓
分析循环数组,找出 Point Stack(最可能导致卡顿的堆栈)
   ↓
生成卡顿报告

设计目标

目标 实现方式
及时性 每 50ms 采集一次,不错过卡顿过程
完整性 保存一个周期内的所有堆栈(通常 20 个)
准确性 通过算法找出真正导致卡顿的堆栈
高效性 固定大小循环数组,避免内存膨胀
低开销 CPU 占用 < 3%,不影响用户体验

核心时间参数

三个关键参数

// 1. RunLoop 超时阈值(卡顿判定阈值)
static useconds_t g_RunLoopTimeOut = 2000000;  // 2000ms = 2秒
// 作用:超过此时间判定为卡顿

// 2. 检查周期(单次采集周期)
static useconds_t g_CheckPeriodTime = 1000000;  // 1000ms = 1秒
// 作用:一轮堆栈采集的总时间,通常为超时阈值的一半

// 3. 堆栈采集间隔
static useconds_t g_PerStackInterval = 50000;  // 50ms
// 作用:每次堆栈采集之间的时间间隔

参数关系

┌────────────────────────────────────────────────┐
│ g_RunLoopTimeOut (2秒) - 卡顿判定阈值          │
└────────────────────────────────────────────────┘
                    │
                    ├─ 一半
                    ↓
┌────────────────────────────────────────────────┐
│ g_CheckPeriodTime (1秒) - 检查周期             │
└────────────────────────────────────────────────┘
                    │
                    ├─ 除以
                    ↓
┌────────────────────────────────────────────────┐
│ g_PerStackInterval (50ms) - 堆栈间隔           │
└────────────────────────────────────────────────┘
                    ↓
┌────────────────────────────────────────────────┐
│ g_MainThreadCount = 1000ms / 50ms = 20         │
│ (循环数组大小 = 一个周期内采集的堆栈数量)      │
└────────────────────────────────────────────────┘

时间轴示意

时间线(以2秒卡顿为例):

T=0ms      开始监控
|
T=50ms     采集第1个堆栈 ← S0
|
T=100ms    采集第2个堆栈 ← S1
|
T=150ms    采集第3个堆栈 ← S2
|
...
|
T=950ms    采集第19个堆栈 ← S18
|
T=1000ms   采集第20个堆栈 ← S19  ← 完成一轮采集
|          ↓
|          检查是否卡顿(检查RunLoop执行时间)
|          如果未卡顿,进入下一轮采集
|
T=1050ms   采集第21个堆栈 ← S20(覆盖S0)
|
...
|
T=2000ms   检查发现 RunLoop 执行超过 2秒
|          ↓
|          触发卡顿检测
|          ↓
|          分析循环数组中的 20 个堆栈
|          ↓
|          找出 Point Stack(最可能导致卡顿的堆栈)
|          ↓
|          生成卡顿报告

堆栈获取流程

整体流程图

┌──────────────────────────────────────────┐
│ 监控线程主循环                            │
│ while (YES) {                            │
│   check();            // 检测卡顿         │recordCurrentStack(); // 采集堆栈       │
│ }                                        │
└──────────────────────────────────────────┘
                    ↓
┌──────────────────────────────────────────┐
│ recordCurrentStack() 方法                │
│                                          │
│ 外层循环:遍历检查周期                    │
│   nTotalCnt = m_nIntervalTime /         │
│               g_CheckPeriodTime         │
│   通常 = 1000ms / 1000ms = 1次          │
│                                          │
│   内层循环:在一个周期内多次采集          │
│     intervalCount = g_CheckPeriodTime / │
│                     g_PerStackInterval  │
│     通常 = 1000ms / 50ms = 20次         │
│                                          │
│     每次循环:                            │
│       1. usleep(50ms)  // 等待          │2. 获取主线程堆栈                   │
│       3. 添加到循环数组                   │
└──────────────────────────────────────────┘

详细步骤

步骤 1:初始化
// 在 WCBlockMonitorMgr 的 start 方法中
- (void)start {
    // 计算循环数组大小
    g_MainThreadCount = g_CheckPeriodTime / g_PerStackInterval;
    // 例如:1000ms / 50ms = 20
    
    // 创建主线程堆栈处理器
    m_pointMainThreadHandler = [[WCMainThreadHandler alloc] 
                                 initWithCycleArrayCount:g_MainThreadCount];
    
    // g_MainThreadCount = 20
    // 意味着循环数组可以保存 20 个堆栈
}
步骤 2:周期性采集
- (void)recordCurrentStack {
    // ================================================================
    // 外层循环:决定执行几个检查周期
    // ================================================================
    // 正常情况:m_nIntervalTime = 1000ms
    // 退火情况:m_nIntervalTime = 2000ms, 3000ms, 5000ms...
    unsigned long nTotalCnt = m_nIntervalTime / g_CheckPeriodTime;
    
    for (int nCnt = 0; nCnt < nTotalCnt && !m_bStop; nCnt++) {
        // 记录本轮开始时间(用于检测系统挂起)
        gettimeofday(&m_recordStackTime, NULL);
        
        if (g_MainThreadHandle) {
            // ========================================================
            // 内层循环:在一个检查周期内多次采集
            // ========================================================
            // intervalCount = 1000ms / 50ms = 20
            int intervalCount = g_CheckPeriodTime / g_PerStackInterval;
            
            for (int index = 0; index < intervalCount && !m_bStop; index++) {
                // 1️⃣ 等待 50ms
                usleep(g_PerStackInterval);  // 50000 微秒 = 50ms
                
                // 2️⃣ 分配内存
                size_t stackBytes = sizeof(uintptr_t) * g_StackMaxCount;
                uintptr_t *stackArray = (uintptr_t *)malloc(stackBytes);
                if (stackArray == NULL) {
                    continue;  // 内存分配失败,跳过本次
                }
                
                // 3️⃣ 初始化
                __block size_t nSum = 0;
                memset(stackArray, 0, stackBytes);
                
                // 4️⃣ 获取主线程堆栈
                [WCGetMainThreadUtil
                 getCurrentMainThreadStack:^(NSUInteger pc) {
                     stackArray[nSum] = (uintptr_t)pc;  // 保存程序计数器
                     nSum++;
                 }
                 withMaxEntries:g_StackMaxCount        // 最大100个栈帧
                 withThreadCount:g_CurrentThreadCount];
                
                // 5️⃣ 添加到循环数组
                [m_pointMainThreadHandler addThreadStack:stackArray 
                                           andStackCount:nSum];
                // 注意:stackArray 的所有权转移给 m_pointMainThreadHandler
            }
        }
        
        // ============================================================
        // 检测是否被系统挂起
        // ============================================================
        struct timeval tvCur;
        gettimeofday(&tvCur, NULL);
        unsigned long long diff = [WCBlockMonitorMgr diffTime:&m_recordStackTime 
                                                      endTime:&tvCur];
        
        if (diff > DETECTION_THREAD_JUDGE_SUSPEND_THRESHOLD) {
            // 实际消耗时间 > 10秒,说明被挂起了
            gettimeofday(&g_tvRun, NULL);  // 更新时间,避免误报
            MatrixInfo(@"挂起后运行,差值 %llu", diff);
            return;
        }
    }
}
步骤 3:获取主线程堆栈(底层实现)
// WCGetMainThreadUtil 内部使用 backtrace
+ (void)getCurrentMainThreadStack:(StackCallback)callback 
                   withMaxEntries:(size_t)maxEntries
                  withThreadCount:(NSUInteger)threadCount {
    // 1. 获取主线程
    thread_t mainThread = pthread_mach_thread_np(pthread_main_thread_np());
    
    // 2. 暂停主线程(非常短暂,微秒级)
    thread_suspend(mainThread);
    
    // 3. 获取线程状态
    _STRUCT_MCONTEXT machineContext;
    mach_msg_type_number_t state_count = THREAD_STATE_COUNT;
    kern_return_t kr = thread_get_state(mainThread,
                                        THREAD_STATE,
                                        (thread_state_t)&machineContext.__ss,
                                        &state_count);
    
    // 4. 回溯堆栈
    if (kr == KERN_SUCCESS) {
        uintptr_t backtraceBuffer[maxEntries];
        size_t backtraceLength = ksbt_backtraceLength(&machineContext);
        
        // 遍历堆栈帧
        for (size_t i = 0; i < backtraceLength && i < maxEntries; i++) {
            uintptr_t pc = ksbt_framePointer(&machineContext, i);
            callback(pc);  // 回调传递每个栈帧的地址
        }
    }
    
    // 5. 恢复主线程
    thread_resume(mainThread);
}

循环数组存储机制

数据结构设计

@interface WCMainThreadHandler {
    // ================================================================
    // 循环数组配置
    // ================================================================
    int m_cycleArrayCount;  // 数组大小,例如 20
    
    // ================================================================
    // 循环数组(核心存储结构)
    // ================================================================
    uintptr_t **m_mainThreadStackCycleArray;  // 二维数组
    // 第一维:堆栈索引 [0, 19]
    // 第二维:堆栈地址数组 uintptr_t[]
    
    size_t *m_mainThreadStackCount;  // 每个堆栈的深度
    // 例如:[50, 48, 52, ..., 45]
    
    uint64_t m_tailPoint;  // 尾指针,指向下一个写入位置
    
    // ================================================================
    // 分析数据(用于 Point Stack 算法)
    // ================================================================
    size_t *m_topStackAddressRepeatArray;  // 栈顶地址连续重复次数
    // 例如:[0, 1, 2, 0, 1, 0, ...]
    
    int *m_mainThreadStackRepeatCountArray;  // Point Stack 地址总重复次数
    // 动态分配,在找到 Point Stack 后创建
}

循环数组可视化

初始化状态(m_cycleArrayCount = 5):

索引:    0     1     2     3     4
       ┌─────┬─────┬─────┬─────┬─────┐
数组:   │NULL │NULL │NULL │NULL │NULL │
       └─────┴─────┴─────┴─────┴─────┘
          ↑
     m_tailPoint = 0


添加第 1 个堆栈(S0):

索引:    0     1     2     3     4
       ┌─────┬─────┬─────┬─────┬─────┐
数组:   │ S0  │NULL │NULL │NULL │NULL │
       └─────┴─────┴─────┴─────┴─────┘
               ↑
          m_tailPoint = 1


添加第 2-5 个堆栈:

索引:    0     1     2     3     4
       ┌─────┬─────┬─────┬─────┬─────┐
数组:   │ S0  │ S1  │ S2  │ S3  │ S4  │
       └─────┴─────┴─────┴─────┴─────┘
          ↑
     m_tailPoint = 0 (回绕)


添加第 6 个堆栈(S5,覆盖 S0):

索引:    0     1     2     3     4
       ┌─────┬─────┬─────┬─────┬─────┐
数组:   │ S5  │ S1  │ S2  │ S3  │ S4  │
       └─────┴─────┴─────┴─────┴─────┘
               ↑
          m_tailPoint = 1

时间顺序:S1 → S2 → S3 → S4 → S5(最新)

添加堆栈的实现

- (void)addThreadStack:(uintptr_t *)stackArray 
         andStackCount:(size_t)stackCount {
    if (stackArray == NULL) {
        return;
    }
    
    pthread_mutex_lock(&m_threadLock);
    
    // ================================================================
    // 1. 将堆栈写入循环数组
    // ================================================================
    
    // 如果当前位置已有堆栈,先释放旧的
    if (m_mainThreadStackCycleArray[m_tailPoint] != NULL) {
        free(m_mainThreadStackCycleArray[m_tailPoint]);
    }
    
    // 保存新堆栈
    m_mainThreadStackCycleArray[m_tailPoint] = stackArray;
    m_mainThreadStackCount[m_tailPoint] = stackCount;
    
    // ================================================================
    // 2. 统计栈顶地址连续重复次数(核心!)
    // ================================================================
    
    // 计算上一个位置的索引
    uint64_t lastTailPoint = (m_tailPoint + m_cycleArrayCount - 1) % m_cycleArrayCount;
    
    // 获取上一个堆栈的栈顶地址
    uintptr_t lastTopStack = 0;
    if (m_mainThreadStackCycleArray[lastTailPoint] != NULL) {
        lastTopStack = m_mainThreadStackCycleArray[lastTailPoint][0];
    }
    
    // 获取当前堆栈的栈顶地址
    uintptr_t currentTopStackAddr = stackArray[0];
    
    // 比较栈顶地址
    if (lastTopStack == currentTopStackAddr) {
        // 栈顶地址相同,累加重复次数
        size_t lastRepeatCount = m_topStackAddressRepeatArray[lastTailPoint];
        m_topStackAddressRepeatArray[m_tailPoint] = lastRepeatCount + 1;
    } else {
        // 栈顶地址不同,重置重复次数
        m_topStackAddressRepeatArray[m_tailPoint] = 0;
    }
    
    // ================================================================
    // 3. 移动尾指针
    // ================================================================
    
    m_tailPoint = (m_tailPoint + 1) % m_cycleArrayCount;
    
    pthread_mutex_unlock(&m_threadLock);
}

栈顶地址重复次数统计示例

假设连续采集到以下堆栈(简化为栈顶地址):

时间: T0   T50  T100 T150 T200 T250 T300 T350 T400
索引:  0    1    2    3    4    5    6    7    8
堆栈: S0   S1   S2   S3   S4   S5   S6   S7   S8
栈顶: A    B    C    C    C    C    C    D    D

m_topStackAddressRepeatArray 的值:
     [0,   0,   0,   1,   2,   3,   4,   0,   1]
      ↑    ↑    ↑    ↑    ↑    ↑    ↑    ↑    ↑
      A    B    C   CCCCD   D重
     首次  首次 首次  复2345  首次  复2

分析:
- S6(索引6)的栈顶地址 C 连续重复了 4 次(从 S2S6- 说明主线程在函数 C 上停留了 5 × 50ms = 250ms
- S6 就是最有可能导致卡顿的堆栈(Point Stack

什么是 Point Stack?

Point Stack(关键堆栈) 是指在一个检查周期内,最有可能导致卡顿的主线程堆栈

一、核心数据结构

1. 循环数组配置

变量名 类型 说明
m_cycleArrayCount int 循环数组大小(例如:20)
m_tailPoint uint64_t 循环数组尾指针,指向下一个写入位置
pthread_mutex_t m_threadLock 线程锁,保护循环数组的并发访问

循环数组原理:

数组大小 = 检查周期 / 堆栈间隔
例如:1000ms / 50ms = 20

索引:    0    1    2    3    4    ...   19
       ┌────┬────┬────┬────┬────┬ ─ ─ ┬────┐
堆栈:   │ S0 │ S1 │ S2 │    │    │     │    │
       └────┴────┴────┴─▲──┴────┴ ─ ─ ┴────┘
                        │
                   m_tailPoint

当数组满时,从头开始覆盖(FIFO)

2. 堆栈存储数组(二维数组)

变量名 类型 维度 说明
m_mainThreadStackCycleArray uintptr_t ** [cycleArrayCount][stackDepth] 堆栈地址二维数组
m_mainThreadStackCount size_t * [cycleArrayCount] 每个堆栈的深度数组

数据结构示意:

m_mainThreadStackCycleArray:
  [0] → [0x1000, 0x2000, 0x3000, ...]  // 第0个堆栈,深度=3
  [1] → [0x1000, 0x2000, 0x3000, ...]  // 第1个堆栈,深度=3
  [2] → [0x1000, 0x2000, 0x4000, ...]  // 第2个堆栈,深度=3
  ...
  [19] → NULL                          // 尚未写入

m_mainThreadStackCount:
  [0] = 3   // 第0个堆栈深度
  [1] = 3   // 第1个堆栈深度
  [2] = 3   // 第2个堆栈深度
  ...
  [19] = 0  // 尚未写入

3. 栈顶重复次数数组

变量名 类型 说明
m_topStackAddressRepeatArray size_t * 每个堆栈的栈顶地址连续重复次数

用途: 找出 Point Stack(栈顶重复次数最多的堆栈)

数据示例:

假设连续采集的堆栈栈顶地址:
索引:    0      1      2      3      4
栈顶:    A      A      A      B      B

m_topStackAddressRepeatArray:
       [0]    [1]    [2]    [3]    [4]
       0      1      2      0      1

解释:
- 索引0: 第一次出现A,重复0- 索引1: 第二次出现A(与前一个相同),重复1- 索引2: 第三次出现A(与前一个相同),重复2- 索引3: 出现B(改变了),重复0- 索引4: 第二次出现B(与前一个相同),重复1次

结果:索引2的重复次数最多(2次),所以索引2Point Stack

4. Point Stack地址重复次数数组

变量名 类型 说明
m_mainThreadStackRepeatCountArray int * Point Stack中每个地址的总重复次数(动态分配)

用途: 统计 Point Stack 中每个地址在所有堆栈中的总出现次数,识别热点函数

数据示例:

假设有4个堆栈,Point Stack是索引2Stack 0:        Stack 1:        Stack 2(Point):  Stack 3:
0x1000          0x1000          0x1000          0x1000
0x2000          0x2000          0x2000          0x2000
0x3000          0x3000          0x3000          0x4000
0x4000          0x5000          0x6000

Point Stack (索引2) 的地址:
  [0] = 0x1000
  [1] = 0x2000
  [2] = 0x3000

统计结果 m_mainThreadStackRepeatCountArray:
  [0] = 4   // 0x1000 在4个堆栈中都出现
  [1] = 4   // 0x2000 在4个堆栈中都出现
  [2] = 3   // 0x3000 在3个堆栈中出现

符号化后:
  [0] main           (4次) ← 所有堆栈都有,基础函数
  [1] viewDidLoad    (4次) ← 所有堆栈都有,入口函数
  [2] heavyWork      (3次) ← 75%的时间在这里,瓶颈!⚠️

image.png

算法流程

总体流程图

开始
  ↓
1. 查找最大重复次数
  ↓
2. 按时间顺序找出第一个等于最大值的堆栈索引
  ↓
3. 复制 Point Stack
  ↓
4. 计算 Point Stack 中每个地址的总重复次数
  ↓
5. 创建 KSStackCursor 并返回
  ↓
结束

步骤 1:查找最大重复次数

目的: 找出 m_topStackAddressRepeatArray 中的最大值。

size_t maxValue = 0;
BOOL trueStack = NO;

// 第一次遍历:只找最大值(不记录索引)
for (int i = 0; i < m_cycleArrayCount; i++) {
    size_t currentValue = m_topStackAddressRepeatArray[i];
    if (currentValue >= maxValue) {
        maxValue = currentValue;
        trueStack = YES;
    }
}

if (!trueStack) {
    return NULL;  // 没有有效堆栈
}

步骤 2:找出 Point Stack 的索引

目的: 按时间顺序(从新到旧)找第一个重复次数等于 maxValue 的堆栈。

size_t currentIndex = (m_tailPoint + m_cycleArrayCount - 1) % m_cycleArrayCount;

// 第二次遍历:按时间顺序(从新到旧)
for (int i = 0; i < m_cycleArrayCount; i++) {
    // 计算真实索引
    int trueIndex = (m_tailPoint + m_cycleArrayCount - i - 1) % m_cycleArrayCount;
    
    // 找到第一个等于最大值的
    if (m_topStackAddressRepeatArray[trueIndex] == maxValue) {
        currentIndex = trueIndex;
        break;  // 找到最新的,立即停止
    }
}

索引计算公式:

trueIndex = (m_tailPoint + m_cycleArrayCount - i - 1) % m_cycleArrayCount

参数说明:
- m_tailPoint: 下一个要写入的位置
- i: 遍历变量(0 = 最新,1 = 次新,...)
- m_cycleArrayCount: 数组大小(如20)

例子:
假设 m_tailPoint = 1, m_cycleArrayCount = 5

i=0: trueIndex = (1+5-0-1) % 5 = 0  → 最新堆栈
i=1: trueIndex = (1+5-1-1) % 5 = 4  → 次新堆栈
i=2: trueIndex = (1+5-2-1) % 5 = 3  → 第三新堆栈
i=3: trueIndex = (1+5-3-1) % 5 = 2  → 第四新堆栈
i=4: trueIndex = (1+5-4-1) % 5 = 1  → 最旧堆栈(空)

步骤 3:复制 Point Stack

size_t stackCount = m_mainThreadStackCount[currentIndex];
size_t pointThreadSize = sizeof(uintptr_t) * stackCount;
uintptr_t *pointThreadStack = (uintptr_t *)malloc(pointThreadSize);

// 复制堆栈地址
for (size_t idx = 0; idx < stackCount; idx++) {
    pointThreadStack[idx] = m_mainThreadStackCycleArray[currentIndex][idx];
}

步骤 4:计算地址总重复次数

三层循环统计:

// 分配重复次数数组
m_mainThreadStackRepeatCountArray = (int *)malloc(stackCount * sizeof(int));
memset(m_mainThreadStackRepeatCountArray, 0, stackCount * sizeof(int));

// 外层循环:遍历 Point Stack 的每个地址
for (size_t i = 0; i < stackCount; i++) {
    uintptr_t targetAddress = m_mainThreadStackCycleArray[currentIndex][i];
    
    // 中层循环:遍历循环数组中的每个堆栈
    for (int innerIndex = 0; innerIndex < m_cycleArrayCount; innerIndex++) {
        size_t innerStackCount = m_mainThreadStackCount[innerIndex];
        
        // 内层循环:遍历当前堆栈的每个地址
        for (size_t idx = 0; idx < innerStackCount; idx++) {
            // 比较是否匹配
            if (targetAddress == m_mainThreadStackCycleArray[innerIndex][idx]) {
                m_mainThreadStackRepeatCountArray[i] += 1;
            }
        }
    }
}

算法分析:

  • 时间复杂度:O(n × m × k)
    • n = Point Stack 深度(通常 < 100)
    • m = 循环数组大小(通常 20)
    • k = 平均堆栈深度(通常 < 50)
  • 实际数据量很小,性能可接受

步骤 5:创建 KSStackCursor

KSStackCursor *pointCursor = (KSStackCursor *)malloc(sizeof(KSStackCursor));
kssc_initWithBacktrace(pointCursor, pointThreadStack, (int)stackCount, 0);
return pointCursor;

作用: 将原始堆栈数组包装成 KSCrash 能使用的标准格式。


至于堆栈的获取,可以参考我的另一篇文章ARM64 调用栈回溯原理

移动应用上架到应用商店的完整指南:原理与详细步骤

2026年3月17日 14:03

随着智能手机的普及,移动应用程序(App)已经成为人们日常生活中必不可少的一部分。而将自己的App上架到应用商店则是许多开发者的梦想,因为这意味着他们的作品可以被更多人看到、下载和使用。本文将介绍App上架到应用商店的原理和详细步骤。

一、App上架的原理

App上架到应用商店的原理可以简单概括为:开发者将开发好的App上传到应用商店,应用商店审核通过后将App发布到应用商店。在这个过程中,开发者需要遵守应用商店的规定和要求,以确保App能够通过审核并成功上架。

具体来说,开发者需要准备好以下内容:

  1. 应用商店账号:开发者需要在目标应用商店注册一个账号,并遵守该应用商店的规定和要求。

  2. App信息:开发者需要提供App的名称、描述、图标、版本号、支持的设备类型等信息。

  3. App安装包:开发者需要将App打包成符合应用商店要求的安装包,并上传到应用商店。对于iOS应用,可以使用AppUploader等工具在Windows、Linux或Mac系统中上传IPA文件到App Store,无需Mac电脑即可操作,比传统方法更高效。

  4. 证书和签名:开发者需要使用证书和签名对App进行加密和验证,以确保App的安全性和可靠性。使用工具如AppUploader可以简化iOS证书的申请和签名过程,支持多电脑协同,无需钥匙串助手。

  5. 测试和调试:开发者需要对App进行测试和调试,以确保App的质量和稳定性。

二、App上架的详细步骤

  1. 注册应用商店账号

开发者需要在目标应用商店注册一个账号,以便上传App和管理App的信息。不同的应用商店可能有不同的注册流程和要求,开发者需要仔细阅读应用商店的注册指南,并提供必要的信息和证明文件。

  1. 准备App信息

开发者需要准备好App的名称、描述、图标、版本号、支持的设备类型等信息。这些信息将在应用商店中展示,并影响用户对App的印象和选择。

  1. 打包App安装包

开发者需要将App打包成符合应用商店要求的安装包,并上传到应用商店。不同的应用商店可能有不同的安装包要求,开发者需要仔细阅读应用商店的指南,并使用合适的工具和方法进行打包。AppUploader支持快速上传IPA文件,并内置工具查看和编辑相关文件内容。

  1. 证书和签名

开发者需要使用证书和签名对App进行加密和验证,以确保App的安全性和可靠性。证书和签名的获取和使用也可能因应用商店的不同而有所差异,开发者需要仔细阅读应用商店的指南,并按照要求进行操作。利用AppUploader,开发者可以直接创建和管理iOS证书,简化流程。

  1. 测试和调试

开发者需要对App进行测试和调试,以确保App的质量和稳定性。测试和调试的过程可能会涉及多个设备和操作系统,开发者需要尽可能模拟用户的使用场景,并记录和解决问题。AppUploader提供USB和二维码安装测试功能,方便在iOS设备上验证应用。

  1. 提交审核

开发者需要将准备好的App信息、安装包、证书和签名上传到应用商店,并提交审核。审核的过程可能需要几天甚至几周的时间,开发者需要耐心等待,并及时响应应用商店的反馈和要求。

  1. 上架发布

审核通过后,应用商店会将App发布到应用商店,供用户下载和使用。开发者需要及时更新App的信息和版本,并处理用户的反馈和问题。

总之,将App上架到应用商店需要开发者投入大量时间和精力,需要遵守应用商店的规定和要求,并保证App的质量和安全性。只有经过认真准备和审核,才能让自己的App在应用商店中脱颖而出,成为用户喜爱的产品。

Xcode SPM 太慢/报错?代理 + 缓存修复

作者 iOS日常
2026年3月17日 11:24

SPM 加速:终端代理

在终端执行(端口如 7890 按自己改):

export https_proxy=http://127.0.0.1:7890
export http_proxy=http://127.0.0.1:7890
cd /path/to/your/project
xcodebuild -resolvePackageDependencies

报 fatalError 时

错误里会带类似 FloatingPanel-f92b491a 的路径,删掉该缓存再重试:

rm -rf ~/Library/Caches/org.swift.swiftpm/repositories/FloatingPanel-f92b491a

多个包都报错就清空整个缓存:

rm -rf ~/Library/Caches/org.swift.swiftpm/repositories/*

然后重新执行 xcodebuild -resolvePackageDependencies

isa 指针、元类、继承链

作者 泉木
2026年3月17日 09:58

一、isa 不只是一个指针

在 64 位设备上,指针只需要 36~40 位就能表示所有内存地址。苹果觉得剩下的位浪费了,于是把 isa 设计成了一个 union(联合体) ,把类指针和一堆标志位都塞进了这 64 位里。

这叫 Tagged Pointer / Non-pointer ISA 技术。


二、isa_t 的完整源码

// 文件:objc-private.h
union isa_t {
    isa_t() { }
    isa_t(uintptr_t value) : bits(value) { }

    uintptr_t bits;         // 原始的 64 位值

private:
    Class cls;              // 类指针(只在 non-pointer isa 关闭时使用)

public:
#if defined(ISA_BITFIELD)
    struct {
        ISA_BITFIELD;       // 展开后是一堆位域定义
    };
    ...
};

ISA_BITFIELD 展开(ARM64,iOS 真机)

// 这是 ARM64 的位域定义
uintptr_t nonpointer        : 1;   // bit 0
uintptr_t has_assoc         : 1;   // bit 1
uintptr_t has_cxx_dtor      : 1;   // bit 2
uintptr_t shiftcls          : 33;  // bit 3~35  ← 类指针在这里!
uintptr_t magic             : 6;   // bit 36~41
uintptr_t weakly_referenced : 1;   // bit 42
uintptr_t unused            : 1;   // bit 43
uintptr_t has_sidetable_rc  : 1;   // bit 44
uintptr_t extra_rc          : 19;  // bit 45~63

三、每一位的含义逐个解释

bit 0:nonpointer

uintptr_t nonpointer : 1;

含义: 这个 isa 是不是 "non-pointer isa"(优化过的 isa)。

  • 0:纯指针,整个 64 位就是类地址(老设备/某些特殊情况)
  • 1:non-pointer isa,64 位里藏了很多信息

现代 iOS 设备全是 1


bit 1:has_assoc

uintptr_t has_assoc : 1;

含义: 这个对象是否有关联对象(Associated Object)。

关联对象就是你用 objc_setAssociatedObject 给对象动态绑定的数据。

为什么需要这一位?

  • 对象 dealloc 时,runtime 需要清理关联对象
  • 用这一位做快速判断:has_assoc == 0 → 跳过关联对象清理,直接释放,更快

bit 2:has_cxx_dtor

uintptr_t has_cxx_dtor : 1;

含义: 这个类(或它的父类)是否有 C++ 析构函数,或者 OC 的 .cxx_destruct 方法。

.cxx_destruct 是编译器自动生成的方法,用来清理带有 __strong 修饰的成员变量(ARC 下自动 release)。

为什么需要这一位?

  • 对象 dealloc 时,如果没有需要清理的 C++ 对象,就跳过 .cxx_destruct 调用
  • 优化释放速度

bit 3~35:shiftcls(33位)

uintptr_t shiftcls : 33;

含义: 这 33 位才是真正的类指针(右移 3 位存储,取的时候左移 3 位还原)。

为什么只用 33 位?因为 ARM64 的内存对齐保证类地址的低 3 位永远是 0,可以省掉。

如何取出类指针?

// runtime 内部的取法
Class getClass() const {
    return (Class)(shiftcls << 3);  // 左移3位还原真实地址
}

bit 36~41:magic(6位)

uintptr_t magic : 6;

含义: 固定的魔数,值是 0b011010(十进制 26)。

用途: 调试用。当你看到一个 isa,如果 magic 值不对,说明这个对象已经被释放或内存被踩了(野指针)。Xcode 和 runtime 的断言会检查这个值。


bit 42:weakly_referenced

uintptr_t weakly_referenced : 1;

含义: 这个对象是否被弱引用__weak 指针)指向过。

为什么需要这一位?

  • 对象 dealloc 时,如果有弱引用指向它,需要去 SideTable(全局散列表)里把那些弱引用都清零(避免 dangling pointer)
  • 用这一位快速判断:weakly_referenced == 0 → 跳过 SideTable 查找,直接释放

bit 43:unused

uintptr_t unused : 1;

含义: 目前未使用,预留位。


bit 44:has_sidetable_rc

uintptr_t has_sidetable_rc : 1;

含义: 引用计数是否溢出到了 SideTable。

正常情况下,引用计数存在 isa 的 extra_rc 里(19位,最大能存 2^19 - 1 = 524287)。如果引用计数超过了这个值,has_sidetable_rc = 1,多出来的部分存在全局的 SideTable 里。


bit 45~63:extra_rc(19位)

uintptr_t extra_rc : 19;

含义: 存储对象的引用计数 - 1

为什么是减 1?因为对象存活时引用计数至少为 1,存 0 代表计数是 1,节省一点空间。

实际的引用计数 = extra_rc + 1(如果 has_sidetable_rc == 0)


四、如何取出 isa 里的类指针(实际代码)

// objc-object.h
inline Class objc_object::getIsa() {
    if (fastpath(!isTaggedPointer())) {
        return ISA();
    }
    // ... TaggedPointer 的特殊处理
}

inline Class objc_object::ISA(bool authenticated) {
    ASSERT(!isTaggedPointer()); 
#if SUPPORT_INDEXED_ISA
    // 某些架构用索引
    ...
#else
    // ARM64 主路径:取 shiftcls 位,左移3位还原地址
    return (Class)(isa.bits & ISA_MASK);
#endif
}

其中 ISA_MASK 在 ARM64 是 0x0000000ffffffff8ULL,作用就是取 bit 3~35。


五、元类(Metaclass)是什么?

这是 OC 最难理解的概念之一,但其实逻辑非常自洽。

问题的由来

在 OC 里,"一切皆对象"——包括类本身也是对象。

[NSString class]  // 这返回的是一个对象
[NSString stringWithString:@"hello"]  // 这是给"类对象"发消息

既然类也是对象,那类对象的 isa 指向哪里?

答案就是:元类(metaclass)

元类的定义

元类是"类的类"。它存储的是类方法+ 方法),就像普通类存储实例方法(- 方法)一样。

对比:类 vs 元类

普通类(Class) 元类(Metaclass)
本质 objc_class 结构体 也是 objc_class 结构体
方法列表里存的 实例方法(- 类方法(+
isa 指向 元类 根元类(NSObject 的元类)
superclass 指向 父类 父类的元类

六、完整的 isa + 继承链图

这是 OC 里最经典的一张图,一定要理解它:

                 isa                  isa               isa
实例对象(inst) --------→ 类(MyClass) --------→ 元类(Meta-MyClass) ──→ 根元类
                                                                          │
              superclass              superclass             superclass    │ isa(自指)
         MyClass ───────→ NSObject     Meta-MyClass ──────→ Meta-NSObject─┘
                               │                                  │
                               │ superclass = nil                 │ superclass
                               ↓                                  ↓
                             (nil)                             NSObject(不是元类!)

用文字描述:

  1. 实例对象.isaMyClass(类)
  2. MyClass.isaMeta-MyClass(元类)
  3. Meta-MyClass.isaMeta-NSObject(根元类)
  4. Meta-NSObject.isaMeta-NSObject自指!根元类的 isa 指向自己

继承链:

  1. MyClass.superclassNSObject
  2. NSObject.superclassnil
  3. Meta-MyClass.superclassMeta-NSObject(元类也有继承链)
  4. Meta-NSObject.superclassNSObject元类继承链的终点是 NSObject 类,不是 nil!

七、为什么元类的继承链终点是 NSObject?

这个设计让你可以在任何类方法里调用 NSObject 的实例方法(比如 respondsToSelector:)。

// 这为什么能工作?
[MyClass respondsToSelector:@selector(doSomething)];

+respondsToSelector: 是 NSObject 的实例方法(- 方法),存在 NSObject 类里。
当你给 MyClass 发这个消息,runtime 查找路径:

Meta-MyClass(没有)
    → Meta-NSObject(没有)
        → NSObject(在这找到了!)

因为 Meta-NSObject.superclass = NSObject,所以元类链最终能访问到 NSObject 的实例方法。优雅!


八、TaggedPointer:特殊的对象

不是所有"对象"都是真正的对象(有 isa 的结构体)。

什么是 TaggedPointer?

对于一些小值对象(比如 NSNumberNSDate、小字符串),苹果直接把值编码进指针本身,不分配堆内存。

NSNumber *num = @42;
// 在 64 位下,这个指针可能长这样:
// 0xb000000000000162  (不是真实的堆地址!)
// 最高位 1 = TaggedPointer 标志
// 低位存了 42 这个值

判断是否是 TaggedPointer

static inline bool _objc_isTaggedPointer(const void * _Nullable ptr) {
    return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK;
}
// ARM64: _OBJC_TAG_MASK = (1UL<<63),最高位为1就是 TaggedPointer

TaggedPointer 的好处

  • 不需要堆分配:直接在指针里存值,alloc 时不走 malloc
  • 不需要引用计数:也不需要 release,直接丢弃
  • 更快:少了内存分配和释放的开销

九、SideTable:引用计数和弱引用的大本营

当 isa 的 extra_rc 不够用,或者有弱引用时,数据存在 SideTable 里。

struct SideTable {
    spinlock_t slock;           // 自旋锁,保证线程安全
    RefcountMap refcnts;        // 引用计数表(散列表)
    weak_table_t weak_table;    // 弱引用表
};

全局有 8 个(或 64 个)SideTable,通过对象地址取模来分配,减少锁竞争。

weak_table_t 弱引用表

struct weak_table_t {
    weak_entry_t *weak_entries;  // 弱引用条目数组
    size_t num_entries;
    ...
};

struct weak_entry_t {
    DisguisedPtr<objc_object> referent;  // 被指向的对象
    // 指向该对象的所有 __weak 指针地址的集合
    union {
        struct { weak_referrer_t *referrers; ... };
        struct { weak_referrer_t inline_referrers[WEAK_INLINE_COUNT]; };
    };
};

__weak 置零的过程

对象 dealloc
    ↓
检查 isa.weakly_referenced
    ↓(== 1)
去 SideTable 找 weak_entry_t
    ↓
遍历所有指向该对象的 __weak 指针
    ↓
全部置 nil
    ↓
从 weak_table 删除该条目

这就是为什么 __weak 指针在对象释放后自动变成 nil,而不会变成野指针——runtime 帮你清零了。


十、小结

概念 本质 存在哪里
isa 64位 union,含类指针+引用计数+标志位 每个对象的第一个字段
元类 存类方法的 objc_class 全局静态区
TaggedPointer 值直接编码进指针,无堆对象 栈/寄存器
extra_rc 引用计数(-1)的快速存储 isa 的高19位
SideTable 溢出引用计数 + 弱引用表 全局散列表

下一篇:延伸问题 Q&A——消息发送、方法查找、Swizzle、dealloc 全流程等

objc_class 结构体逐行解析

作者 泉木
2026年3月16日 20:05

前言

objc_class 开始,是因为它是整个 Runtime 的基础数据结构。Runtime 管的事很多——消息发送、方法查找、内存管理、Category 加载……但这些行为最终都要落在"类长什么样"上面。搞清楚 objc_class,后面的东西才能接得上。

一、源码全貌(先看完整结构)

下面是从 Apple 开源的 objc4 里提取的核心结构体,我做了适度精简,保留所有关键字段。

建议先整体扫一遍,有个印象,后面逐个解释。

// ============================================================
// 文件:objc-runtime-new.h(objc4-818.2)
// 源码地址:https://opensource.apple.com/source/objc4/
// ============================================================

// -------------------- 1. objc_object --------------------
// 所有 OC 对象的基类,只有一个字段:isa
struct objc_object {
private:
    isa_t isa;  // 64位,包含类指针+引用计数+标志位

public:
    Class ISA(bool authenticated = false);
    Class getIsa();
    // ... 省略其他方法
};


// -------------------- 2. objc_class --------------------
// 这就是"类"的底层结构,继承自 objc_object
struct objc_class : objc_object {
    // 注意:isa 字段继承自 objc_object,这里不重复写
    
    Class superclass;           // 父类指针
    cache_t cache;              // 方法缓存(哈希表)
    class_data_bits_t bits;     // 指向 class_rw_t 的指针+标志位

    // 取出真正的数据
    class_rw_t *data() const {
        return bits.data();
    }
    // ... 省略其他方法
};


// -------------------- 3. class_data_bits_t --------------------
// 这是 objc_class.bits 的类型,用来存储指向 class_rw_t 的指针 + 几个标志位
struct class_data_bits_t {
private:
    uintptr_t bits;   // 就是一个 64 位整数,低位藏标志位,高位存指针

public:
    // 用掩码取出真正的 class_rw_t 指针
    class_rw_t *data() const {
        return (class_rw_t *)(bits & FAST_DATA_MASK);
    }

    // 各种标志位的读取方法
    bool isSwiftLegacy() const {
        return getBit(FAST_IS_SWIFT_LEGACY);
    }
    bool isSwiftStable() const {
        return getBit(FAST_IS_SWIFT_STABLE);
    }
    // ... 其他方法
};

// ARM64 下的掩码和标志位定义:
// FAST_DATA_MASK      = 0x00007ffffffffff8UL  (取 bit 3~46,即真正的指针)
// FAST_IS_SWIFT_LEGACY = 1 << 0  (bit 0: 是否是旧版 Swift 类)
// FAST_IS_SWIFT_STABLE = 1 << 1  (bit 1: 是否是新版 Swift 类)
// FAST_HAS_DEFAULT_RR  = 1 << 2  (bit 2: 是否有默认的 retain/release)


// -------------------- 4. cache_t --------------------
// 方法缓存,加速方法查找
struct cache_t {
private:
    explicit_atomic<uintptr_t> _bucketsAndMaybeMask;  // 桶数组地址
    union {
        struct {
            explicit_atomic<mask_t>    _maybeMask;   // 桶数量-1(用于哈希取模)
            uint16_t                   _flags;
            uint16_t                   _occupied;    // 已使用的桶数
        };
        explicit_atomic<preopt_cache_t *> _originalPreoptCache;
    };
public:
    // ... 省略查找、插入方法
};

// 单个缓存桶
struct bucket_t {
private:
    explicit_atomic<SEL> _sel;       // 方法名(选择子)
    explicit_atomic<uintptr_t> _imp; // 函数指针(方法实现地址)
};


// -------------------- 5. class_rw_t --------------------
// 运行时可读写数据(Category 方法会合并到这里)
struct class_rw_t {
    uint32_t flags;
    uint16_t witness;
    uint16_t index;

    explicit_atomic<uintptr_t> ro_or_rw_ext;  // 指向 class_ro_t 或扩展数据

    Class firstSubclass;       // 第一个子类
    Class nextSiblingClass;    // 兄弟类(形成链表)

    // 获取方法/属性/协议列表
    const method_array_t methods() const;
    const property_array_t properties() const;
    const protocol_array_t protocols() const;

    // 获取只读数据
    const class_ro_t *ro() const;
};


// -------------------- 6. class_ro_t --------------------
// 编译期只读数据(源码里写死的方法、变量、属性)
struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart;      // 实例变量起始偏移
    uint32_t instanceSize;       // sizeof(实例),对象占多少字节

    const uint8_t * ivarLayout;  // 强引用 ivar 的内存布局

    const char * name;           // 类名字符串,如 "NSString"
    
    WrappedPtr<method_list_t, method_list_t::Ptrauth> baseMethods;  // 方法列表
    protocol_list_t * baseProtocols;     // 协议列表
    const ivar_list_t * ivars;           // 实例变量列表
    
    const uint8_t * weakIvarLayout;      // 弱引用 ivar 的内存布局
    property_list_t *baseProperties;     // 属性列表
};


// -------------------- 7. method_t --------------------
// 单个方法的描述
struct method_t {
    SEL name;              // 方法名(选择子),本质是 const char *
    const char *types;     // 类型编码,如 "v16@0:8"
    IMP imp;               // 函数指针(真正的代码地址)
};


// -------------------- 8. ivar_t --------------------
// 单个实例变量的描述
struct ivar_t {
    int32_t *offset;       // 偏移量指针(Non-Fragile ABI 用)
    const char *name;      // 变量名,如 "_name"
    const char *type;      // 类型编码,如 "@"NSString""
    uint32_t alignment_raw;// 对齐方式
    uint32_t size;         // 占多少字节
};


// -------------------- 9. isa_t --------------------
// isa 的真正定义(union,64位里塞了很多信息)
union isa_t {
    uintptr_t bits;        // 原始64位值

    // ARM64 位域展开(iOS 真机):
    struct {
        uintptr_t nonpointer        : 1;   // bit 0:  是否是优化过的 isa
        uintptr_t has_assoc         : 1;   // bit 1:  有关联对象?
        uintptr_t has_cxx_dtor      : 1;   // bit 2:  有 C++ 析构?
        uintptr_t shiftcls          : 33;  // bit 3-35:  类指针(右移3位存储)
        uintptr_t magic             : 6;   // bit 36-41: 固定值 0x1a,调试用
        uintptr_t weakly_referenced : 1;   // bit 42: 被弱引用?
        uintptr_t unused            : 1;   // bit 43: 未使用
        uintptr_t has_sidetable_rc  : 1;   // bit 44: 引用计数溢出到 SideTable?
        uintptr_t extra_rc          : 19;  // bit 45-63: 引用计数-1
    };
};

二、结构关系图

objc_class(一个类在内存里的样子)
┌─────────────────────────────────────┐
│  isa (继承自 objc_object)           │ ← isa_t union,64位
├─────────────────────────────────────┤
│  superclass                         │ ← 指向父类的 objc_class
├─────────────────────────────────────┤
│  cache                              │ ← cache_t 结构体
│    └── bucket_t[] 数组              │     每个桶存 { SEL, IMP }
├─────────────────────────────────────┤
│  bits                               │ ← class_data_bits_t(指针+标志位)
│    └── data() ───────────────────────────→ class_rw_t(运行时可写)
│                                     │        ├── methods()
│                                     │        ├── properties()
│                                     │        ├── protocols()
│                                     │        └── ro() ────────→ class_ro_t(只读)
│                                     │                             ├── name
│                                     │                             ├── baseMethods
│                                     │                             │     └── method_t[]
│                                     │                             ├── ivars
│                                     │                             │     └── ivar_t[]
│                                     │                             └── baseProperties
└─────────────────────────────────────┘

三、逐结构体解析

接下来按源码出现的顺序,逐个讲解每个结构体、每个字段的含义。


3.1 objc_object —— 所有对象的祖宗

struct objc_object {
private:
    isa_t isa;
};

这是什么?

这是 OC 里所有对象的底层表示。不管是 NSStringUIView、还是你自定义的 MyClass 实例,底层都是 objc_object

字段解析

字段 类型 含义
isa isa_t "is a" 的缩写,标识"这个对象是什么类型"。是一个 64 位的 union,里面藏了类指针 + 引用计数 + 各种标志位。isa_t 的详细结构会在第二篇展开讲解。

为什么只有一个字段?

因为 objc_object最小公共祖先。每个对象只需要知道"我是什么类型"(isa),其他的成员变量由具体的类定义,紧跟在 isa 后面存储。

内存布局示意

一个 MyClass 实例的内存:
┌────────────────┐ ← 对象起始地址
│     isa        │   8 字节(objc_object 的字段)
├────────────────┤
│    _name       │   8 字节(MyClass 自己的 ivar)
├────────────────┤
│    _age        │   4 字节(MyClass 自己的 ivar)
└────────────────┘

3.2 objc_class —— 类的完整定义

struct objc_class : objc_object {
    Class superclass;
    cache_t cache;
    class_data_bits_t bits;

    class_rw_t *data() const {
        return bits.data();
    }
};

这是什么?

这是 OC 里的底层表示。每个 @interface MyClass 在运行时都对应一个 objc_class 结构体实例。

注意它继承自 objc_object,所以"类也是对象"——类对象有自己的 isa(指向元类)。

字段逐个解析

字段 类型 含义
isa isa_t(继承来的) 类对象的 isa 指向它的元类(metaclass)。isa_t 的详细结构见第二篇。
superclass Class 父类指针。Classobjc_class * 的 typedef,即指向另一个 objc_class 的指针。NSObject 的 superclass 是 nil。;
cache cache_t 方法缓存,哈希表结构。最近调用的方法会缓存在这里,加速后续调用。
bits class_data_bits_t 一个 64 位整数,低 3 位是标志位,高位是 class_rw_t 指针

Class 是什么类型?

// objc.h
typedef struct objc_class *Class;

Class 就是 objc_class * 的别名,一个指向类对象的指针。你代码里写的所有 Class 都只是这个指针,没有额外结构:

Class cls = [MyClass class];       // 拿到 MyClass 的 objc_class * 指针
Class superCls = [cls superclass]; // 拿到父类的 objc_class * 指针

同理,id 也是:

typedef struct objc_object *id;    // id = objc_object *,指向任意实例对象

superclass 有什么用?

实现继承。当在当前类找不到方法时,runtime 会沿着 superclass 链往上找。

调用 [myObj doSomething]
    ↓
在 MyClass 的方法列表里找
    ↓ 找不到
通过 superclass 到 NSObject 里找
    ↓ 还找不到
触发消息转发

3.3 class_data_bits_t —— 指针 + 标志位的混合体

struct class_data_bits_t {
private:
    uintptr_t bits;   // 64 位整数

public:
    class_rw_t *data() const {
        return (class_rw_t *)(bits & FAST_DATA_MASK);
    }
};

这是什么?

就是 objc_class.bits 的类型。它不是简单的指针,而是把 class_rw_t 指针几个标志位 打包进同一个 64 位整数里。

为什么能这样做?

因为 class_rw_t 在内存里是 8 字节对齐的,所以它的地址的低 3 位永远是 000。苹果就把这 3 位拿来存标志位,不浪费。

64 位的布局

class_data_bits_t.bits(64位)

 63                                3  2  1  0
┌────────────────────────────────┬──┬──┬──┐
│     class_rw_t 指针 (bit 3~63) │ 210│
└────────────────────────────────┴──┴──┴──┘
                                   │  │  │
                                   │  │  └─ FAST_IS_SWIFT_LEGACY (是旧版Swift类?)
                                   │  └──── FAST_IS_SWIFT_STABLE (是新版Swift类?)
                                   └─────── FAST_HAS_DEFAULT_RR  (有默认retain/release?)

取指针的掩码

// ARM64
#define FAST_DATA_MASK 0x00007ffffffffff8UL

// 二进制:...11111111111111111111111111111111111111000
// 作用:与运算后,低 3 位清零,剩下的就是真正的 class_rw_t 地址

data() 方法做了什么?

class_rw_t *data() const {
    return (class_rw_t *)(bits & FAST_DATA_MASK);
    // bits & 掩码 → 把低 3 位标志位清掉 → 得到纯净的 class_rw_t 指针
}

一句话总结

class_data_bits_tisa_t 的设计思路一样——充分利用内存对齐带来的空闲位,一个 64 位整数里塞多种信息,省内存


3.4 cache_t —— 方法缓存

struct cache_t {
private:
    explicit_atomic<uintptr_t> _bucketsAndMaybeMask;
    union {
        struct {
            explicit_atomic<mask_t> _maybeMask;
            uint16_t _flags;
            uint16_t _occupied;
        };
        explicit_atomic<preopt_cache_t *> _originalPreoptCache;
    };
};

struct bucket_t {
private:
    explicit_atomic<SEL> _sel;
    explicit_atomic<uintptr_t> _imp;
};

为什么需要缓存?

每次调用方法都去 class_rw_t 的方法列表里遍历查找,太慢了。cache_t 是一个哈希表,把最近调用过的方法缓存起来。

字段解析

字段 含义
_bucketsAndMaybeMask 哈希桶数组的起始地址
_maybeMask 桶数量 - 1,用于哈希取模(hash & mask
_occupied 当前已使用的桶数量

bucket_t 是什么?

单个缓存条目,存储 SEL(方法名)和 IMP(函数指针)的映射。

字段 类型 含义
_sel SEL 方法选择子(方法名),如 @selector(viewDidLoad)
_imp uintptr_t 方法实现的函数地址

查找流程

[obj doSomething]
    ↓
计算 @selector(doSomething) 的哈希值
    ↓
hash & _maybeMask → 得到桶的索引
    ↓
取出 bucket_t,比较 _sel 是否等于 @selector(doSomething)
    ↓
相等 → 直接调用 _imp,结束(命中缓存,极快)
不相等 → 去 class_rw_t 里慢速查找

缓存什么时候会失效?

  • 调用 method_exchangeImplementations(Method Swizzle)后
  • 动态添加方法后
  • 类第一次加载时

失效时 runtime 会调用 flushCaches() 清空缓存。


3.5 class_rw_t —— 运行时可读写数据

struct class_rw_t {
    uint32_t flags;
    uint16_t witness;
    uint16_t index;

    explicit_atomic<uintptr_t> ro_or_rw_ext;

    Class firstSubclass;
    Class nextSiblingClass;

    const method_array_t methods() const;
    const property_array_t properties() const;
    const protocol_array_t protocols() const;
    const class_ro_t *ro() const;
};

这是什么?

rw = read-write(可读写)。这里存放运行时可以修改的数据,比如 Category 添加的方法会合并到这里。

字段解析

字段 含义
flags 各种标志位(是否已初始化、是否有 C++ 构造函数等)
ro_or_rw_ext 指向 class_ro_t(只读数据),或扩展数据
firstSubclass 指向第一个子类,形成子类链表
nextSiblingClass 指向下一个兄弟类(同一个父类的其他子类)

获取方法/属性/协议

const method_array_t methods() const;     // 返回方法列表(含 Category 方法)
const property_array_t properties() const; // 返回属性列表
const protocol_array_t protocols() const;  // 返回协议列表

这些方法返回的是合并后的列表——源码里写的 + Category 加进来的。

ro() 方法

返回 class_ro_t 指针,取出编译期确定的只读数据。


3.6 class_ro_t —— 编译期只读数据

struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart;
    uint32_t instanceSize;

    const uint8_t * ivarLayout;
    const char * name;
    
    WrappedPtr<method_list_t, ...> baseMethods;
    protocol_list_t * baseProtocols;
    const ivar_list_t * ivars;
    
    const uint8_t * weakIvarLayout;
    property_list_t *baseProperties;
};

这是什么?

ro = read-only(只读)。这里存放编译时就确定的数据,运行时不能修改。

字段逐个解析

字段 类型 含义
flags uint32_t 标志位
instanceStart uint32_t 实例变量在对象内存中的起始偏移(通常是 8,跳过 isa)
instanceSize uint32_t 一个实例对象占多少字节(sizeof
ivarLayout const uint8_t * 描述哪些 ivar 是强引用(ARC 用)
name const char * 类名字符串,如 "UIViewController"
baseMethods method_list_t * 源码里定义的方法列表(不含 Category)
baseProtocols protocol_list_t * 源码里遵循的协议列表
ivars ivar_list_t * 实例变量列表
weakIvarLayout const uint8_t * 描述哪些 ivar 是弱引用
baseProperties property_list_t * 源码里定义的属性列表

class_ro_t vs class_rw_t 对比

class_ro_t class_rw_t
全称 read-only read-write
什么时候确定 编译期(写进 Mach-O 二进制文件) 运行时(启动时构造)
能修改吗 ❌ 不能 ✅ 能
存什么 源码里写死的方法、属性、变量 动态添加的方法、Category 合并的方法

为什么要分两层?

因为 Category 是运行时加载的。编译期不知道会有哪些 Category,所以:

  1. 编译期:把源码里写的方法存进 class_ro_t
  2. 运行时:遍历所有 Category,把它们的方法合并class_rw_t

查找方法时,先查 class_rw_t(含 Category),它内部会访问 class_ro_t


3.7 method_t —— 单个方法

struct method_t {
    SEL name;
    const char *types;
    IMP imp;
};

字段解析

字段 类型 含义 例子
name SEL 方法选择子(方法名) @selector(viewDidLoad)
types const char * 类型编码(返回值+参数的类型) "v16@0:8"
imp IMP 函数指针,指向方法的真正实现 0x100001234(代码段地址)

SEL 是什么?

typedef struct objc_selector *SEL;

本质是一个唯一化的 C 字符串。同名方法在整个程序里 SEL 值相同(指针相等),所以比较方法名只需要比较指针,极快。

SEL sel1 = @selector(doSomething);
SEL sel2 = @selector(doSomething);
// sel1 == sel2(指针相等,不是字符串比较)

IMP 是什么?

typedef void (*IMP)(id, SEL, ...);

函数指针,前两个参数固定是:

  • id self:消息接收者
  • SEL _cmd:方法选择子

这解释了为什么 OC 方法里能直接用 self_cmd——它们是函数的隐藏参数。

// 你写的:
- (void)doSomething {
    NSLog(@"%@", self);
}

// 编译器眼里的:
void doSomething(id self, SEL _cmd) {
    NSLog(@"%@", self);
}

types 字符串怎么读?

- (NSString *)nameWithPrefix:(NSString *)prefix 为例,types 是 @24@0:8@16

@    → 返回值是 id(对象)
24   → 所有参数总共占 24 字节
@    → 第1个参数是 id(self)
0    → 从第 0 字节开始
:    → 第2个参数是 SEL(_cmd)
8    → 从第 8 字节开始
@    → 第3个参数是 id(prefix)
16   → 从第 16 字节开始

这套编码叫 Type Encoding,runtime 靠它做方法签名校验。


3.8 ivar_t —— 单个实例变量

struct ivar_t {
    int32_t *offset;
    const char *name;
    const char *type;
    uint32_t alignment_raw;
    uint32_t size;
};

字段解析

字段 类型 含义 例子
offset int32_t * 偏移量的指针(不是值!) 指向存储偏移量的内存
name const char * 变量名 "_name"
type const char * 类型编码 "@"NSString""
alignment_raw uint32_t 内存对齐方式 通常是 3(2^3 = 8 字节对齐)
size uint32_t 占多少字节 指针占 8 字节

为什么 offset 是指针而不是值?

这是 Non-Fragile ABI(非脆弱 ABI)的设计。

假设父类 NSObject 有 8 字节的 isa,子类 MyClass_name 变量在 offset 8。

如果 Apple 在新系统里给 NSObject 加了一个成员变量(变成 16 字节),按老 ABI,MyClass_name 还在 offset 8,就会和 NSObject 新增的变量重叠——程序崩溃。

Non-Fragile ABI 的解决方案:

  1. offset 是指针,不是值
  2. App 启动时,runtime 检查父类大小是否变化
  3. 如果变化了,自动调整所有子类 ivar 的 offset 值
  4. 子类不需要重新编译
旧系统:NSObject 8字节,MyClass._name 在 offset 8
    ↓ Apple 升级系统
新系统:NSObject 16字节
    ↓ runtime 自动修正
MyClass._name 的 offset 从 8 改成 16

访问 ivar 的过程

// 伪代码
id value = *(id *)((char *)obj + *ivar->offset);
// 1. 取出 offset 指针指向的偏移值
// 2. 对象地址 + 偏移值 = ivar 的内存地址
// 3. 解引用得到 ivar 的值

3.9 isa_t —— 64 位里藏了很多东西

union isa_t {
    uintptr_t bits;

    struct {
        uintptr_t nonpointer        : 1;
        uintptr_t has_assoc         : 1;
        uintptr_t has_cxx_dtor      : 1;
        uintptr_t shiftcls          : 33;
        uintptr_t magic             : 6;
        uintptr_t weakly_referenced : 1;
        uintptr_t unused            : 1;
        uintptr_t has_sidetable_rc  : 1;
        uintptr_t extra_rc          : 19;
    };
};

为什么不直接存指针?

在 64 位系统上,指针只需要约 40 位就能表示所有内存地址。剩下的位"浪费"了,苹果就把引用计数和各种标志位塞进去,省内存。

这叫 Non-pointer ISA(优化过的 isa)。

每一位的含义

位域 位数 含义
nonpointer 1 是否是优化过的 isa(现代设备都是 1)
has_assoc 1 对象是否有关联对象(objc_setAssociatedObject
has_cxx_dtor 1 是否有 C++ 析构函数或 ARC 的 .cxx_destruct
shiftcls 33 类指针(右移 3 位存储,取时左移还原)
magic 6 固定值 0x1a,调试用(值不对说明内存被踩了)
weakly_referenced 1 是否被 __weak 指针指向过
unused 1 未使用,预留
has_sidetable_rc 1 引用计数是否溢出到 SideTable
extra_rc 19 存储引用计数 - 1(最大 2^19 - 1 = 524287)

如何取出类指针?

Class cls = (Class)(isa.bits & ISA_MASK);
// ISA_MASK = 0x0000000ffffffff8ULL
// 掩码取出 bit 3~35,然后隐含左移还原

四、完整内存布局示意

把所有结构体串起来,一个类在内存里长这样:

objc_class 实例(代表 MyClass 这个类)
┌─────────────────────────────────────────────────────┐
│  isa (64位 isa_t)                                   │ → 指向 Meta-MyClass(元类)
├─────────────────────────────────────────────────────┤
│  superclass (8字节)                                 │ → 指向 NSObject
├─────────────────────────────────────────────────────┤
│  cache (cache_t)                                    │
│    _bucketsAndMaybeMask → [ bucket_t, bucket_t... ] │ 每个桶: { SEL, IMP }
│    _maybeMask = N-1                                 │
│    _occupied = 已用桶数                              │
├─────────────────────────────────────────────────────┤
│  bits (class_data_bits_t)                           │ ← 低3位是标志位,高位是指针
│    data() ──────────────────────────────────────────│──→ class_rw_t
│                                                     │      ├── methods()   → [method_t, ...]
│                                                     │      ├── properties()→ [property_t, ...]
│                                                     │      ├── protocols() → [protocol_t, ...]
│                                                     │      └── ro() ───────→ class_ro_t
│                                                     │              ├── name = "MyClass"
│                                                     │              ├── instanceSize = 24
│                                                     │              ├── baseMethods
│                                                     │              │     ├── method_t { SEL, types, IMP }
│                                                     │              │     └── method_t { ... }
│                                                     │              └── ivars
│                                                     │                    ├── ivar_t { offset*, "_name", "@", 3, 8 }
│                                                     │                    └── ivar_t { offset*, "_age",  "i", 2, 4 }
└─────────────────────────────────────────────────────┘

五、小结

结构体 可否运行时修改 存放什么
objc_class 不直接改 类的容器,持有 superclass/cache/bits
isa_t 部分可改(引用计数位) 类指针 + 引用计数 + 标志位,全塞在 64 位里
class_data_bits_t 不直接改 class_rw_t 指针 + 3 个标志位,又一个"指针+标志"混合体
cache_t 是(每次调用方法后更新) 最近调用的方法 SEL → IMP 映射
class_rw_t 运行时合并后的方法、属性、协议
class_ro_t 编译期确定的方法、变量、属性,写死在二进制里
method_t IMP 可以换(Swizzle) 一个方法的名字、类型编码、实现地址
ivar_t offset 可改(Non-Fragile ABI) 一个实例变量的名字、类型、偏移量

下一篇:isa 指针深度解析、元类体系、完整继承链图

50 岁的苹果和 51 岁的我 -- 肘子的 Swift 周报 #127

作者 东坡肘子
2026年3月17日 07:54

issue127.webp

50 岁的苹果和 51 岁的我

再有不到半个月,Apple 将迎来 50 岁生日。Tim Cook 也发表了一篇短文,致敬过去半个世纪的历程。不过,由于苹果一直以来始终引领潮流的形象,很多人并没有意识到它已经是 IT 产业中名副其实的元老。与它年龄相当的 IT 巨头,如今仍留在一线牌桌上的寥寥无几。

作为一个只比苹果大一岁的科技爱好者,从 Apple II 到如今的 iPhone、MacBook,苹果的产品几乎伴随我走过了大半人生。严格来说,我并不算真正的果粉——不会因为没能第一时间买到新品而遗憾,也几乎不再熬夜看发布会,更说不出新产品的具体参数。但回顾过去,在每一个人生节点上,我都会很自然地选择苹果的产品,并在近几年成为了苹果开发生态中的一员。

其实我也没有完全想明白,苹果对我持久的吸引力究竟来自哪里。是因为很早就开始使用它的产品?是它的创新、体验和气质?还是 Jobs 的人格魅力?说实话,如今的选择已经完全出于习惯和本能,就像老友间的默契,早已不需要什么特别的理由。

当然,苹果的成长之路并非一帆风顺,其间也有过低谷。但有一点必须承认:它在过去 50 年间的企业定位几乎没变——为个人和社会创造强大的工具。即便在最新一轮 AI 浪潮中,苹果看似失去了先机,但作为连接人与数字世界的“最后一厘米”的核心参与者,它仍然具备在 AI 时代留在牌桌中央的资本。毕竟,我们生活在物质世界中,需要实打实的硬件设备和个人化服务来享受技术进步的成果。

50 岁的苹果或许能给更多企业带来启示:与其模仿它“炫酷”、“创新”的外表,不如学习它的专注与坚持。成为与用户长久互相陪伴的伙伴,或许才是它成功的真正密码。

大概率再过十年,当苹果 60 岁、我 61 岁的时候,我仍然用着一台苹果电脑。

生日快乐,苹果!

本期内容 | 前一期内容 | 全部周报列表

原创

2026 年,为什么我仍在思考 Core Data

到 2026 年,Core Data 已经问世 21 年,尽管仍有不少开发者在使用它,但在今天的 Swift 项目里,它越来越像个“时代遗留”。并发得靠 perform 一层层套,模型声明堆满样板代码,字符串谓词随时等你踩坑。这篇文章不是要为 Core Data 辩护,也不是要说服新的开发者回到 Core Data。它更像是一篇问题整理:在 2026 年,为什么仍有人坚持使用 Core Data;而如果要继续使用它,我们今天真正需要解决的问题又是什么。

近期推荐

原生 AI 聊天应用 — 极速、隐私优先、100+ 专业功能

一个原生应用,100+ AI 模型,支持 Mac、iOS 和 Android。极速响应、键盘驱动、非 Electron。使用码 FATBOBMAN25 立享 25% OFF。


苹果工程师谈应用安全与内存保护 (Fortify Your App: Essential Strategies to Strengthen Security Q&A)

在苹果开发者中心举办的一场安全专题活动中,多位苹果工程师围绕应用安全与内存安全进行了近六小时的分享与问答,内容涵盖现代应用面临的安全挑战,以及 Apple 平台提供的一系列防护技术。Anton Gubarenko 将这场活动中的大量开发者问答整理成文,讨论了第三方库安全评估、UserDefaults 与 plist 数据存储的风险、Keychain 与文件保护策略、Swift unsafe API 的使用边界,以及如何在 Xcode 中启用 Enhanced Security 等能力。对于希望了解 Apple 平台安全机制与实践建议的开发者来说,这是一份信息密度很高的问答整理,其中包含不少来自苹果工程师的一手信息。


用 CLI 与 MCP 自动化配置 iOS 订阅 (Faster iOS Subscriptions with ASC CLI and RevenueCat MCP)

为应用添加订阅功能本身并不复杂,但在 App Store Connect 与 RevenueCat 两个后台之间来回配置,过程往往相当繁琐。Rudrank Riyam 介绍了一种更高效的做法:使用 ASC CLI 在终端中一次性创建订阅产品,再让 AI 代理通过 RevenueCat 的 MCP Server 自动完成 entitlements、offerings 与 paywall 的配置,从而将原本依赖控制台点击的流程迁移到 CLI + AI Agent 的自动化工作流中。


JetBrains 面向 Swift 开发者的调查 (JetBrains Swift Developers Survey)

JetBrains 最近发布了一份面向 Swift 开发者的调研问卷,邀请开发者分享当前使用的开发工具、工作流程以及在 Swift 生态中的痛点。尽管官方并未说明调研的具体用途,但社区中已经出现不少猜测:这项调查可能与 JetBrains 重新评估 Swift 开发工具支持有关。

在 JetBrains 于 2022 年宣布停止维护 AppCode 之后,Swift 开发者基本回到了以 Xcode 为核心的工具链。此次调研也引发了一些讨论——有人期待 JetBrains 重新探索 Swift tooling 的可能性,也有人认为这更可能与 Kotlin Multiplatform 或 Swift 构建工具链相关。如果你对 Swift 开发工具生态的未来方向感兴趣,不妨参与这份调查。


不依赖编译器识别 Swift Protocol 的方法 (How Well Can You Detect a Swift Protocol Without the Compiler?)

在 Swift 项目中,Protocol 几乎无处不在,但如果不依赖编译器或完整构建环境,仅通过源码文本判断一个文件是否定义或使用了协议,结果会有多可靠?Xiangyu Sun 在这篇文章中系统评估了多种检测策略,例如使用 SourceKit/LSP、SwiftSyntax AST、关键字正则匹配,以及通过 extension Foo: Barany / some 等语法信号进行启发式判断,并对这些方法的准确率与适用场景进行了比较。

文章最有意思的部分在于作者发现:简单的命名约定可以显著提升静态分析效果。如果团队统一使用 *Protocol 后缀命名协议类型(如 PaymentServiceProtocol),很多原本存在歧义的检测方法都会变得更加可靠。作者还进一步讨论了这种约定在 AI 辅助开发中的价值:通过在文件级别预分类协议文件,可以在向 LLM 提供上下文时显著减少 token 消耗,并提高分析效率,这是一个颇具启发性的视角。


迁移到 Swift Concurrency 前需要注意的细节 (What you should know before Migrating from GCD to Swift Concurrency)

从 GCD 迁移到 Swift Concurrency 并非简单的语法替换。在这篇文章中,Soumya Ranjan Mahunt 指出:Swift Concurrency 在任务调度、执行顺序以及并发语义上与 GCD 存在一些关键差异,例如 Task 的调度并不保证与 GCD 相同的 FIFO 执行顺序,而 actor 也并不是 DispatchQueue 的直接替代,其执行行为可能受到任务优先级和调度策略的影响。此外,文中还讨论了一些在实际迁移过程中容易被忽视的问题,例如 DispatchGroup 在 Swift Concurrency 中并没有完全等价的 API,以及在旧系统版本中使用 assumeIsolated 可能遇到的兼容性问题。


选择 AI Agent Skill 的九步框架 (A 9-Step Framework for Choosing the Right Agent Skill)

随着 AI Agent 在开发工作流中的应用越来越广泛,如何为 Agent 设计合适的“技能”(Skill / Tool)也逐渐成为一个新的工程问题。Antoine van der Lee 提出了一个用于判断何时应该为 Agent 创建技能的九步框架,帮助开发者在自动化能力、可维护性以及系统复杂度之间取得平衡。Antoine 指出,并非所有任务都适合直接交给 LLM,也并非所有能力都需要实现为 Agent 工具。文章从任务确定性、执行成本、可复用性以及安全性等角度出发,提供了一套相对系统的评估思路。

工具

DataStoreKit

这是一个很有意思的开源项目,由 Anferne Pineda 开发。它基于 SwiftData 的自定义 store 能力,在保留 SwiftData 上层开发体验的同时,重新实现了一套面向 SQLite 的底层存储后端,包括从 SwiftData 模型、谓词到 SQLite schema、SQL、快照与持久化历史的映射和执行。

DataStoreKit 提供了一些值得关注的特性,例如支持对数组、字典等集合类型数据进行谓词查询,底层以 JSON 形式映射到 SQLite;同时也提供了 SQL 直通能力,让开发者在 #Predicate 之外,能够直接利用 SQLite 的能力完成查询或维护操作。

这是目前为数不多、且实现深度较高的 SwiftData DataStore 自定义实践,展示了 SwiftData 作为数据表现层而非完整持久化引擎的另一种可能性。项目目前仍处于较早期阶段,API 和能力边界可能还会继续调整,但已经非常值得持续关注。


Playwright for Swift

Miguel Piedrafita 开发的 swift-playwright,将 Playwright 这套成熟的浏览器自动化能力带入了 Swift 生态。开发者可以直接使用 Swift 代码驱动 Chromium、Firefox 和 WebKit,完成页面导航、点击、输入、截图、执行 JavaScript 等常见操作,整体 API 风格也尽量贴近官方 Playwright。

从实现方式上看,它并不是重新实现一套浏览器自动化框架,而是在 Swift 侧封装了 Playwright 协议,底层依然通过 Node.js 的 Playwright driver 与浏览器通信。对于希望使用 Swift 构建测试工具、CLI,甚至 AI Agent 的开发者来说,这个项目提供了一个颇具吸引力的切入点。

活动

LET'S VISION 2026 -- Born to Create · Powered by AI

  • 👀 70+ 展商现场体验
  • 🤖 AI 创新产品 / AI Agent
  • 🥽 XR / 空间计算沉浸体验
  • 🎤 创作者与开发者分享

如果你是开发者、设计师、产品经理、创作者,还是对 AI 和未来科技感兴趣的探索者,都很值得来逛逛。

  • 📅 2026.3.28 – 3.29
  • 📍 上海 · 漕河泾会议中心

15% OFF 门票 👇

往期内容

💝 支持与反馈

如果本期周报对你有帮助,请:

  • 👍 点赞 - 让更多开发者看到
  • 💬 评论 - 分享你的看法或问题
  • 🔄 转发 - 帮助同行共同成长

🚀 拓展 Swift 视野

用好你的 jj - 重新思考 Agent 时代的版本控制

2026年3月16日 22:30
过去大半年我一直在高强度地用 AI agent 写代码,用着用着发现一个问题:“怎么组织 agent 吐出来的东西”这件事,比我原来想的重要太多了。 这话听着可能有点奇怪。大家关心的一般都是模型能力、prompt 怎么写、上下文够不够长……但真的和 agent 密集配合过一阵子之后,你会发现有个更底层的东西一直在拖后腿:版本控制。说得再具体一点,就是你拿什么样的心智模型来管理本地的代码变更。 我现在的结论是:Git 作为远端协作和代码托管的标准还是没什么好说的,但在本地工作流这头,jj (Jujutsu) 明显更适合现在这种人和 agent 来回切着干活的开发方式。这篇文章就是来安利这个的。 Git 在 Agent 时代的摩擦 Git 是个伟大的工具,这一点没啥好争的。但它的很多设计假设,是建立在二十年前”人类手工编程”的时代背景上——一个人坐在编辑器前面,想清楚要改什么,改完检查一遍,然后 add、commit、push。这套流程是给人类的线性思维量身做的:staging area 给你一个”最后再看一眼”的机会,branch 帮你隔离不同的工作流,stash 让你...
❌
❌