阅读视图

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

《Flutter全栈开发实战指南:从零到高级》- 17 -核心动画

引言

Snipaste_2025-11-24_16-55-23.png 动画是移动应用的灵魂,能够增强页面交互效果,比如:微信下拉刷新的旋转动画、支付宝页面切换的平滑过渡、抖音点赞动效等等这些炫酷的动画效果使你的App更加出彩。今天我们就来深入探讨Flutter中那些让人惊艳的动画效果,一步步掌握动画的核心技巧!

一、动画核心类介绍

1.1 动画的本质是什么?

在深入代码之前,我们先要理解动画的本质。简单来说,动画就是在一段时间内连续改变属性值的过程。

举个例子:

  • 一个按钮从透明变成不透明(改变opacity值)
  • 一个图标从左边移动到右边(改变position值)
  • 一个容器从小变大(改变size值)

用伪代码表示就是:

// 伪代码
开始值 = 0.0
结束值 = 1.0
持续时间 = 1.0 

// 在1秒内,当前值从0.0线性变化到1.0
当前值 = 开始值 + (结束值 - 开始值) * (已用时间 / 持续时间)

1.2 动画核心类

Flutter的动画系统建立在以下四个核心类基础之上:

// 1. AnimationController
AnimationController controller;

// 2. Animation
Animation<double> animation;

// 3. Tween
Tween<double> tween;

// 4. Curve
CurvedAnimation curvedAnimation;

如果把动画效果比作开车,别想歪了,哈哈!想象一下你将车从A点开到B点:

  • AnimationController就像是你的脚踩油门和刹车,控制车的启动、停止、加速、减速
  • Tween就像是导航,告诉你从A点(开始值)到B点(结束值)的路线
  • Animation就像是车速表,实时显示当前的速度值
  • Curve就像是道路状况,决定了你是匀速前进、先快后慢,还是有什么特殊的加速模式

1.3 核心类的职责

Animation - 值容器

Animation<double> sizeAnimation;

// 主要职责:
// - 持有当前动画值
// - 通知监听器值发生变化
// - 管理动画状态

// 添加值监听器
sizeAnimation.addListener(() {
  setState(() {
    // 当动画值变化时,重建Widget。。。
  });
});

// 添加状态监听器  
sizeAnimation.addStatusListener((status) {
  switch (status) {
    case AnimationStatus.dismissed:
      print('动画在开始状态'); 
      break;
    case AnimationStatus.forward:
      print('正向执行'); 
      break;
    case AnimationStatus.reverse:
      print('反向执行'); 
      break;
    case AnimationStatus.completed:
      print('执行完成'); 
      break;
  }
});

AnimationController

class _MyAnimationState extends State<MyAnimation> 
    with SingleTickerProviderStateMixin {
  
  late AnimationController controller;
  
  @override
  void initState() {
    super.initState();
    
    // 创建动画控制器
    controller = AnimationController(
      duration: Duration(seconds: 2),  // 持续2秒
      vsync: this,                     // 垂直同步
    );
  }
  
  @override
  void dispose() {
    controller.dispose();  // 释放
    super.dispose();
  }
}

这里有个重要概念:vsync vsync的作用是防止动画在页面不可见时继续运行,造成浪费资源。通过SingleTickerProviderStateMixin,当页面被遮挡或切换到后台时,动画会自动暂停。

二、隐式动画

2.1 什么是隐式动画?

本质就是告诉Widget最终的状态是什么,Flutter会自动帮你生成过渡动画。

  • 普通Widget:不加任何动画效果,widget会很生硬的直接过渡过去;
  • 隐式动画Widget:从旧状态到新状态间有一个平滑的过渡,用户体验更好一些;

2.2 隐式动画组件

2.2.1 AnimatedContainer

AnimatedContainer:最常用的隐式动画组件,几乎所有的容器属性动画都可以用它来完成。

class AnimatedContainerExample extends StatefulWidget {
  @override
  _AnimatedContainerExampleState createState() => _AnimatedContainerExampleState();
}

class _AnimatedContainerExampleState extends State<AnimatedContainerExample> {
  // 定义可变属性
  double _width = 100.0;
  double _height = 100.0;
  Color _color = Colors.blue;
  BorderRadius _borderRadius = BorderRadius.circular(8.0);
  
  void _toggleAnimation() {
    setState(() {
      // 改变属性值
      _width = _width == 100.0 ? 200.0 : 100.0;
      _height = _height == 100.0 ? 200.0 : 100.0;
      _color = _color == Colors.blue ? Colors.green : Colors.blue;
      _borderRadius = _borderRadius == BorderRadius.circular(8.0) 
          ? BorderRadius.circular(50.0)   // 变成圆
          : BorderRadius.circular(8.0);   // 变回圆角矩形
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        AnimatedContainer(
          width: _width,
          height: _height,
          duration: Duration(seconds: 1),     // 动画时长
          curve: Curves.easeInOut,            // 动画曲线
          decoration: BoxDecoration(
            color: _color,
            borderRadius: _borderRadius,
          ),
          child: Center(
            child: Text(
              '点我',
              style: TextStyle(color: Colors.white),
            ),
          ),
        ),
        SizedBox(height: 20),
        ElevatedButton(
          onPressed: _toggleAnimation,
          child: Text('触发动画'),
        ),
      ],
    );
  }
}

AnimatedContainer支持的动画属性:

  • 尺寸widthheight
  • 颜色color
  • 边框圆角borderRadius
  • 内边距padding
  • 外边距margin
  • 阴影boxShadow
  • 变换transform

2.2.2 AnimatedOpacity

用来处理透明度变化的组件,非常适合实现显示/隐藏的过渡效果。

class AnimatedOpacityExample extends StatefulWidget {
  @override
  _AnimatedOpacityExampleState createState() => _AnimatedOpacityExampleState();
}

class _AnimatedOpacityExampleState extends State<AnimatedOpacityExample> {
  double _opacity = 1.0;  // 1.0 = 完全显示,0.0 = 完全隐藏
  
  void _toggleVisibility() {
    setState(() {
      _opacity = _opacity == 1.0 ? 0.0 : 1.0;
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        AnimatedOpacity(
          opacity: _opacity,
          duration: Duration(seconds: 1),
          curve: Curves.easeInOut,
          child: Container(
            width: 200,
            height: 200,
            color: Colors.blue,
            child: Center(
              child: Text(
                '淡入淡出',
                style: TextStyle(color: Colors.white, fontSize: 20),
              ),
            ),
          ),
        ),
        SizedBox(height: 20),
        ElevatedButton(
          onPressed: _toggleVisibility,
          child: Text(_opacity == 1.0 ? '隐藏' : '显示'),
        ),
      ],
    );
  }
}

使用场景:

  • 页面元素的显示/隐藏
  • 加载状态的过渡
  • 错误信息的淡入淡出

2.2.3 其他隐式动画组件

// AnimatedPadding - 内边距动画
AnimatedPadding(
  padding: EdgeInsets.all(_paddingValue),
  duration: Duration(seconds: 1),
  child: YourWidget(),
)

// AnimatedAlign - 对齐位置动画  
AnimatedAlign(
  alignment: _alignment,
  duration: Duration(seconds: 1),
  child: YourWidget(),
)

// AnimatedPositioned - 定位动画(注意:只能在Stack中使用)
AnimatedPositioned(
  left: _left,
  top: _top,
  duration: Duration(seconds: 1),
  child: YourWidget(),
)

2.3 隐式动画的工作原理

很多人会有疑问:为什么改变属性值就能产生动画?下面我们深入探讨其背后的实现原理:

sequenceDiagram
    participant U as 用户交互
    participant S as setState()
    participant W as 隐式动画Widget
    participant E as 动画引擎
    participant R as 渲染层
    
    U->>S: 触发状态变化
    S->>W: 重建Widget树
    W->>E: 检测到属性变化
    E->>E: 计算动画补间值
    loop 每一帧
        E->>W: 更新动画值
        W->>R: 重绘界面
    end
    E->>W: 动画完成

下面来看一段代码,来辅助理解隐式动画:

// 隐式动画的内部逻辑
class AnimatedContainer extends ImplicitlyAnimatedWidget {
  @override
  void didUpdateWidget(AnimatedContainer oldWidget) {
    super.didUpdateWidget(oldWidget);
    
    // 当属性发生变化时
    if (oldWidget.width != widget.width) {
      // 创建动画控制器
      // 创建补间动画
      // 启动动画
    }
  }
}

简单来说,当setState()被调用时:

  1. Widget树重建
  2. AnimatedContainer检测到属性值变化
  3. 自动创建动画控制器和补间动画
  4. 启动动画,在指定时间内平滑过渡到新值

三、补间动画

3.1 什么是补间动画?

"补间"这个词来源于动画制作领域,意思是在起始状态和结束状态之间过渡帧

在Flutter中,Tween就是做这个工作的:

  • 你告诉它起始值(begin)和结束值(end)
  • 它负责计算中间的所有值
// 创建一个从50到200的尺寸补间动画
Tween<double> sizeTween = Tween<double>(
  begin: 50.0,   // 开始值
  end: 200.0,    // 结束值
);

// 创建一个从红色到蓝色的颜色补间动画  
ColorTween colorTween = ColorTween(
  begin: Colors.red,
  end: Colors.blue,
);

3.2 Tween的完整使用流程

class TweenAnimationExample extends StatefulWidget {
  @override
  _TweenAnimationExampleState createState() => _TweenAnimationExampleState();
}

class _TweenAnimationExampleState extends State<TweenAnimationExample> 
    with SingleTickerProviderStateMixin {
  
  late AnimationController _controller;
  late Animation<double> _sizeAnimation;
  late Animation<Color?> _colorAnimation;
  late Animation<double> _rotationAnimation;
  
  @override
  void initState() {
    super.initState();
    
    // 1. 创建动画控制器
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );
    
    // 2. 创建补间动画
    _sizeAnimation = Tween<double>(
      begin: 50.0,
      end: 200.0,
    ).animate(_controller);
    
    _colorAnimation = ColorTween(
      begin: Colors.blue,
      end: Colors.red,
    ).animate(_controller);
    
    _rotationAnimation = Tween<double>(
      begin: 0.0,
      end: 2 * 3.14159,  // 2π弧度 = 360度
    ).animate(_controller);
    
    // 3. 启动动画
    _controller.forward();
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Transform.rotate(
          angle: _rotationAnimation.value,
          child: Container(
            width: _sizeAnimation.value,
            height: _sizeAnimation.value,
            color: _colorAnimation.value,
            child: Center(
              child: Text(
                '动画中...',
                style: TextStyle(color: Colors.white),
              ),
            ),
          ),
        );
      },
    );
  }
}

3.3 Tween成员

Flutter提供了多种专门的Tween类:

// 尺寸
SizeTween(
  begin: Size(50, 50),
  end: Size(200, 200),
)

// 矩形区域
RectTween(
  begin: Rect.fromLTRB(0, 0, 50, 50),
  end: Rect.fromLTRB(0, 0, 200, 200),  
)

// 整数
IntTween(
  begin: 0,
  end: 100,
)

// 步进
StepTween(
  begin: 0,
  end: 100,
)

// 可以为自定义类型创建补间动画
Tween<YourCustomType>(
  begin: CustomType(),
  end: CustomType(),
)

四、动画曲线与控制器

4.1 动画曲线 - Curves

动画曲线本质就是模拟现实生活中的自然运动规律。

// 使用不同的动画曲线
Animation<double> animation = CurvedAnimation(
  parent: controller,
  curve: Curves.easeInOut,     // 先加速再减速
  reverseCurve: Curves.easeIn, // 反向动画
);

常用的动画曲线:

// 线性
Curves.linear

// 曲线
Curves.easeIn        // 先慢后快
Curves.easeOut       // 先快后慢  
Curves.easeInOut     // 先慢,中间快,后慢

// 弹性效果
Curves.bounceOut     // 回弹效果
Curves.elasticOut    // 弹簧效果

// 回弹效果
Curves.decelerate    // 先快后慢

4.2 不同曲线效果

下面创建一个不同曲线的例子:

class CurvesDemo extends StatefulWidget {
  @override
  _CurvesDemoState createState() => _CurvesDemoState();
}

class _CurvesDemoState extends State<CurvesDemo> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  final List<Curve> _curves = [
    Curves.linear,
    Curves.easeIn,
    Curves.easeOut,
    Curves.easeInOut,
    Curves.bounceOut,
    Curves.elasticOut,
  ];
  final List<String> _curveNames = [
    'linear',
    'easeIn', 
    'easeOut',
    'easeInOut',
    'bounceOut',
    'elasticOut',
  ];
  
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    )..repeat(reverse: true);  // 循环
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: _curves.length,
      itemBuilder: (context, index) {
        return Padding(
          padding: EdgeInsets.all(8.0),
          child: Column(
            children: [
              Text(_curveNames[index]),
              SizedBox(height: 10),
              Container(
                height: 50,
                child: AnimatedBuilder(
                  animation: _controller,
                  builder: (context, child) {
                    final animation = CurvedAnimation(
                      parent: _controller,
                      curve: _curves[index],
                    );
                    return Container(
                      width: 50 + animation.value * 200,  // 宽度从50到250
                      color: Colors.blue,
                      child: Center(
                        child: Text('${(animation.value * 100).round()}%'),
                      ),
                    );
                  },
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}

五、AnimationController

5.1 基本操作

AnimationController:控制动画的整个生命周期。

class AnimationControllerExample extends StatefulWidget {
  @override
  _AnimationControllerExampleState createState() => _AnimationControllerExampleState();
}

class _AnimationControllerExampleState extends State<AnimationControllerExample> 
    with SingleTickerProviderStateMixin {
  
  late AnimationController _controller;
  late Animation<double> _animation;
  
  @override
  void initState() {
    super.initState();
    
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );
    
    _animation = Tween<double>(begin: 0, end: 300).animate(_controller);
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          height: _animation.value,
          width: _animation.value,
          color: Colors.blue,
        ),
        SizedBox(height: 20),
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            ElevatedButton(
              onPressed: _controller.forward,  // 正向
              child: Text('播放'),
            ),
            ElevatedButton(
              onPressed: _controller.reverse,  // 反向
              child: Text('倒放'),
            ),
            ElevatedButton(
              onPressed: _controller.stop,     // 暂停
              child: Text('暂停'),
            ),
            ElevatedButton(
              onPressed: () {
                _controller.reset();           // 重置
                _controller.forward();
              },
              child: Text('重置并播放'),
            ),
          ],
        ),
        SizedBox(height: 20),
        Text('当前值: ${_animation.value.toStringAsFixed(1)}'),
        Text('当前状态: ${_controller.status.toString()}'),
      ],
    );
  }
}

5.2 使用技巧

// 动画控制的高级方法

// 1. 从特定值开始
_controller.animateTo(0.5);  // 从当前值动画到0.5

// 2. 相对动画
_controller.forward(from: 0.0);  // 从0.0开始正向动画

// 3. 重复动画
_controller.repeat(         // 无限重复
  min: 0.2,                 // 最小值
  max: 0.8,                 // 最大值
  reverse: true,            // 往返执行
  period: Duration(seconds: 1), // 循环周期
);

// 4. 获取动画信息
print('是否动画中: ${_controller.isAnimating}');
print('是否完成: ${_controller.isCompleted}');
print('是否停止: ${_controller.isDismissed}');

六、自定义显式动画与性能优化

6.1 自定义显式动画

有时候隐式动画不能满足我们的需求,这时候就需要自定义显式动画。

class CustomExplicitAnimation extends StatefulWidget {
  @override
  _CustomExplicitAnimationState createState() => _CustomExplicitAnimationState();
}

class _CustomExplicitAnimationState extends State<CustomExplicitAnimation> 
    with SingleTickerProviderStateMixin {
  
  late AnimationController _controller;
  late Animation<double> _sizeAnimation;
  late Animation<double> _opacityAnimation;
  late Animation<Color?> _colorAnimation;
  
  @override
  void initState() {
    super.initState();
    
    _controller = AnimationController(
      duration: Duration(seconds: 3),
      vsync: this,
    );
    
    // 创建交错动画效果
    _sizeAnimation = Tween<double>(
      begin: 50.0,
      end: 200.0,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Interval(0.0, 0.6, curve: Curves.easeInOut), // 只在前60%时间执行
    ));
    
    _opacityAnimation = Tween<double>(
      begin: 0.3,
      end: 1.0,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Interval(0.2, 0.8, curve: Curves.easeIn), // 从20%到80%时间执行
    ));
    
    _colorAnimation = ColorTween(
      begin: Colors.blue,
      end: Colors.purple,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Interval(0.5, 1.0, curve: Curves.easeOut), // 从50%时间开始执行
    ));
    
    _controller.forward();
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Opacity(
          opacity: _opacityAnimation.value,
          child: Container(
            width: _sizeAnimation.value,
            height: _sizeAnimation.value,
            decoration: BoxDecoration(
              color: _colorAnimation.value,
              borderRadius: BorderRadius.circular(20),
              boxShadow: [
                BoxShadow(
                  color: Colors.black.withOpacity(0.3),
                  blurRadius: 10,
                  offset: Offset(0, 5),
                ),
              ],
            ),
            child: Center(
              child: Text(
                '自定义动画',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 18,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
          ),
        );
      },
    );
  }
}

二者之间有什么区别呢?隐式动画 vs 显式动画

维度 隐式动画 显式动画
控制 自动控制 完全控制
代码量 简单 相对复杂
灵活性 有限 非常高
适用场景 简单属性变化 复杂动画序列

6.2 性能优化

动画性能很重要,不好的动画效果会让用户觉得应用卡顿。这里有几个优化技巧:

6.2.1 使用AnimatedBuilder局部重建

// 不推荐:整个页面重建
@override
Widget build(BuildContext context) {
  return Scaffold(
    body: Opacity(
      opacity: _animation.value,
      child: TestWidget(), // 组件被重复重建
    ),
  );
}

// 推荐:只重建动画部分
@override
Widget build(BuildContext context) {
  return Scaffold(
    body: AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return Opacity(
          opacity: _animation.value,
          child: child, // 使用缓存的child
        );
      },
      child: TestWidget(), // 组件只构建一次
    ),
  );
}

6.2.2 使用Transform代替布局属性

// 不推荐:改变尺寸会触发布局重新计算
Container(
  width: _animation.value,
  height: _animation.value,
)

// 推荐:Transform只影响绘制,不触发布局
Transform.scale(
  scale: _animation.value,
  child: Container(
    width: 100,  // 固定尺寸
    height: 100,
  ),
)

6.2.3 避免在动画中使用Opacity

// 不推荐:Opacity会导致整个子树重绘
Opacity(
  opacity: _animation.value,
  child: TestComplexWidget(),
)

// 推荐:使用颜色透明度
Container(
  color: Colors.blue.withOpacity(_animation.value),
  child: TestComplexWidget(),
)

// 推荐:使用FadeTransition
FadeTransition(
  opacity: _animation,
  child: TestComplexWidget(),
)

6.3 创建一个加载动画

下面用一个加载动画效果串一下上面所讲内容:

class SmoothLoadingAnimation extends StatefulWidget {
  @override
  _SmoothLoadingAnimationState createState() => _SmoothLoadingAnimationState();
}

class _SmoothLoadingAnimationState extends State<SmoothLoadingAnimation> 
    with SingleTickerProviderStateMixin {
  
  late AnimationController _controller;
  late Animation<double> _rotationAnimation;
  late Animation<double> _scaleAnimation;
  late Animation<Color?> _colorAnimation;
  
  @override
  void initState() {
    super.initState();
    
    _controller = AnimationController(
      duration: Duration(milliseconds: 1500),
      vsync: this,
    )..repeat(reverse: true);
    
    _rotationAnimation = Tween<double>(
      begin: 0.0,
      end: 2 * 3.14159,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    ));
    
    _scaleAnimation = Tween<double>(
      begin: 0.8,
      end: 1.2,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    ));
    
    _colorAnimation = ColorTween(
      begin: Colors.blue[300],
      end: Colors.blue[700],
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    ));
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Transform.rotate(
          angle: _rotationAnimation.value,
          child: Transform.scale(
            scale: _scaleAnimation.value,
            child: Container(
              width: 60,
              height: 60,
              decoration: BoxDecoration(
                color: _colorAnimation.value,
                borderRadius: BorderRadius.circular(30),
                boxShadow: [
                  BoxShadow(
                    color: Colors.black.withOpacity(0.2),
                    blurRadius: 10,
                    offset: Offset(0, 5),
                  ),
                ],
              ),
              child: Icon(
                Icons.refresh,
                color: Colors.white,
                size: 30,
              ),
            ),
          ),
        );
      },
    );
  }
}

总结

核心知识点

  • 理解了Animation、AnimationController、Tween、Curve的作用
  • 掌握了动画状态的生命周期管理
  • 理解了隐式动画的自动管理机制
  • 学会了创建各种类型的补间动画
  • 学会了使用Curves让动画更自然
  • 学会了创建复杂的自定义动画
  • 学会了使用AnimatedBuilder优化性能

一个好的应用整体动画效果风格要一致,同时要保持流畅度,增强用户体验。通过本节学习,相信大家已经掌握了Flutter动画的核心概念和实战技巧。多动手实现各种动画效果,不断调试优化吧~~~ 有任何问题欢迎在评论区留言,看到会第一时间回复!

Xcode 没人想解决的问题:为什么苹果对平庸感到满意

十年的 iOS 开发,数千次崩溃,以及一个令人不安的事实:Xcode 的糟糕程度,恰好是苹果愿意容忍的程度。

如果你做 iOS 开发者超过,比方说,五分钟,你可能就经历过以下情景:

  • 你打开 Xcode

  • 你按下 ⌘B\text{⌘B}(构建/编译)。

  • 发生了点什么。但不是你预期的那个东西。

    • 构建挂起了。
    • 索引器卡死了。
    • 模拟器毅然决然地说:今天不是我工作的日子(即模拟器出故障或运行缓慢)。

image.png

然后,在库比蒂诺的某个角落,有人耸了耸肩,表示无所谓。

欢迎来到 Xcode 的世界:这是一款没人爱,人人忍,却被全球最有价值公司保护着的唯一一款 IDE(集成开发环境)。

但我今天不是来单纯发牢骚的。我搞 iOS 应用已经十多年了,从 Xcode 4 熬到了 16。经历了 Swift 的普及、Storyboards、SwiftUI、并发编程等等,所有的一切。

经过这几年的心力交瘁,我得出了一个残酷的结论:

Xcode 不是“意外变差的”。它目前的平庸程度,恰恰是苹果允许它达到的程度。


日常 Xcode 体验:一份痛苦清单

咱们说实话:Xcode 不只是有 Bug,它是以一种我们非常熟悉的方式持续地不可预测。

一个典型的 Xcode 日常是这样的:

  • 刚加了一个新文件, “Indexing…” 就卡住了 5 分钟。
  • 在一个大文件里输入 .\text{.} 的时候,自动补全直接冻结。
  • SwiftUI 预览可能工作了一次,然后就消失了,只留下 “Some previews failed.”
  • 构建(Build)成功了。App 没启动。模拟器崩了。Xcode 在撒谎。
  • 断点(Breakpoints) …自己消失了。
  • 你叹了口气。打开活动监视器,把那个索引进程杀掉。

你甚至已经不再惊讶了。而这才是更大的问题。

很多开发者已经停止质疑了。我们默默地接受了这种平庸。


Xcode 是如何变成一个瓶颈的

Xcode 一开始没这么烂。当然,它一直有点笨重——但至少还能应付。

然后一切都变了。

1. Swift 登场了

Swift 让 Xcode 的复杂性呈指数级增长。它是一个强大的语言,但编译器也非常重

再加上 Swift 每年都在变,Xcode 就必须疲于应付:

  • 旧的 Swift 项目
  • 正在过渡中的项目
  • 使用最新特性的新代码库

这可不是正常的 IDE 应该有的工作量。

2. SwiftUI 登场了

SwiftUI 预览本应实时显示 UI 变化。但它们经常:

  • 冻住
  • 悄无声息地失败
  • 像个挖矿程序一样耗干你的笔记本电池

开发者已经学会了不信任预览。如果一个开发工具教会你不要相信它显示的内容——那就有大问题了。

3. 年度操作系统更新周期

苹果每年都会发布新的 iOS、macOS、watchOS、tvOS,现在还有 visionOS。Xcode 必须全部支持。

每一年,都得来一遍。

那什么事情从来没有发生过呢?

“我们停下来,重构一下,花三年时间把工具链做得坚如磐石吧。”

结果就是:每年六月都有新东西。新的 Bug。新的临时解决方案。但 Xcode 还是那个 Xcode。


为什么苹果就是不修复它?

简单的答案:他们没必要

苹果垄断了整个生态

如果你想做 iOS App,你就得用 Xcode。就这么简单。

没有竞争。没有替代品。除非能帮到设备销售,否则苹果没有动力去投资提升开发者的幸福感。

这就是为什么你会看到:

  • CI/CD\text{CI/CD} 流水线在底层也必须使用 Xcode
  • IDE 工作流程被绑定在专有的代码签名过程上
  • 一群沮丧的开发者社区,却仍然在不断地交付应用

开发者的痛苦不是 KPI\text{KPI}

苹果在乎的是:

  • iPhone 销量
  • 生态系统锁定
  • 服务收入
  • App Store 收入
  • WWDC 制造的热度

这个清单里,根本没有这条:

“这个季度 Xcode 导致开发者浪费了 300 小时在构建失败上吗?”

如果它不影响收入或公众形象——那就不是优先事项。

WWDC\text{WWDC} 文化:炫酷 > 稳定

苹果的文化是宣布“下一步是什么”。新的 API\text{API}。新的功能。新的框架。

没有人会站在台上说:

“Xcode 现在速度提升了 50%,崩溃减少了 60%。”

这不带感。这在推特上火不起来。

“Vision Pro 新 API\text{API}” 才能火。


平庸的隐性成本

Xcode 最糟糕的部分不是崩溃。而是我们已经把这种感受常态化了:

“做 iOS 开发,就是这样的。”

我们学会了:

  • 像条件反射一样删除 DerivedData\text{DerivedData} 文件夹
  • 避免某些重构,因为索引器可能会挂掉
  • 禁用预览,改用 print\text{print} 语句来调试
  • 接受模拟器崩溃是工作的一部分

这会以一种看不见的方式拖慢团队进度。

  • 它破坏了心流状态。
  • 它消耗了认知能量。
  • 它给新开发者灌输了错误观念:现代开发流程就该是这个样子。

而且它悄悄地赶走了人才。一些非常牛的工程师——尤其是做后端的人——试了一下 iOS,体验了一下 Xcode,然后就再也没回来了。


一个“不平庸”的 Xcode 应该是什么样的

让我们幻想一下。

一个很棒的 Xcode 应该:

  • 启动很快。而不是“卡顿 40 秒来重建模块”。
  • 提供值得信任的预览,即使失败也能体面地给出提示。
  • 给出人能看懂的构建错误,而不是用另一种语言抛出编译器崩溃。
  • 允许你直观地调试异步代码。
  • 让你可靠地进行内部整理:重构、依赖升级和诊断。

如果苹果愿意把它当作一款产品来重新构想,而不仅仅是作为一个依赖工具,它可能会是世界上最好的 IDE\text{IDE}

但这需要高层把这件事列为优先事项。


那么,我们能做什么?

我们没有万亿美元的预算。但作为开发者,我们手上有两个筹码:

1. 要求苹果负责

不是通过空洞的愤怒,而是通过清晰的沟通:

  • 提交详细的反馈。
  • 公开谈论真实的用户体验问题。
  • 别再美化开发者体验了。

沉默对任何人都没有好处。

2. 呼吁更好的开发者文化

让我们停止说:

“唉,Xcode 就那样。”

让我们开始说:

“这坏了。而且这不应该是常态。”

最后的想法

Xcode 不是我们的私人仇敌。它是系统性选择的结果——一个刚好能用来发布 iOS 应用的工具。

但我们值得拥有更好的。

我们为苹果带来了世界上最具创新性的应用。

我们在他们的平台上建立了数十亿美元的业务。

我们学习了 Swift、SwiftUI、Async/Await、Combine、Concurrency。

我们遵守了所有模式。学会了所有规则。买了所有 Mac。

难道我们要求 Xcode 能正常工作,过分吗?

不用花哨。不用神奇。只要可靠就行。

在苹果把 Xcode 当作一款值得完善的产品之前,我们得到的就会是现在这个样子:

一个打磨得无比精美的硬件生态系统……运行在一个开发者们多希望自己能修复的,摇摇晃晃的软件地基之上。

欢迎关注我的公众号:OpenFlutter

SwiftUI 状态管理极简之道:从“最小状态”到“状态树”

为什么“状态”是 SwiftUI 的牛顿第三定律?

在物理学里,力与反作用力成对出现;在 SwiftUI 里,状态变化与UI 反应也成对出现。

用户每一次点击、每一次网络返回,都相当于给系统施加了一个“力”,而 UI 必须以某种“反作用力”做出响应。

因此,状态管理不是可选技能,而是 SwiftUI 世界的万有引力。

最小状态原则(Minimal State)

先记住一句“咒语”: “能让 UI 正确且及时响应的最少状态到底是哪些?

凡是不在这份清单里的数据,一律:

  • 计算得出 → 用 computed property
  • 可推导 → 不用 @State
  • 临时存活 → 用 let 或局部变量

这样做的好处:

  1. 减少无效刷新,提升性能
  2. 降低心智负担,代码更易读
  3. 为后续拆分模块、复用组件扫清障碍

状态载具速查表

属性包装器 作用域 典型用途 备注
@State 当前 View 私有 局部 UI 小数据(如 String、Bool、Int) 值类型
@Binding 父子共享 将“引用”传给子 View,使其能修改父数据 不拥有数据
@StateObject 当前 View 私有 创建并持有引用类型(如 ObservableObject) 生命周期与 View 一致
@ObservedObject 任意 View 外部传入的 ObservableObject 不创建,只引用
@Environment 全局 系统级值(如 colorScheme、locale 等) 通过 key 读取
@EnvironmentObject 全局 自定义共享对象 需提前注入

本文不讨论 MVVM / MVC / TCA 等架构,只聚焦“状态本身如何存在、如何流动”。

从 0 到 1:状态是如何“被声明”的?

静态单状态 —— 宇宙奇点

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
    }
}

注释:没有 @State,UI 永远不变,宇宙一片死寂。

引入第一个 @State —— 宇宙大爆炸

struct ContentView: View {
    @State private var statefulText: String = "Stateful Text"
    
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text(statefulText)          // 依赖状态
        }
        .padding()
    }
}

注释:现在 UI 可以随 statefulText 变化而刷新,但用户还没法干预。

让用户当“上帝”—— 引入交互

struct ContentView: View {
    @State private var statefulText: String = "Stateful Text"
    
    var body: some View {
        VStack {
            Button {
                statefulText = "Ouch!"  // 用户施加“力”
            } label: {
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundStyle(.tint)
            }
            Text(statefulText)          // 自动反应
        }
        .padding()
    }
}

注释:状态变化 ⇄ UI 反应 的环路闭合,宇宙开始演化。

多状态宇宙:枚举扛起大旗

把“加载中/加载成功/错误/哲学生命周期”抽象成枚举,一次性把状态机搬进 UI:

// 1. 定义状态机
enum ViewState {
    case loading
    case loaded
    case error
    case whoami
}

struct ContentView: View {
    // 2. 最小状态:当前处于哪一步
    @State private var currentState: ViewState = .loading
    // 3. 仅 loaded 才关心的文字
    @State private var statefulText: String = "We did it!"
    
    var body: some View {
        Group {
            switch currentState {
            case .loading:
                ContentUnavailableView("One moment please...",
                                       systemImage: "hourglass")
            case .loaded:
                loadedUI
            case .error:
                ContentUnavailableView("Oops!",
                                       systemImage: "x.circle")
            case .whoami:
                Text("Existential Crisis!")
            }
        }
        .task {
            // 4. 模拟网络请求
            do {
                try await Task.sleep(nanoseconds: 2_000_000_000)
                currentState = .loaded
            } catch {
                currentState = .error
            }
        }
    }
    
    // 把 loaded 状态 UI 拆成 computed property,可读性更好
    private var loadedUI: some View {
        VStack {
            Button("点我改文字") {
                statefulText = "Ouch!"
            }
            Button("进入哲学模式") {
                currentState = .whoami
            }
            Text(statefulText)
        }
        .padding()
    }
}

注释:

  • switch 做穷尽式匹配,编译器帮你检查漏掉的状态
  • .task 修饰符在视图出现时自动执行,离开即取消,比 onAppear 更安全

状态树:像公司一样分部门治理

当父 View 既要管“加载枚举”又要管“文字细节”时,责任过重。

把只跟 loaded 相关的状态下放给子 View,父级只保留“导航级”状态,形成状态树:

struct ContentView: View {
    @State private var currentState: ViewState = .loading
    
    var body: some View {
        Group {
            switch currentState {
            case .loading:
                ContentUnavailableView("One moment please...",
                                       systemImage: "hourglass")
            case .loaded:
                LoadedView(currentState: $currentState) // 只传绑定
            case .error:
                ContentUnavailableView("Oops!",
                                       systemImage: "x.circle")
            case .whoami:
                Text("Existential Crisis!")
            }
        }
        .task {
            do {
                try await Task.sleep(nanoseconds: 2_000_000_000)
                currentState = .loaded
            } catch {
                currentState = .error
            }
        }
    }
}

// 子 View:只关心 loaded 世界
struct LoadedView: View {
    @Binding var currentState: ViewState // 需要回跳父级,用 Binding
    @State private var statefulText: String = "We did it!" // 局部状态
    
    var body: some View {
        VStack(spacing: 20) {
            Button("改文字") {
                statefulText = "Ouch!"
            }
            Button("哲学模式") {
                currentState = .whoami
            }
            Text(statefulText)
                .font(.title)
        }
        .padding()
    }
}

注释:

  • 父 View 代码量瞬间减半,职责单一
  • 子 View 可独立预览、独立测试,甚至一键抽成 Swift Package给别的项目用

递归地追问“最小状态”

状态树不是一层就够。如果 LoadedView 里再出现子模块(比如点赞数、评论列表),继续问自己:

  1. 这些子模块是否必须由父级驱动?
  2. 能否把“点赞数”做成 @StateObjectLikesService,通过 @EnvironmentObject 注入?
  3. 能否把“评论列表”做成纯粹 @State 的局部数组,只在进入评论区才初始化?

每一层都回答一次“最小状态”问题,复杂度就被递归地压扁。

实战扩展:把状态树做成“可插拔”模块

假设未来要加“夜间模式”全局开关:

@main
struct MyApp: App {
    @StateObject private var theme = ThemeService() // 遵循 ObservableObject
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(theme) // 一次性注入
        }
    }
}

任意深层子 View 只需:

struct DeepChild: View {
    @EnvironmentObject var theme: ThemeService
    
    var body: some View {
        Text("深夜模式自动响应")
            .foregroundColor(theme.labelColor)
    }
}

关键点:

  • 全局状态绝不放在根 View 的 @State,而是 @StateObject + @EnvironmentObject
  • 业务层继续遵循“最小状态”,不依赖全局主题即可独立运行

总结与 Checklist

  1. 先写“死”的 UI,再慢慢声明状态,而不是一上来就 @State 满天飞。
  2. 每一次加状态前,背一遍咒语:“这是让 UI 正确且及时响应的最小集合吗?”
  3. 当状态超过 3 个且互相耦合,立刻画状态树:
    • 谁能独立?→ 拆子 View
    • 谁需共享?→ 拆 ObservableObject
    • 谁只读?→ 用 Environment
  4. 把“枚举 + switch”当成状态机语法糖,穷尽所有 case,让编译器当你 QA。

学习文章

  1. captainswiftui.substack.com/p/swiftui-c…

Flutter iOS 项目 UIScene 迁移指南


一、UIScene 是什么?

在 iOS 13 发布时,Apple 引入了 UIScene,正式把一个 App 的生命周期从「单个进程」拆成了「多个场景(Scene)」。
简单理解:

旧时代(AppDelegate) 新时代(UIScene)
一个 App 只有一个窗口 一个 App 可以同时有多个窗口(iPad 分屏、外接屏、多任务)
所有 UI 生命周期都在 AppDelegate 每个窗口(Scene)都有自己的 SceneDelegate 管理生命周期
applicationDidBecomeActive 等方法全局生效 每个 Scene 独立触发 sceneDidBecomeActive

二、2025 年最硬的刀:iOS 26 强制适配 UIScene 警告

未进行适配的iOS项目,运行后会有一个红色的警告:

三、Flutter 项目 UIScene 迁移步骤

前置条件

# pubspec.yaml
environment:
  sdk: ^3.10.0
  flutter: ">=3.38.0" 

方式一:自动迁移(推荐先试)

flutter config --enable-uiscene-migration
flutter clean
flutter run

看到 Finished migration to UIScene lifecycle 就说明成功了,直接跳到最后测试即可。如果有自定义代码,可能会提示手动迁移。

方式二:手动迁移(自定义过 AppDelegate 的项目)

1. 改造 AppDelegate

  • Swift
import UIKit
import Flutter

@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    // 不要再在这里注册插件了!
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  // 所有插件注册必须搬到这里
  func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
    GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
    
    // 你原来的 MethodChannel、PlatformView、原生代码全部搬到这里
    let channel = FlutterMethodChannel(
      name: "your_channel",
      binaryMessenger: engineBridge.applicationRegistrar.messenger()
    )
  }
}

2. 添加 Application Scene Manifest

打开 ios/Runner/Info.plist,加入以下内容:

<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <false/> <!-- 普通 App 保持 false -->
    
    <key>UISceneConfigurations</key>
    <dict>
        <key>UIWindowSceneSessionRoleApplication</key>
        <array>
            <dict>
                <key>UISceneClassName</key><string>UIWindowScene</string>
                <key>UISceneDelegateClassName</key><string>FlutterSceneDelegate</string>
                <key>UISceneConfigurationName</key><string>flutter</string>
                <key>UISceneStoryboardFile</key><string>Main</string>
            </dict>
        </array>
    </dict>
</dict>

3. (可选)创建自定义 SceneDelegate

如果你原来在 AppDelegate 里写了前后台逻辑,全部搬到这里:

// SceneDelegate.swift
import UIKit
import Flutter

class SceneDelegate: FlutterSceneDelegate {
  
  override func sceneDidBecomeActive(_ scene: UIScene) {
    super.sceneDidBecomeActive(scene)
    // 原来 applicationDidBecomeActive 的代码
  }
  
  override func sceneWillResignActive(_ scene: UIScene) {
    super.sceneWillResignActive(scene)
    // 原来 applicationWillResignActive 的代码
  }
}

然后把 Info.plist 里的 UISceneDelegateClassName 改成:

<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>

4. 清理重建

flutter clean
cd ios && pod install --repo-update && cd ..
flutter run

四、Add-to-App (ATA) 项目迁移

如果你的 Flutter 是嵌入现有 iOS App(Add-to-App),迁移更复杂,因为需要手动管理 FlutterEngine 和场景生命周期。以下是详细步骤(基于 Flutter 官方指南):

1. 在 application:configurationForConnecting:options: 中设置 Delegate Class

在你的现有 AppDelegate 中,为连接的场景配置 FlutterSceneDelegate。这确保每个新场景使用 Flutter 的场景委托。

Swift 示例:

override func application(
  _ application: UIApplication,
  configurationForConnecting connectingSceneSession: UISceneSession,
  options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
  let configuration = UISceneConfiguration(
    name: nil,
    sessionRole: connectingSceneSession.role
  )
  configuration.delegateClass = FlutterSceneDelegate.self  // 设置为 FlutterSceneDelegate
  return configuration
}

Objective-C 示例:

- (UISceneConfiguration *)application:(UIApplication *)application
    configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession
    options:(UISceneConnectionOptions *)options {
        UISceneConfiguration *configuration = [[UISceneConfiguration alloc] initWithName:nil sessionRole:connectingSceneSession.role];
        configuration.delegateClass = [FlutterSceneDelegate class];
        return configuration;
    }

2. 禁用多场景支持(除非必要)

Info.plist 中设置 UIApplicationSupportsMultipleScenesNO,避免不必要的多窗口复杂性。除非你的 App 明确需要 iPad 分屏或外部显示器支持。

<key>UIApplicationSupportsMultipleScenes</key>
<false/>

3. 手动创建并运行 FlutterEngine

在 SceneDelegate 的 scene:willConnectToSession:options: 中手动创建 FlutterEngine,运行它,注册插件,并设置视图层次。Flutter 不会自动处理引擎创建。

Swift 示例(子类 FlutterSceneDelegate):

class SceneDelegate: FlutterSceneDelegate {
  let flutterEngine = FlutterEngine(name: "my flutter engine")  // 手动创建引擎

  override func scene(
    _ scene: UIScene,
    willConnectTo session: UISceneSession,
    options connectionOptions: UIScene.ConnectionOptions
  ) {
    guard let windowScene = scene as? UIWindowScene else { return }
    window = UIWindow(windowScene: windowScene)

    flutterEngine.run()  // 运行引擎
    GeneratedPluginRegistrant.register(with: flutterEngine)  // 注册插件
    self.registerSceneLifeCycle(with: flutterEngine)  // 注册场景生命周期

    let viewController = ViewController(engine: flutterEngine)  // 创建 FlutterViewController
    window?.rootViewController = viewController
    window?.makeKeyAndVisible()
    
    super.scene(scene, willConnectTo: session, options: connectionOptions)
  }
}

Objective-C 示例:

@interface SceneDelegate : FlutterSceneDelegate
@property (nonatomic, strong) FlutterEngine *flutterEngine;
@end

@implementation SceneDelegate

- (instancetype)init {
    if (self = [super init]) {
        _flutterEngine = [[FlutterEngine alloc] initWithName:@"my flutter engine"];
    }
    return self;
}

- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session
                                    options:(UISceneConnectionOptions *)connectionOptions {
                                        if (![scene isKindOfClass:[UIWindowScene class]]) {
                                            return;
                                        }
                                        UIWindowScene *windowScene = (UIWindowScene *)scene;
                                        self.window = [[UIWindow alloc] initWithWindowScene:windowScene];

                                        [self.flutterEngine run];
                                        [GeneratedPluginRegistrant registerWithRegistry:self.flutterEngine];
                                        [self registerSceneLifeCycleWithFlutterEngine:self.flutterEngine];

                                        ViewController *viewController = [[ViewController alloc] initWithEngine:self.flutterEngine];
                                        self.window.rootViewController = viewController;
                                        [self.window makeKeyAndVisible];

                                        [super scene:scene willConnectToSession:session options:connectionOptions];
                                    }
@end

4. 注册和注销场景生命周期

  • 注册(registerSceneLifeCycle) :在 willConnectToSession 中调用,确保引擎接收场景回调。
  • 注销(unregisterSceneLifeCycle) :在场景断开(如 sceneDidDisconnect)或切换时调用,避免内存泄漏。

Swift 示例:

// 注册
self.registerSceneLifeCycle(with: flutterEngine)

// 注销(例如在 sceneDidDisconnect 中)
self.unregisterSceneLifeCycle(with: flutterEngine)

Objective-C 示例:

// 注册
[self registerSceneLifeCycleWithFlutterEngine:self.flutterEngine];

// 注销
[self unregisterSceneLifeCycleWithFlutterEngine:self.flutterEngine];

5. 替代方案:实现 FlutterSceneLifeCycleProvider

如果无法直接子类 FlutterSceneDelegate,实现 FlutterSceneLifeCycleProvider 协议,手动转发回调。

Swift 示例:

class SceneDelegate: UIResponder, UIWindowSceneDelegate, FlutterSceneLifeCycleProvider {
  var sceneLifeCycleDelegate: FlutterPluginSceneLifeCycleDelegate =
    FlutterPluginSceneLifeCycleDelegate()

  func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
    sceneLifeCycleDelegate.scene(scene, willConnectTo: session, options: options)
    // 其他设置...
  }

  // 实现其他场景回调,如 sceneDidBecomeActive 等
}

6. 多场景支持

如果启用多场景,为每个场景单独创建 FlutterEngine,并在连接/断开时注册/注销。忘记注销可能导致重复回调或泄漏。

参考 Apple 指南:Migrating to the UIKit Scene-Based Life Cycle

五、Flutter 插件迁移

如果你的插件依赖 UI 生命周期(如权限请求、前后台切换),必须适配场景委托。并非所有插件都需要改;仅那些使用 UIApplicationDelegate UI 事件的才需更新。以下是详细步骤:

1. 更新 pubspec.yaml

确保插件支持新版本。

environment:
  sdk: ^3.10.0
  flutter: ">=3.38.0"

2. 采用 FlutterSceneLifeCycleDelegate

更新插件主类,实现协议以接收场景回调。

Swift 示例:

public final class MyPlugin: NSObject, FlutterPlugin, FlutterSceneLifeCycleDelegate {
  // 插件逻辑...
}

Objective-C 示例:

@interface MyPlugin : NSObject <FlutterPlugin, FlutterSceneLifeCycleDelegate>
// 插件逻辑...
@end

3. 注册插件为场景委托接收者

register(with:) 中,同时注册为 AppDelegate 和 SceneDelegate,支持向后兼容。

Swift 示例:

public static func register(with registrar: FlutterPluginRegistrar) {
  let instance = MyPlugin()
  registrar.addApplicationDelegate(instance)  // 保持 AppDelegate 支持
  registrar.addSceneDelegate(instance)  // 添加 SceneDelegate 支持
  // 其他注册,如 MethodChannel...
}

Objective-C 示例:

+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar {
    MyPlugin *instance = [[MyPlugin alloc] init];
    [registrar addApplicationDelegate:instance];
    [registrar addSceneDelegate:instance];
    // 其他注册...
}

4. 实现场景生命周期回调

实现协议方法,替换旧的 AppDelegate UI 事件。每个方法对应一个场景事件。

Swift 示例(关键方法):

// 场景连接(处理启动选项)
public func scene(
  _ scene: UIScene,
  willConnectTo session: UISceneSession,
  options connectionOptions: UIScene.ConnectionOptions?
) -> Bool {
  // 从 connectionOptions 处理启动 URL、UserActivity 等
  // 原来在 didFinishLaunchingWithOptions 的逻辑搬到这里
  return true
}

// 其他回调
public func sceneDidDisconnect(_ scene: UIScene) { /* 清理资源 */ }
public func sceneWillEnterForeground(_ scene: UIScene) { /* 前台准备 */ }
public func sceneDidBecomeActive(_ scene: UIScene) { /* 激活逻辑 */ }
public func sceneWillResignActive(_ scene: UIScene) { /* 暂停逻辑 */ }
public func sceneDidEnterBackground(_ scene: UIScene) { /* 后台处理 */ }
public func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) -> Bool { /* URL 处理 */ }
public func scene(_ scene: UIScene, continue userActivity: NSUserActivity) -> Bool { /* 续接活动 */ }
public func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) -> Bool { /* 快捷方式 */ }

Objective-C 示例(关键方法):

- (BOOL)scene:(UIScene*)scene willConnectToSession:(UISceneSession*)session options:(nullable UISceneConnectionOptions*)connectionOptions {
    // 处理启动选项
    return YES;
}

// 其他回调
- (void)sceneDidDisconnect:(UIScene*)scene { }
- (void)sceneWillEnterForeground:(UIScene*)scene { }
- (void)sceneDidBecomeActive:(UIScene*)scene { }
- (void)sceneWillResignActive:(UIScene*)scene { }
- (void)sceneDidEnterBackground:(UIScene*)scene { }
- (BOOL)scene:(UIScene*)scene openURLContexts:(NSSet<UIOpenURLContext*>*)URLContexts { return YES; }
- (BOOL)scene:(UIScene*)scene continueUserActivity:(NSUserActivity*)userActivity { return YES; }
- (BOOL)windowScene:(UIWindowScene*)windowScene performActionForShortcutItem:(UIApplicationShortcutItem*)shortcutItem completionHandler:(void (^)(BOOL))completionHandler { return YES; }

5. 迁移启动逻辑

将从 application:didFinishLaunchingWithOptions: 的逻辑移到 scene:willConnectToSession:options:,因为 UIScene 后 launchOptions 可能为 nil。

6. 测试与发布

  • 测试所有 UI 事件(如推送、权限)。
  • 保持 AppDelegate 注册以支持旧 App。

六、最后

做完以上,你的 Flutter iOS 项目就彻底拥抱了 UIScene 了。

官方文档:
docs.flutter.dev/release/bre…

适合iOS开发的一种缓存策略YYCache库 的原理

YYCache 是 iOS 上一个高性能的缓存框架,它由内存缓存 YYMemoryCache 和磁盘缓存 YYDiskCache 两部分组成。

核心总览

YYCache 的核心设计目标是 高效、线程安全和高性能。它通过以下方式实现这一目标:

  1. 分层设计:内存缓存提供极速访问,磁盘缓存提供大容量存储。
  2. LRU 淘汰算法:两者都使用 LRU 算法来管理缓存项,确保高频数据留在缓存中。
  3. 数据结构优化
    • 内存缓存:结合 NSDictionary 和双向链表。
    • 磁盘缓存:结合 SQLite 和文件系统。
  4. 锁策略优化:使用 pthread_mutex 锁来保证线程安全,性能优于 @synchronizeddispatch_semaphore

为了更直观地理解其核心工作原理,我们可以用以下流程图来展示其数据结构和关键操作:

image.png

上图揭示了YYCache的核心架构,下面我们来详细拆解图中各个部分的工作原理。

一、YYMemoryCache (内存缓存) 原理

YYMemoryCache 使用了一种非常经典且高效的数据结构组合:双向链表 + 哈希表

1. 核心数据结构:_YYLinkedMapNode_YYLinkedMap

  • _YYLinkedMapNode:链表节点。
    @interface _YYLinkedMapNode : NSObject {
        @package
        __unsafe_unretained _YYLinkedMapNode *_prev; // 指向上一节点
        __unsafe_unretained _YYLinkedMapNode *_next; // 指向下一节点
        id _key;      // 缓存的键
        id _value;    // 缓存的值
        NSUInteger _cost;   // 开销成本(用于成本计算)
        NSTimeInterval _time; // 访问时间
    }
    @end
    
  • _YYLinkedMap:一个双向链表,用于管理所有节点。
    @interface _YYLinkedMap : NSObject {
        @package
        CFMutableDictionaryRef _dic; // 哈希表,用于O(1)的存取
        NSUInteger _totalCost;      // 总开销
        NSUInteger _totalCount;     // 总数量
        _YYLinkedMapNode *_head;    // 链表头(MRU,最近使用)
        _YYLinkedMapNode *_tail;    // 链表尾(LRU,最久未使用)
    }
    @end
    

2. 工作原理

存取过程与LRU管理

其工作流程可以精确地描述为以下步骤:

sequenceDiagram
    participant A as Client(客户端)
    participant M as YYMemoryCache
    participant D as _dic (哈希表)
    participant L as 双向链表

    A->>M: setObject:forKey:
    M->>D: 通过key查找节点
    alt 节点已存在
        M->>L: 更新节点value,将节点移至_head
    else 节点不存在
        M->>M: 创建新节点
        M->>D: 插入新节点
        M->>L: 将新节点插入至_head
        M->>M: _totalCount++, _totalCost++
        loop 超过限制(count/cost)
            M->>L: 移除_tail节点(LRU)
            M->>D: 删除对应key
            M->>M: 更新_totalCount, _totalCost
        end
    end

    A->>M: objectForKey:
    M->>D: 通过key查找节点
    alt 节点存在
        M->>L: 将节点移至_head
        M->>A: 返回value
    else 节点不存在
        M->>A: 返回nil
    end

线程安全YYMemoryCache 使用 pthread_mutex 锁来保证上述所有操作(_dic 的读写、链表的修改)的线程安全性。它在每个操作开始时加锁,结束时解锁。


二、YYDiskCache (磁盘缓存) 原理

YYDiskCache 的设计更为复杂,它采用了一种智能的混合存储策略,根据 value 的大小选择不同的存储方式,以在性能和空间之间取得平衡。

1. 核心思想:SQLite + 文件系统

  • SQLite 数据库

    • 存储所有的 元数据(key, 文件名,大小,访问时间等)。
    • 如果 value 很小(例如小于 16KB),直接将其作为 BLOB 数据存储在数据库的某一列中
    • 优势:对于小数据,读写非常快,并且数据库事务保证了操作的原子性。
    • 方便实现 LRU 淘汰算法,只需要通过 SQL 语句操作元数据即可。
  • 文件系统

    • 如果 value 很大(例如大于 16KB),则将其写入单独的文件,在数据库中只记录其文件名和路径。
    • 优势:避免大文件塞满 SQLite 数据库,导致性能下降。文件系统对于大文件的读写效率更高。

2. 工作流程

存储过程:

  1. 根据 key 在数据库中查询记录。
  2. 判断 value 的数据大小。
  3. 小数据:直接写入 SQLite 的 data 列。如果之前是文件存储,则删除对应的文件。
  4. 大数据:将数据写入一个文件,并在数据库的 filename 列记录文件名。如果之前 SQLite 的 data 列有数据,则清空。
  5. 更新数据库中的元信息(大小、访问时间等)。

读取过程:

  1. 根据 key 从数据库中查询记录。
  2. 如果记录中有文件名(filename 不为空),则从文件系统中读取该文件。
  3. 如果记录中没有文件名,则直接从数据库的 data 列读取数据。
  4. 更新访问时间:每次读取后,都会在数据库中更新该记录的 last_access_time 字段,这对于实现 LRU 至关重要。

淘汰机制:

  1. 当磁盘缓存的总大小或总数量超过限制时,触发清理。
  2. 通过一条 SQL 查询,按照 last_access_time 升序排列(最久未使用的在前),获取需要淘汰的项。
  3. 根据查询结果,如果该项有文件,则删除文件;最后,从数据库中删除该记录。

三、YYCache 的整体协作

  1. 写入缓存

    • 先写入 YYMemoryCache
    • 再异步写入 YYDiskCache
  2. 读取缓存

    • 首先在 YYMemoryCache 中查找,找到则返回并更新链表。
    • 如果内存中没有,则去 YYDiskCache 中查找。
    • 如果在磁盘中找到,则将其返回给用户,并根据需要(可配置)写回 YYMemoryCache,以便下次快速访问。

总结

特性 YYMemoryCache YYDiskCache
存储介质 内存 磁盘 (SQLite + 文件系统)
数据结构 双向链表 + 哈希表 数据库表 + 文件
线程安全 pthread_mutex 串行队列 + dispatch_semaphore
淘汰算法 LRU (链表移动) LRU (SQL 按时间排序)
性能 极快,O(1) 较快,对小数据优化好
容量 受内存限制 受磁盘空间限制

YYCache 的成功在于其对经典算法和数据结构的深刻理解,并结合 iOS 平台特性进行了精妙的工程优化,使其成为了一个非常出色和可靠的缓存组件。

Re: 0x03. 从零开始的光线追踪实现-多球体着色

目标

上一节 已经实现一个球显示在窗口中央,这节的目标是显示多个球。

本节最终效果

image.png

先显示两个球

我们先来想像现实场景,假设你桌子有一个有显示器,此时你举起手机录屏,你能很直观认识到手机离你更近,显示器离你更远,你的眼睛就是那个摄像机,它发出的射线,肯定是先到手机,再到显示器。
现在我们代码做得事情就是,球就是“手机”,背景(天空)就是“显示器”,通过 intersect_sphere 我们可以计算出把“显示器”挡住的“手机”。

回到之前的代码,只显示一个球,也就是满足光线跟球相交时,就告诉 fragment shader 这里要应该显示某个颜色

if (intersect_sphere(ray, sphere) > 0) {
  return vec4f(1, 0.76, 0.03, 1);
}

现在我们要显示两个球,所以先弄一个数组。需要注意到,这里用 constant 是因为 MSL(Metal Shading Language)规定 Program scope variable must reside in constant address space(程序作用域的变量,必须放在常量地址空间),总之就是你要是写个函数外的常量,那就用 constant 把它放到常量地址空间去。

constant u32 OBJECT_COUNT = 2;

constant Sphere scene[OBJECT_COUNT] = {
  { .center = vec3f(0., 0., -1.), .radius = 0.5 },
  { .center = vec3f(0., -100.5, -1.), .radius = 100. },
};

声明结束后,在 fragment shader 函数内循环匹配光线相交。
我们把离咱们最近的值定义为 closest_t,初始值给个 Metal 内置的常量 FLT_MAX,它表示 float 的最大值(因为我们用了 float 类型),然后循环通过调用 intersect_sphere 计算的值 t 去更新 closest_t(因为 intersect_sphere 没匹配到会返回 -1,所以很显然我们要判断 t > 0.,同时要再判断下这个 t 是比已知最近的还要近的值,也就是要满足 t < closest_t)。

fragment vec4f fragmentFn(Vertex in [[stage_in]], constant Uniforms &uniforms [[buffer(1)]]) {
  // ...
  let ray = Ray { origin, direction };
  var closest_t = FLT_MAX;
  for (u32 i = 0; i < OBJECT_COUNT; ++i) {
    var t = intersect_sphere(ray, scene[i]);
    if (t > 0. && t < closest_t) {
      closest_t = t;
    }
  }
  if (closest_t < FLT_MAX) {
    return vec4f(1, 0.76, 0.03, 1);
  }
  return vec4f(sky_color(ray), 1);
}

于是就会显示

image.png

改颜色

这里因为我们设置的颜色是相同,所以连在一块根本分不清哪跟哪,所以我们可以让离得近得更亮,离得远的更暗,给原先设置的颜色再乘上一个值,saturate 这个是 MSL 内置的函数,作用是把小于 0 的转成 0,大于 1 的转成 1,在 [0,1][0, 1] 范围内的不变,等于讲,大的就乘多一点,小的就乘少一点,符合近得更亮,远得更暗的要求

fragment vec4f fragmentFn(Vertex in [[stage_in]], constant Uniforms &uniforms [[buffer(1)]]) {
  // ...
  let ray = Ray { origin, direction };
  var closest_t = FLT_MAX;
  for (u32 i = 0; i < OBJECT_COUNT; ++i) {
    var t = intersect_sphere(ray, scene[i]);
    if (t > 0. && t < closest_t) {
      closest_t = t;
    }
  }
  if (closest_t < FLT_MAX) {
    return vec4f(1, 0.76, 0.03, 1) * saturate(1. - closest_t);
  }
  return vec4f(sky_color(ray), 1);
}

现在我们能看到这个效果

image.png

实现目标效果

其实到这一步,只是换个颜色,为了实现目标效果,我们直接用 closest_t 作为基础值,在它的基础上转成颜色向量

if (closest_t < FLT_MAX) {
  return vec4f(saturate(closest_t) * 0.5);
}

这样就能实现最终效果


最后总结一下代码

#include <metal_stdlib>

#define let const auto
#define var auto

using namespace metal;

using vec2f = float2;
using vec3f = float3;
using vec4f = float4;

using u8 = uchar;
using i8 = char;
using u16 = ushort;
using i16 = short;
using i32 = int;
using u32 = uint;
using f16 = half;
using f32 = float;
using usize = size_t;

struct VertexIn {
  vec2f position;
};

struct Vertex {
  vec4f position [[position]];
};

struct Uniforms {
  u32 width;
  u32 height;
};

struct Ray {
  vec3f origin;
  vec3f direction;
};

struct Sphere {
  vec3f center;
  f32 radius;
};

constant u32 OBJECT_COUNT = 2;
constant Sphere scene[OBJECT_COUNT] = {
  { .center = vec3f(0., 0., -1.), .radius = 0.5 },
  { .center = vec3f(0., -100.5, -1.), .radius = 100. },
};

f32 intersect_sphere(const Ray ray, const Sphere sphere) {
  let v = ray.origin - sphere.center;
  let a = dot(ray.direction, ray.direction);
  let b = dot(v, ray.direction);
  let c = dot(v, v) - sphere.radius * sphere.radius;
  let d = b * b - a * c;
  if (d < 0.) {
    return -1.;
  }
  let sqrt_d = sqrt(d);
  let recip_a = 1. / a;
  let mb = -b;
  let t = (mb - sqrt_d) * recip_a;
  if (t > 0.) {
    return t;
  }
  return (mb + sqrt_d) * recip_a;
}

vec3f sky_color(Ray ray) {
  let a = 0.5 * (normalize(ray.direction).y + 1);
  return (1 - a) * vec3f(1) + a * vec3f(0.5, 0.7, 1);
}

vertex Vertex vertexFn(constant VertexIn *vertices [[buffer(0)]], uint vid [[vertex_id]]) {
  return Vertex { vec4f(vertices[vid].position, 0, 1) };
}

fragment vec4f fragmentFn(Vertex in [[stage_in]], constant Uniforms &uniforms [[buffer(1)]]) {
  let origin = vec3f(0);
  let focus_distance = 1.0;
  let aspect_ratio = f32(uniforms.width) / f32(uniforms.height);
  var uv = in.position.xy / vec2f(f32(uniforms.width - 1), f32(uniforms.height - 1));
  uv = (2 * uv - vec2f(1)) * vec2f(aspect_ratio, -1);
  let direction = vec3f(uv, -focus_distance);
  let ray = Ray { origin, direction };
  var closest_t = FLT_MAX;
  for (u32 i = 0; i < OBJECT_COUNT; ++i) {
    var t = intersect_sphere(ray, scene[i]);
    if (t > 0. && t < closest_t) {
      closest_t = t;
    }
  }
  if (closest_t < FLT_MAX) {
//    return vec4f(1, 0.76, 0.03, 1);
//    return vec4f(1, 0.76, 0.03, 1) * saturate(1. - closest_t);
    return vec4f(saturate(closest_t) * 0.5);
  }
  return vec4f(sky_color(ray), 1);
}

BLE 通信设计与架构落地

面向低功耗、稳定可靠 BLE 通信方案,从软件设计目标、方案选型到分层架构与关键流程,配套流程图与实现要点,直达量产质量与研发效率。

背景与目标

  • 背景:电机控制、状态采集、故障诊断与 OTA 升级都依赖移动端与设备端的低延迟、低功耗、稳定连接。
  • 目标:低功耗、快速连接、高可靠传输、应用层安全、可扩展协议、易维护 SDK。

架构总览

  • 分层清晰:UI → SDK → 协议 → BLE 客户端 → 设备固件 → 服务/状态机
  • 控制与状态分离:控制通道低延迟,状态通道稳定流式;OTA 独立不阻塞控制

截屏2025-11-22 12.45.56.png

方案选型

  • 传输:基于 GATT;命令走写入特征,设备通过通知特征上行响应与事件。
  • 编码:采用起止分隔符与转义机制,配合 XOR 校验保证帧边界与数据一致性;帧结构为 7E | cmd(1) | len(2) | payload | xor(1) | 7E
  • 命令集:包含应用版本查询、应用控制设置、仪表信息上报、控制响应等,区分查询型与设置型。
  • 解析:先进行包格式与校验验证,再按协议掩码解析为结构化的状态/响应模型。
  • MTU:优先协商更大 MTU 值,写入根据协商结果自动选择短写或长写以提升吞吐。
  • 服务与特征:定义稳定的服务 UUID 与写/通知特征 UUID;扫描阶段可结合常见服务进行辅助过滤提升发现成功率。

连接生命周期

  • 秒级发现、自动检测系统已连/已配对设备;MTU 协商后开启 Notify 再发指令
flowchart TB
  A[扫描广播] --> B{过滤设备}
  B -->|匹配UUID/厂商数据| C[建立GATT连接]
  C --> D[MTU协商]
  D --> E[订阅Notify特征]
  E --> F[开启通知]
  F --> G[正常通信]
  H --> I{断链?}
  I -->|是| J[退回扫描/优先重连]
  I -->|否| H

指令与应答

  • 上层采用统一命令模型构建请求,协议层负责组帧与发送;设备通过通知返回响应或状态,协议处理器完成验证与解析,上层按请求-响应模式匹配回调。
sequenceDiagram
  participant App
  participant SDK
  participant Device

  App->>SDK: send(Command)
  SDK->>SDK: 组帧(7E/转义/XOR)
  SDK->>Device: 写入数据包
  Device-->>SDK: 通知上行数据包
  SDK-->>SDK: 验证/解析为模型
  SDK-->>App: 回调(响应/状态)

服务与特征

  • 服务与特征保持稳定命名,便于跨端协作与维护;扫描阶段可结合通用服务过滤以提升发现效率。
  • 写入策略根据 MTU 协商结果选择短写/长写,兼顾效率与兼容性。
flowchart LR
  CtrlSvc[控制服务 0xFFF0] --> cmd_write[cmd_write: Write NR]
  CtrlSvc --> cmd_notify[cmd_notify: Notify]
  StateSvc[状态服务 0xFFF1] --> state_notify[state_notify: Notify]
  OTASvc[OTA服务 0xFFF2] --> ota_write[ota_write: Write]
  OTASvc --> ota_notify[ota_notify: Notify]

协议帧设计

  • 结构:7E | cmd(1) | len(2,BE) | payload(N) | xor(1) | 7E
  • 转义:对分隔符与转义字符采用双字节转义,保证帧边界不被误判。
  • 校验:使用 XOR 校验覆盖命令、长度与载荷,快速验证数据一致性。
  • 验证:先校验起止与最小长度,再重算校验对比,失败立即丢弃并上报错误。

安全策略(可选)

  • 基于明文帧 + 校验的基本可靠性方案;如需加强安全,可在载荷层加入签名与时间戳,或在连接建立后协商会话密钥并进行载荷加密与认证。

OTA 升级

  • 使用长写支持大数据包传输,结合 MTU 协商提升吞吐;分片与断点续传由上层控制,升级流程与控制通道隔离,保障常规通信不受影响。
flowchart LR
  Start[开始升级] --> Prepare[校验包/签名/版本]
  Prepare --> Switch[进入DFU模式/暂停控制通道]
  Switch --> Chunk[分片发送]
  Chunk --> Ack{应答/校验通过?}
  Ack -->|否| Retry[重传/断点续传]
  Ack -->|是| Commit[提交/应用新固件]
  Commit --> Reboot[重启/回到正常模式]
  Reboot --> End[升级完成]

低功耗策略

  • 广播:普通 300–500ms;待机 800–1200ms;事件触发短时提升
  • 连接:interval=30–50msslaveLatency=4–10timeout=4–6s
  • MTU:协商至 185,分片随 ATT 与机型差异自适应
  • 节流:状态合并与节流(100–200ms);小包合帧减少唤醒

关键实现要点

  • 重传窗口:大小 3–5;超时 300–500ms;指数退避防抖
  • 指令模型:区分必答控制与可跳过状态;明确重试与时限
  • 线程模型:移动端 BLE 回调线程与业务线程解耦;设备侧事件与控制循环分离
  • 指标与日志:连接时延、MTU、吞吐、丢包、重试、握手耗时、失败原因

调试与调优

  • 吞吐压测:不同 MTU 下 pps/重试率 对比;寻找最佳分片
  • 稳定性:高干扰场景(电机 PWM/金属环境)重连成功率与耗时
  • 兼容性:iOS/Android 栈差异(如 Android GATT 133);连接后强制 discoverServices
  • 低功耗验证:记录电流曲线,量化参数调整影响
  • OTA 可靠性:断电/断链恢复、双镜像回滚、进度一致性

常见坑与规避

  • iOS 后台:后台模式声明与速率控制;避免系统挂起
  • Android 缓存:特征缓存可能写旧;连接后重新 discoverServices
  • MTU 协商:必须在连接成功后;返回值可能不等于实际可用
  • Notify 订阅:先订阅再发指令;避免早期响应丢失
  • 多连接:一车一连;设备侧拒绝第二连接请求

实现思路要点

  • 命令模型统一、组帧规范化、解析结构化,方便扩展与回归测试。
  • 请求-响应严格时序:先订阅响应流,再发送请求,避免早期响应丢失。
  • 设备数据流按设备维度隔离,支持多设备并发管理与订阅清理。
  • 日志与指标贯穿链路:连接与协商、吞吐与丢包、解析与错误,辅助定位与调优。

结语

  • 核心在于稳定、可靠与可维护。围绕 GATT 传输、稳健的帧/转义/校验与清晰的请求-响应模式,既保证 eBike 场景的低功耗与高可靠,又为后续安全与功能扩展留足空间。

[WWDC 21]Detect and diagnose memory issues 笔记

developer.apple.com/videos/play…

概述

本Session主要解释了App内存由哪些部分组成,并介绍了可以使用Performace XCTests工具对内存问题进行排查、分析

Impact of Memory footprint

好的内存管理可以提升用户体验,可以表现在

  1. Faster application activation,因为内存控制的好,所以app进入后台时不易被系统终止,重新激活回到前台时也更快
  2. Responsive experience,更高的响应速度
  3. Complex workflows,内存控制的好则意味着可以增加更多更消耗内存的功能
  4. Wider device compatibility,控制好内存则可以兼容到更老的机器

Memory footprint

本小节主要介绍Memory footprint都是有哪些内容组成

Dirty memory consists of memory written by your application. It also includes all heap allocations such as when you use malloc, decoded image buffers, and frameworks.

Compressed memory refers to any dirty pages that haven't recently been accessed that the memory compressor has compressed. These pages will be decompressed on access.

Tools for profiling memory

Performance XCTests

可以使用XCTests去检查各个功能的性能表现,比如

  • Memory utilization,内存利用情况
  • CPU usage
  • Disk writes
  • Hitch rate,卡顿率
  • Wall clock time,功能的耗时情况
  • Application launch time

Memory utilization 示例

func testSaveMeal() {
    let app = XCUIApplication()
    let options = XCTMeasureOptions()
    options.invocationOptions = [.manuallyStart]
    measure(metrics: [XCTMemoryMetric(application: aapp)1
        options: options) {
        app.launch()
        startMeasuring()
        app.cells.firstMatch.buttons["Save meal"].firstMatch.tap()
        let savedButton = app.cells.firstMatch.buttons["Saved"].firstMatch
        XCTAssertTrue (savedButton.waitForExistendce(timeout: 30)
    }
}

上述代码检测的是点击“Save meal”按钮后内存的变化情况,变化情况如下图所示:

  1. Metric项可以选择不同的内存检测指标,如内存峰值还是普通内存值
  2. 底部柱状图表示是多次执行的情况
  3. Average项表示的是所选Metric的均值情况
  4. Baseline、Max STDDEV(最大标准差)则可以用来设置检测基线和上下浮动阈值
  5. 当检测结束后,可以通过Result查看本次检测结果变好了还是变差了

Diagnostic collection

Xcode 13中引入了两个有力的诊断数据Ktrace files和Memory graphs

执行Performance XCTests时可以开启他们

Ktrace files

是一种专用的文件类型,用于分析卡顿问题,可以用Instrument直接打开

详情可以参考

Memory graphs

某一时刻,App内存中所有对象及引用关系数据

  • 在日常使用Xcode debug App时,我们也能看到内置的Memory graphs功能
  • 下图为运行完XCTests后,结果中Memory graphs文件,也可以单独进行分析

Types of memory issues

内存问题类型有多种,本session中会介绍到Leaks(内存泄漏)和Heap size issues

Leaks

对于内存泄漏问题,可以使用leaks命令对钱文忠的Memory graphs文件进行分析,查找泄漏的代码堆栈、是否存在循环引用

Heap size issues

Heap allocations regressions

堆内存占用的劣化问题

为减少使用堆内存开辟空间导致内存占用劣化,我们可以这样做:

  • Remove unused allocations
  • Shrink overly large allocations
  • Deallocated memory you are finished with
  • Wait to allocate memory until you need it

Session中提到,官方提供了如vmmap等一系列命令对前面生成的Memory graphs文件分析

Fragmentation

Fragmentation译为碎片化

如何可以减少碎片化

  • Allocate objects with similar lifetimes close to each other
  • Aim for 25% fragmentation
  • Use autorelease pools
  • Pay extra attention to long running processes
  • Use allocations track in Instruments

也可以使用vmmap等命令查看碎片比例

  • 上图中FRAG是碎片比例
  • DIRTY SIZE表示碎片化导致的Dirty pages情况
  • DIRTY+SWAP FRAG SIZE表示的是碎片空间大小

总结

总结一下官方推荐的检测、诊断内存问题的最佳实践

第一步:先是检测

  1. 针对业务功能/场景编写Performance XCTests
  2. 设置baseline(基线)进行测试
  3. 如果发现有regression(劣化),则收集诊断数据(Memory graphs or Ktrace)

第二步:诊断

  1. 检查最易发现的内存泄漏
  2. 再使用各种命令查看是否有堆内存劣化

🔥 一句话解释 SNI

SNI 是 TLS 握手时,客户端提前告诉服务器:”我想访问的是哪一个域名“。

它的作用是让服务器在 TLS 握手阶段就能知道要给哪个域名的证书


🎯 为什么需要 SNI?

因为现在很多网站是 多域名共享同一个 IP

例如:

1.2.3.4 上托管了:
- api.a.com
- img.a.com
- pay.a.com

如果没有 SNI:

客户端连接 1.2.3.4:443 → TLS 握手开始
服务器此时 不知道你想访问哪个域名的证书
→ 就没法送对证书
→ 导致握手失败

所以 TLS 扩展 “SNI” 就诞生了。


🧠 SNI 在 TLS 握手里到底发生了什么?

TLS 1.2 / 1.3 都一样,基本流程是:

ClientHello:
    - TLS Versions
    - Cipher Suites
    - Extensions:
        - SNI = "api.example.com"   ← 就是这里!
        - ALPN (比如 h2/h3)
        -

服务器收到 ClientHello 后,看到 SNI:

哦,你访问的是 api.example.com!
那我给你发 api.example.com 的证书

然后握手继续。


🔥 SNI 与 “IP 直连(DNS Mapping)” 有什么关系?

这是你现在最关心的一点:

你做 DNS 优化时会把域名替换成 IP:

https://api.example.com/user
→
https://203.107.1.1/user   (IP直连)

如果你 不加 Host header

服务器会认为:

你要访问 203.107.1.1
那我发给你“203.107.1.1”对应的证书(基本不存在)
→ TLS 握手立即失败

所以你必须:

req.setValue("api.example.com", forHTTPHeaderField: "Host")

⚠️ 更重要的是:
“Host” header 会被 URLSession 自动用作 SNI 的域名

所以 TLS 握手会变成:

SNI = "api.example.com"

→ 即使你是连的 IP,TLS 仍然能拿到对应的域名证书
→ 握手成功

这就是 IP 直连 + HTTPS 能工作的原因。


🎯 用一句更工程化的话总结:

SNI 就是 TLS 握手阶段的 Host header。
客户端会在 ClientHello 里把这个域名告诉服务器,让服务器知道发哪一套证书。

基于 easy_rxdart 的轻量响应式与状态管理架构实践

面向 Flutter/Dart 的响应式与状态管理,easy_rxdart 提供统一的 Stream/Reactive 操作与 Model + InheritedWidget 组合,覆盖防抖/节流/去重、错误恢复、第三方库响应式封装。核心设计旨在减少样板代码、提升组合能力与可读性,让业务逻辑围绕流与状态自然生长,适配中小型到复杂场景的架构演进。

方案选型

  • 响应式核心:以 Stream 为主干,扩展操作符满足事件流需求;用 Reactive<T> 统一链式与组合语义。
  • 状态管理:Model 基于 Listenable,通过 EasyModel<T> 提供上下文,watch/read/listen 精准区分重建与副作用。
  • 第三方整合:通过扩展与工具方法,对 dio、权限、图片选择、存储等提供一致的响应式调用。
  • 取舍与对比:相较 BLoC,减少事件/状态样板,强调“以流为中心”的组合与直观 Model 触发;相较 Riverpod,更贴近 Flutter 机制(InheritedWidget + AnimatedBuilder),简单可控;需要跨层依赖时,用 EasyModelManager 做全局管理。

架构设计

  • 目录分层:
    • 核心:Reactive<T>、流操作扩展、ModelEasyModel<T>EasyStreamControllerSubject 包装。
    • 扩展:面向 Stream/Reactive/Widget/第三方库 的便捷操作。
    • 工具:debounce/throttle/distinct、时间/格式化、定时器组、网络/连接状态。
    • Mixin:应用与路由生命周期、订阅管理。
  • 模块职责:
    • Reactive<T>:包装 Stream<T> 提供 map/flatMap/where/combineLatest/zip/concat/listen,兼容 rxdart。
    • Model + EasyModel<T>:版本与微任务去重策略的最小重建;watch/read/listen 三分法。
    • Stream 扩展:debounceTime/throttleTime/distinctUntilChanged/retryWithDelay/withLatestFrom/buffer/window/sample/audit 等。
    • 管理与集成:EasyModelManager.lazyPut/get/put/delete/reset 全局依赖;第三方能力响应式化。

整体流程图

截屏2025-11-22 12.45.56.png

核心数据流

  • 事件输入:来自控件、网络、定时器、第三方库等。
  • 操作符链:集中完成过滤、限流、错误恢复与组合。
  • 状态触发:Model.notifyListeners() 驱动 UI 最小重建;toReactive 将状态投射为流用于组合。
  • 副作用订阅:无需重建时,用 listen 执行副作用。
flowchart LR
  UI[TextField / Gesture] --> S[Stream<String> / Stream<void>] --> O[debounceTime / throttleTime / distinctUntilChanged] --> M[map / flatMap / combine / zip] --> ST[Model 状态 或 Reactive 输出] --> R[rebuild 或 side-effect]

最小可用示例

定义模型

class CounterModel extends Model {
  int _count = 0;
  int get count => _count;
  void increment() {
    _count++;
    notifyListeners();
  }
}

提供与消费

EasyModel<CounterModel>(
  model: CounterModel(),
  child: Builder(
    builder: (context) {
      final model = EasyModel.watch<CounterModel>(context)!;
      return Column(
        children: [
          Text('${model.count}')
          ,
          ElevatedButton(
            onPressed: () => EasyModel.read<CounterModel>(context)?.increment(),
            child: const Text('Add'),
          ),
        ],
      );
    },
  ),
);

文本输入搜索流

final input = StreamController<String>.broadcast();

final searchStream = input.stream
  .debounceTime(const Duration(milliseconds: 300))
  .distinctUntilChanged()
  .flatMapValue((q) => fetchResult(q))
  .retryWithDelay(count: 3, delay: const Duration(milliseconds: 500));

searchStream.listen((items) {
});

状态到流的桥接

将模型状态投射为 Reactive<T>,用于组合或跨组件订阅。

final counterReactive = model.toReactive(() => model.count);
counterReactive.map((v) => 'Count: $v').listen((text) {
});

第三方集成示例(网络请求)

合理结合错误恢复与重试。

Stream<List<User>> getUsers() =>
  Stream.fromFuture(dio.get('/users'))
    .map((resp) => parseUsers(resp.data))
    .retryWithDelay(count: 2, delay: const Duration(seconds: 1))
    .onErrorReturnItem(<User>[]);

关键设计细节

  • 重建控制:Model 使用版本与微任务去重策略,避免短时间内重复触发。watch 触发构建,read 不触发构建,listen 用于副作用。
  • 订阅生命周期:控制器/Subject 包装统一“谁创建谁销毁”;Mixin 自动清理路由/应用生命周期绑定。
  • 错误治理:timeoutTime/retryWithDelay/onErrorReturn/onErrorResumeNext/defaultIfEmpty/materialize/dematerialize
  • 组合能力:merge/concat/combineLatest/zip/withLatestFrom;窗口与缓冲:windowCount/windowTime/bufferCount/bufferTime

典型场景落地

  • 输入框防抖搜索:debounceTime + distinctUntilChanged + flatMapValue + retryWithDelay
  • 滑动或点击行为治理:对交互加 debounce/throttle/distinct
  • 从状态驱动 UI:Model 维护最小状态集,EasyModel<T> 向下传递,构建边界清晰。
  • 复杂流编排:并发/序列/压缩三类组合,对应 merge/concat/zip

流程图:网络请求装配线

flowchart TD
  REQ[请求触发] --> F[Future -> Stream] --> RETRY[retryWithDelay] --> MAP[map / 解析] --> FALLBACK[onErrorReturnItem 或 defaultIfEmpty] --> OUT[输出到 Model / Reactive] --> UI[UI 重建 或 副作用]

性能与工程实践

  • 边界清晰:将“重建”与“副作用”拆分,避免过度重建。
  • 优先扩展操作符:用扩展而非手工逻辑,减少不可预期状态。
  • 错误兜底:所有外部 IO 流建议配置兜底值与重试策略。
  • 资源回收:统一关闭控制器与订阅;跨页面订阅用 Mixin 自动清理。
  • 可测试性:流管线易单测,模型可通过版本与哈希策略验证通知行为。

与主流方案的协作

  • 与 Riverpod 协作:外层管理依赖,内层用 Model + Reactive 做流编排与最小重建。
  • 与 BLoC 协作:保留既有事件/状态结构时,将副作用和组合逻辑沉到 Stream 扩展与 Reactive

适用边界

  • 最佳适配:事件主导交互、网络数据装配、轻到中型状态管理、端上能力整合。
  • 不适配:跨团队大型复杂域模型、严格 CQRS/DDD 的大规模事件场景,建议与更重型框架配合。

总结与落地建议

  • easy_rxdart 将响应式与状态管理统一到可组合的流与轻量模型之上,降低样板与心智负担。
  • 建议从“输入防抖 + 网络装配 + 模型驱动”起步,逐步引入窗口/缓冲与生命周期治理,避免一开始过度工程化。

实践清单

  • 输入框搜索:debounceTime + distinctUntilChanged + flatMapValue + retryWithDelay
  • 列表滚动埋点:throttleTime + mapNotNull + bufferTime
  • 登录态与页面联动:Model.toReactive + combineLatest2 + defaultIfEmpty
  • 网络兜底:timeoutTime + onErrorReturnItem + retryWithDelay

苹果悄悄上线网页版 App Store!官方出品调研竞品更方便~

苹果悄然推出了网页版 App Store(官网地址:apps.apple.com/cn),无需依赖 iOS 或 macOS 设备,只要打开浏览器,无论是安卓手机、Windows 电脑还是其他终端,都能轻松访问 App Store 的丰富内容。不过目前网页版仅支持浏览、搜索应用,屏蔽了下载功能改为了分享。

企业微信20251121-141812.png

核心亮点:多地区快速切换 + 全设备专区适配

网页版 App Store 最让人惊喜的,莫过于无门槛切换全球地区商店。用户只需修改网址中的两位地区代码(遵循《ISO 31666-1》标准,小写格式),就能一键跳转至对应国家 / 地区的 App Store,比如:

无需注册登录,也不用切换账号地区,就能直接查看目标地区的应用榜单、同类型产品分布,以及特定应用的价格、评分、用户评论等核心信息,操作简单到离谱。

同时,网页版几乎 1:1 复刻了移动端 App Store 的视觉设计和功能布局,还新增了设备专区切换功能—— 左侧菜单栏可直接选择 Mac、iPad、Vision、Watch 等设备,无需拥有对应硬件,就能直观查看应用在不同设备上的展示效果,比如 iPad 端的 5 图排版、Watch 端的适配界面等。

2.png

核心实用场景,精准匹配开发者与产品人核心需求

1. 出海竞品调研提速,摆脱第三方工具束缚

过去做海外市场调研,查看不同地区 App Store 的榜单动态、竞品详情,只能依赖点点数据这类第三方平台。不仅要完成登录流程,还面临加载迟缓、数据滞后的问题,部分关键数据甚至需要开通 VIP 才能解锁。网页版 App Store 直接打通全球地区商店通道,无需借助任何额外工具,就能实时获取目标地区的竞品核心信息,从榜单趋势到应用评分、评论、价格等详情全掌握,让出海调研效率大幅提升。

2. 多设备适配核查零门槛,独立开发者福音

独立开发者或小型团队往往难以配齐 Mac、iPad、Vision、Apple Watch 等所有苹果设备,给多设备适配调研带来阻碍。网页版的设备专区切换功能恰好解决了这一难题,左上角下拉菜单即可一键切换至对应设备的专属应用页面。想确认自家应用在 Mac 端的展示效果,或是调研 Watch 端的热门应用类型,只需打开浏览器就能直观查看,零成本完成多设备适配验证。

3. 跨平台无障碍访问,安卓 / Windows 用户不用再借设备

产品经理、运营人员常需调研 App Store 上的应用,但如果手边只有安卓手机或 Windows 电脑,此前只能向同事借用苹果设备才能完成。网页版 App Store 打破了设备系统限制,任何浏览器都能直接访问,跨平台即可轻松浏览应用详情,再也不用为查询一个应用地址而四处借设备。

4. 应用链接分享更高效,告别繁琐查找流程

运营或市场人员需要产品的 App Store 链接时,过去要么翻找存档文档,要么通过第三方平台搜索跳转后复制 URL。现在网页版提供了集中式的应用聚合入口,直接打开网页搜索应用名称,就能快速复制浏览器 URL 一键分享,甚至可让同事自行搜索获取,彻底省去反复沟通查找的麻烦,缩短信息传递路径。

5. 大屏交互体验升级,操作效率再提升

电脑端的大屏优势在网页版 App Store 中得到充分发挥,配合键盘输入搜索,操作比手机端更高效。无论是批量筛选竞品、同时对比多个应用详情,还是沉浸式浏览应用截图与用户评论,体验都更为流畅直观。这种差异就像网页版视频对比手机端,在信息获取和操作便捷性上都有明显提升,让应用调研和探索更省心。

结语

苹果这次低调上线的网页版 App Store,没有大肆宣传,却精准戳中了开发者、产品人、运营等群体的核心需求。它打破了设备和地区的限制,让 App Store 的内容触达更便捷,无论是竞品调研、跨设备适配查看,还是日常应用浏览、分享,都变得更高效、更省心。

对于开发者和产品人来说,这无疑是一份惊喜福利,也让我们看到了苹果在生态开放上的微小但重要的进步。如果你常需要和 App Store 打交道,不妨赶紧收藏网址,体验这份 “无门槛逛店” 的快乐~

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

相关推荐

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

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

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

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

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

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

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

[WWDC]Why is my app getting killed 学习笔记

WWDC2020-[Video]Why is my app getting killed?

该session重点讲解了iOS App在后台可能被系统终止的原因

同时也介绍了自iOS 14开始,MetricKit推出了新的能力和新的数据用以诊断和通缉App在前后台被系统终止的情况,即MXForegroundExitData和MXBackgroundExitData

iOS App在后台可能被系统终止的原因有:

  1. Crash

  2. Watchdog

  3. CPU resource limit

  4. Memory footprint exceeded

  5. Memory pressure exit(jetsam)

  6. Background task timeout

无论App在前台还是后台被系统终止,MetricKit都提供了诊断和统计数据,

  • 开发者一方面可以在程序中通过订阅MXForegroundExitData和MXBackgroundExitData来查看
  • 同时在Xcode Organizer中也可以查看,详细请参考下文

Crash

  • 在Xcode Organizer中可以查看崩溃信息,同时在代码中也可以通过MXCrashDiagnostic获取崩溃信息

Watchdog

  • 在App中一些关键的状态变化时(如App启动、前后台切换),系统Watchdog会设置超时限制(20s),如果超时时间内一直没有完成(也就是App卡住),App就会被终止
  • 这种问题预示着可能有死锁(如主线程中gcd sync行为)、无限循环代码逻辑(如无限递归调用)
  • 模拟器场景,或者连接debugger调试时不会触发Watchdog的终止行为
  • 在代码中可以通过MXCrashDiagnostic查看是否存在Watchdog终止App的情况

CPU resource limit

  • 当在在后台App持续消耗过高CPU资源时,系统会记录CPU异常信息
  • Xcode Organizer中可以查看,对应着Energy
  • 代码中可以通过MXCPUExceptionDiagnositic获取信息
  • 同时异常此时也会记录到MXBackgroundExitData中

Memory footprint exceeded

  • 如果要适配iPhone 6s以前的设备,要保证App的内存占用不要超过200MB

  • 当App进入后台,为尽可能降低系统因其他应用内存占用而把我们App杀死的可能性,最好让我们App内存占用降低到50MB以下

  • App在一些关键的过渡过程中(如启动、前后台切换),如果耗时过长(超过大概20s)Watchdog会终止App

    • 注意,当App连接debugger时,是不会被Watchdog终止的

Memory pressure exit(jetsam)

  • 当应用在后台时,其他应用占用了太大内存,系统为了给其他在前台的App足够的内存空间,会把在后台的应用杀死,也叫做被系统(丢弃)jetsam了

  • jetsam事件并不意味着App有bug,这是系统的行为,但却预示着在前台的App占用过多的内存

  • 如果我们的App被系统jetsam了该怎么办

    • 在App进入后台时保存好状态信息,如View Controller Stack、Draft input、Media playback position
    • 使用UIKit State Restoration相关的API,App即使被jetsam了,也会很快恢复到原来的样子
    • App在后台时尽量保持内存占用在50MB以下,被终止的概率会下降,但系统并不保证一定不会终止

Background task timeout

  • 对于短暂的且重要的后台任务(通过UIApplication.beginBackgroundTask执行的),如果没有执行endBackgroundTask或者任务时间太长,都会导致超时,超时时间大概30s,时间到达后,任务还未结束(endBackgroundTask),App就会被系统杀死。如果超时时间内结束,则可以正常的进入suspended状态
  • 把每个任务看做只有30s的炸弹的导火线,一旦App到了后台,导火线就被点燃了
  • 如果希望后台任务有更长的时间处理则要用Background Tasks框架
  • 关于iOS App进入后台后会发生什么可以参考--iOS App进入后台时会发生什么根据官方文档(Extending your app’s background exec - 掘金

参考

Swift 多线程读变量安全吗?

前文,我们讲了在 Rust 中多线程读 RefCell 变量不安全的例子(见 Rust RefCell 多线程读为什么也 panic 了?),同样的例子,如果在 Swift 中,多线程读变量安全吗?

先看测试用例:

class Object {
    let value: String
    init(value: String) {
        self.value = value
    }

    deinit {
        print("Object deinit")
    }
}

class Demo {
    var object: Object? = Object(value: String("Hello World"))

    func foo() {
        var tmp = object
        object = nil
        (0..<10000).forEach { index in
            DispatchQueue.global().async {
                do {
                    let v = tmp
                }
                usleep(100)
                print(index, tmp)
            }
        }
    }
}

let demo = Demo()
demo.foo()

多次运行后,没有崩溃

当我们读一个变量时,编译器会自动帮我们插入引用计数的逻辑,类似如下,当对象引用计数为 0 时会释放。

do {
    swift_retain(tmp)
    let v = tmp
    swift_release(tmp)
}

按 Rust 中读 RefCell 变量的思路分析看,Swift 在读变量时也会涉及 retain、release 来写引用计数,为什么 Swift 中不会崩溃呢?

我们来扒一下 Swift 的源码:github.com/swiftlang/s…

1) swift_retain

引用计数 +1,主要代码如下:

在这里插入图片描述

refCounts 表示引用计数,定义如下,可以看出 refCounts 是一个原子变量,这也是保证线程安全的关键。

class RefCounts {
  std::atomic<RefCountBits> refCounts;
  ...
}

masked->refCounts.increment(object, 1)对应函数如下: 在这里插入图片描述

有两处关键代码:

第一个红框表示读取当前引用计数,这是一个原子的读取。

第二个红框,表示 CAS(Compare-And-Swap)更新引用计数,这也是一个原子操作,逻辑如下:

  • **比较 (Compare)**:看内存中 refCounts 的当前值,是否还等于刚才读到的 oldbits
  • 如果相等,则交换:相等说明在计算期间,没有其他线程修改过它,则直接将内存中的值更新为 newbits,并返回 true,循环结束
  • 如果不相等,则重置:不相等说明在计算期间,有其他线程抢先修改了内存,此时会将 oldbits 更新为内存中那个最新的、被其他线程改过的值,并返回 false,继续循环,用新的 oldbits 再算一次

可以看出 swift_retain 中对引用计数的读写操作都是原子的。

2) swift_release

引用计数 -1,主要代码如下:

在这里插入图片描述

执行 -1 的代码如下:

在这里插入图片描述

和 swift_retain 很类似,包含两个步骤:

第一个红框是原子的读引用计数。

第二个红框是 CAS 原子的写引用计数。

另外,这里还有另一个点需要注意,swift_release CAS 写引用计数时,传的参数是std::memory_order_release

std::memory_order_release 的作用是避免指令重排,表示在该指令执行完成之前,在代码里写在该指令前面的所有内存操作,必须全部同步到内存中,绝对不允许重排到该指令之后执行。

举个例子:

假设线程 A 在使用对象,然后释放它:

// 线程 A
myObject.someData = 100 // 1. 写数据
// ... 使用完毕 ...
release(myObject)       // 2. 减少引用计数 (可能降为0)

如果没有 std::memory_order_release,CPU 或编译器可能会进行指令重排,把 1 和 2 的顺序颠倒,也就是说,可能先减少了引用计数,再写入数据。

如果发生这种情况,可能导致对一个已释放的对象进行写操作,导致崩溃(Use-After-Free)。

可以对比看下 swift_retain 时传入的参数是**std::memory_order_relaxed**,这是一种性能开销最小、限制最少的内存排序选择,它只保证这个操作本身是原子的,但不保证和其他代码的执行顺序。这是因为 retain 时不会导致对象释放,即使在引用计数写入后执行代码,也不会有影响。

更多内容,欢迎订阅公众号「非专业程序员Ping」!

Swift6 @retroactive:Swift 的重复协议遵循陷阱

欢迎大家给我点个 star!Github: RickeyBoy

背景:一个看似简单的 bug

App 内有一个电话号码输入界面,在使用时用户需要从中选择注册电话对应的国家,以获取正确的电话区号前缀(比如中国是 +86,英国是 +44 等)。

Step 1:入口 Step 2:缺少区号 期望结果
image1.png image2.png image3.png

这是一个看似很简单的 bug,无非就是写 UI 的时候漏掉了区号,那么把对应字段拼上去就行了嘛。不过一番调查之后发现事情没有那么简单。

列表是一个公用组件,我们需要在列表中显示国家及其电话区号,格式像这样:"🇬🇧 United Kingdom (+44)"。所以之前在 User 模块中添加了这个extension:

    extension Country: @retroactive DropdownSelectable {
        public var id: String {
            code
        }
    
        public var displayValue: String {
            emoji + "\t(englishName) ((phoneCode))"
        }
    }

原理一看就明白,displayValue 代表的是展示的内容。但是最终结果展示错误了:明明将电话区号 ((phoneCode)) 拼在了上面,为什么只显示了国家名称:"🇬🇧 United Kingdom"?

代码可以编译。测试通过。没有警告。但功能在生产环境中却是坏的。

顺便说一下,什么是 DropdownSelectable?

DropdownSelectable 是我们 DesignSystem 模块中的一个协议,它使任何类型都能与我们的下拉 UI 组件配合使用:

    protocol DropdownSelectable {
        var id: String { get }           // 唯一标识符
        var displayValue: String { get } // 列表中显示的内容
    }

Part 1: extension 不起作用了

发现问题

经过调试后,我们发现了根本原因:Addresses 模块已经有一个类似的 extension

    // 在 Addresses 模块中
    extension Country: @retroactive DropdownSelectable {
        public var displayValue: String {
            emoji + "\t(englishName)"  // 没有电话区号
        }
    }
Step 1 Step 2
image4.png image5.png

Addresses 模块不需要电话区号,只需要国家名称。这对地址列表来说是合理的。

但关键是:Addresses extension 在运行时覆盖了我们 User extension。我们以为在使用 User 模块的extension(带电话区号),但 Swift 随机选择了 Addresses 的 extension(不带电话区号)。

这就是关键问题。

冲突:同时存在两个拓展协议

代码中发现的两处冲突的拓展协议:

在 User 模块中(我们以为在使用的):

    extension Country: @retroactive DropdownSelectable {
        public var id: String {
            code
        }
        public var displayValue: String {
            emoji + "\t(englishName) ((phoneCode))"  // ✅ 带电话区号
        }
    }

在 Addresses 模块中(实际被使用的):

    extension Country: @retroactive DropdownSelectable {
        public var id: String {
            code
        }
        public var displayValue: String {
            emoji + "\t(englishName)"  // ❌ 不带电话区号
        }
    }

两个模块都有各自合理的实现理由:

  • User 模块:电话号码输入界面需要电话区号
  • Addresses 模块:地址表单不需要电话区号,只需要国家名称

每个开发者都在实现需求时添加了他们需要的内容。代码编译没有警告,新需求测试通过,没人预料到会对旧的需求产生影响。

同时,确实 Swift 也是允许在不同模块中使用相同的 extension。那么到底发生了什么,我们又是如何解决的呢?

Part 2: 为什么会发生这种情况 - Swift 模块系统解析

要理解为什么这是一个问题,我们需要理解 Swift 的模块系统是如何工作的。有趣的是:通常情况下,在不同模块中有相同的 extension 是完全没问题的。但协议遵循是一个特殊情况。

正常情况:extension 在模块间通常工作良好

假设你为一个类型添加了一个辅助方法:

    // 在 UserModule 中
    extension Country {
        var displayValue: String {
            return emoji + "\t(englishName) ((phoneCode))"
        }
    }
    // 在 AddressesModule 中
    extension Country {
        var displayValue: String {
            return emoji + "\t(englishName)"
        }
    }

这完全可以!每个模块看到的是它自己的extension:

  • UserModule 中的代码调用 displayValue 会得到带 phoneCode 的结果
  • AddressesModule 中的代码调用 displayValue 会得到不带 phoneCode 的结果

为什么可以: 常规 extension 方法在编译时根据导入的模块来解析。Swift 根据当前模块的导入准确知道要调用哪个方法。

特殊情况:协议遵循是全局的

但协议遵循的工作方式不同。当你写:

    extension Country: DropdownSelectable {
        var displayValue: String { ... }
    }

你不只是在添加一个方法。你在做一个全局声明:"对于整个应用程序,Country 遵循 DropdownSelectable。"

所以当你创建两个相同的遵循时,会导致重复遵循错误

    // 在 UserModule 中
    extension Country: DropdownSelectable {
        var displayValue: String {
            return emoji + "\t(englishName) ((phoneCode))"
        }
    }
    // 在 AddressesModule 中
    extension Country: DropdownSelectable {
        var displayValue: String {
            return emoji + "\t(englishName)"
        }
    }

当你构建链接两个模块的应用时,Swift 编译器或链接器会报错,类似这样:

'Country' declares conformance to protocol 'DropdownSelectable' multiple times

Part 3: 引入 @retroactive 破坏了编译器检查

剩余问题:这怎么能编译通过?

基本上,如果我们遇到重复遵循错误,编译器会阻止我们。但是为什么这段代码可以正常存在?

一切问题都可以被归咎于 @retroactive

什么是 @retroactive?

在 Swift 6 中,Apple 引入了 @retroactive 关键字来让跨模块遵循变得明确:

    extension Country: @retroactive DropdownSelectable {
        // 让一个外部类型
        // 遵循一个外部协议
    }

你需要使用 @retroactive 当:

  • 类型定义在不同的模块中(例如,来自模块 A 的 Country
  • 协议定义在不同的模块中(例如,来自模块 B 的 DropdownSelectable
  • 你在第三个模块中添加遵循(例如,在 UserModuleAddressesModule 中)

为什么 @retroactive 会破坏编译器检查重复编译问题?

没有 @retroactive 的情况下,重复遵循已经是编译时错误。但有了 @retroactive,问题变得更加棘手 —— 因为现在你明确声明了影响整个应用运行时的东西,而不仅仅是你的模块。

当你写 @retroactive 时,你在说:

"我要为一个我不拥有的现有类型添加遵循,作用于整个 App。"

这意味着编译器允许你 追溯地/逆向地(retroactively) 为在其他地方定义的类型添加遵循。这很强大,但也改变了 Swift 检查重复的方式。

关键点:

Swift 在每个模块内强制执行重复遵循规则,但不跨模块。换句话说,编译器只检查它当前正在构建的代码。

  • 每个生产者模块(UserModule、AddressesModule)单独编译时是正常的(它只"看到"自己的遵循)。到目前为止是正常的。
  • 导入两者的消费者(至少你有一个,就是你的 app target!),会构建失败,因为它看到了两个相同的协议遵循

添加 @retroactive 之后:

使用 @retroactive,Swift 将一些检查推迟到链接时,所以两个模块都能成功编译,即使它们都在声明相同的全局遵循。

重复只有在链接之后才会变得可见,当两个模块都被加载到同一个运行时镜像中时 —— 而那时,编译器已经太晚无法阻止它了。

这就是为什么这些重复可以"逃过"编译器的安全检查,导致令人困惑的运行时级别的 bug。

运行时发生了什么

当链接器发现 (Country, DropdownSelectable) 有两个实现时:

  • 选项 A:UserModule 的实现(带电话区号)
  • 选项 B:AddressesModule 的实现(不带电话区号)

它只能注册一个。所以它根据链接顺序选择一个 —— 基本上是链接器首先处理的那个模块。另一个遵循会被静默忽略。

这解释了为什么 UserModule 的实现被忽略了。

Part 4: 解决方案 - 包装结构体来拯救

幸运的是我们有一个非常简单的修复方法:使用包装类型

解决方案模式

不要让 Country 本身遵循协议,而是包装它:

    // UserModule 示例
    struct CountryWithPhoneDropdown: DropdownSelectable {
        let country: Country
        var id: String { country.code }
        var displayValue: String {
            country.emoji + "\t(country.englishName) ((country.phoneCode))"
        }
    }
    // AddressModule 示例
    struct CountryAddressDropdown: DropdownSelectable {
        let country: Country

        var id: String { country.code }
        var displayValue: String {
            country.emoji + "\t(country.englishName)"
        }
    }
    // 使用方式
    countries.map { CountryWithPhoneDropdown(country: $0) }
    countries.map { CountryAddressDropdown(country: $0) }

Part 5: 预防 — 如何防止它再次发生

当然,如果想要不仅是修复这个问题,而是预防这个问题,那么可以通过在工作流程中添加静态分析CI 检查来轻松避免重复的 @retroactive 遵循。

这确保任何重复的 @retroactive 遵循在到达生产环境之前被发现,避免类似的运行时错误。

结语

这个 bug 根本不是简单的 UI 问题,想要彻底解决就需要深度理解 Swift 的运行机制。协议拓展可以跨模块重复,但协议遵循是全局的,@retroactive 叠加 Swift 的这种能力造成了这次的 bug。

一旦我们理解了这一点,修复就很简单了。

让弹幕飞一会儿!一个轻量级iOS弹幕库的实现与使用

🚀 让弹幕飞一会儿!一个轻量级iOS弹幕库的实现与使用

本文完整源码地址:github.com/chengshixin…

🎯 前言

"前方高能!"、"233333"、"awsl"... 这些熟悉的弹幕是不是让你想起了在B站追番的快乐时光?弹幕已经成为现代视频应用的标配功能,它不仅能够增强用户互动,还能创造独特的社区氛围。

今天,我要为大家介绍一个我自己开发的轻量级iOS弹幕库——BarrageView!这个库不仅功能丰富,而且代码优雅,绝对是你在iOS应用中集成弹幕功能的不二之选!

✨ 功能特色

🎮 四大弹幕方向

  • 从右到左:经典模式,B站同款
  • 从左到右:反向思维,别具一格
  • 从上到下:竖屏专属,瀑布流效果
  • 从下到上:逆流而上,视觉冲击

🔄 两种播放模式

  • 单次播放:适合展示重要信息
  • 循环播放:营造持续的热闹氛围

🎨 全面自定义

  • 字体大小、颜色随心配
  • 背景透明度自由调节
  • 移动速度精准控制
  • 弹幕间距智能避让

🛠 快速上手

基本使用

// 创建弹幕视图
let barrageView = BarrageView(frame: CGRect(x: 0, y: 100, width: view.bounds.width, height: 200))

// 设置弹幕数据
barrageView.setBarrageData([
    "前方高能预警!",
    "这个功能太棒了!",
    "iOS开发者福音",
    "已star,感谢作者"
])

// 开始播放
barrageView.startBarrage()

高级配置

// 自定义样式和效果
barrageView.setDirection(.rightToLeft)
barrageView.setPlayMode(.loop)
barrageView.setSpeed(80.0)
barrageView.setFontSize(18.0)
barrageView.setTextColor(.red)
barrageView.setTextBackgroundColor(UIColor.black.withAlphaComponent(0.8))

🔧 核心实现原理

🎪 弹幕调度机制

BarrageView采用定时器+动画的双重调度机制:

// 定时器负责生成新弹幕
timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
    self?.createBarrageLabel()
}

// UIView动画负责移动效果
UIView.animate(withDuration: duration, delay: 0, options: .curveLinear) {
    label.frame.origin = endPoint
}

🚦 智能避让算法

为了防止弹幕重叠,我们实现了智能的位置检测:

private func isYPositionOccupied(y: CGFloat, labelHeight: CGFloat) -> Bool {
    for label in activeLabels {
        let labelMinY = label.frame.minY - 10  // 预留安全间距
        let labelMaxY = label.frame.maxY + 10
        
        if y > labelMinY && y < labelMaxY {
            return true  // 位置被占用
        }
    }
    return false  // 位置可用
}

⏸️ 流畅的暂停恢复

通过CALayer扩展实现精准的动画控制:

extension CALayer {
    func pauseAnimation() {
        let pausedTime = convertTime(CACurrentMediaTime(), from: nil)
        speed = 0.0
        timeOffset = pausedTime
    }
    
    func resumeAnimation() {
        let pausedTime = timeOffset
        speed = 1.0
        timeOffset = 0.0
        beginTime = 0.0
        let timeSincePause = convertTime(CACurrentMediaTime(), from: nil) - pausedTime
        beginTime = timeSincePause
    }
}

📱 实际应用场景

🎥 视频播放器

// 在视频播放时启动弹幕
func videoDidStartPlaying() {
    barrageView.startBarrage()
}

func videoDidPause() {
    barrageView.pauseBarrage()
}

func videoDidResume() {
    barrageView.resumeBarrage()
}

🎮 直播互动

// 收到新消息时实时添加弹幕
func didReceiveNewMessage(_ message: String) {
    var currentData = barrageView.barrageTexts
    currentData.append(message)
    barrageView.setBarrageData(currentData)
}

🎉 活动庆典

// 节日特效弹幕
func setupFestivalBarrage() {
    barrageView.setTextColor(.red)
    barrageView.setFontSize(20)
    barrageView.setBarrageData(festivalWishes)
}

🚀 性能优化技巧

内存管理

  • 使用weak self避免循环引用
  • 及时移除完成动画的标签
  • 合理控制同时显示的弹幕数量

动画优化

  • 使用curveLinear保证匀速运动
  • 避免频繁创建销毁对象
  • 复用UILabel减少内存分配

🔮 未来规划

  • 支持富文本弹幕
  • 添加弹幕点击事件
  • 实现3D弹幕效果
  • 支持弹幕轨道管理
  • 添加弹幕过滤机制

💫 结语

BarrageView不仅仅是一个工具库,更是我对iOS动画和用户体验的一次深度探索。通过这个项目,我学习到了:

  • 🎯 精准的动画控制:如何让弹幕平滑移动又不失性能
  • 🧠 智能的布局算法:如何避免弹幕间的"交通事故"
  • 🎨 优雅的代码设计:如何构建可扩展、易维护的架构

如果你对这个项目感兴趣,欢迎:

Stargithub.com/chengshixin…

🐛 提交Issue → 反馈bug或提出新功能建议

🔀 Pull Request → 一起让这个项目变得更好


让我们的应用也拥有B站一样的弹幕文化吧! 🎊

"弹幕虽小,却能承载万千情感;代码虽简,却能创造无限可能。"

本文由BarrageView作者撰写,转载请注明出处。

Apple更新App审核条款,严打擅自与第三方 AI 共享个人数据的应用

App审核条款变更

最近iOS开发者该都收到了Apple发来了更新审核条款的邮件,原文内容如下:

  • 1.2.1(a): This new guideline specifies that creator apps must provide a way for users to identify content that exceeds the app’s age rating, and use an age restriction mechanism based on verified or declared age to limit access by underage users.
  • 2.5.10: This language has been deleted ("Apps should not be submitted with empty ad banners or test advertisements.”).
  • 3.2.2(ix): Clarified that loan apps may not charge a maximum APR higher than 36%, including costs and fees, and may not require repayment in full in 60 days or less.
  • 4.1(c): This new guideline specifies that you cannot use another developer's icon, brand, or product name in your app's icon or name, without approval from the developer.
  • 4.7: Clarifies that HTML5 and JavaScript mini apps and mini games are in scope of the guideline.
  • 4.7.2: Clarifies that apps offering software not embedded in the binary may not extend or expose native platform APIs or technologies to the software without prior permission from Apple.
  • 4.7.5: Clarifies that apps offering software not embedded in the binary must provide a way for users to identify content that exceeds the app’s age rating, and use an age restriction mechanism based on verified or declared age to limit access by underage users.
  • 5.1.1(ix): Adds crypto exchanges to the list of apps that provide services in highly regulated fields.
  • 5.1.2(i): Clarifies that you must clearly disclose where personal data will be shared with third parties, including with third-party AI, and obtain explicit permission before doing so.

翻译过来就是:

  • 1.2.1(a): 这项新的指南明确规定,创作者应用(creator apps)必须提供一种方式,让用户能够识别超出应用年龄分级的内容,并使用基于验证或声明年龄的年龄限制机制来限制未成年用户访问这些内容。

  • 2.5.10: 此措辞已被删除(原措辞为:“应用不应提交带有空白广告横幅或测试广告。”)。

  • 3.2.2(ix): 澄清了贷款应用收取的最高年利率 (Maximum APR) 不得高于 36% (包括所有成本和费用),并且不得要求在 60 天或更短时间内全额偿还贷款。

  • 4.1(c): 这项新的指南明确规定,未经该开发者批准,您不得在您的应用图标或名称中使用其他开发者的图标、品牌或产品名称

  • 4.7: 澄清了 HTML5 和 JavaScript 小程序(mini apps)和迷你游戏(mini games) 属于该指南的管辖范围。

  • 4.7.2: 澄清了提供未嵌入二进制文件的软件的应用,未经 Apple 事先许可,不得向该软件扩展或暴露原生平台 API 或技术。

  • 4.7.5: 澄清了提供未嵌入二进制文件的软件的应用,必须提供一种方式,让用户能够识别超出应用年龄分级的内容,并使用基于验证或声明年龄的年龄限制机制来限制未成年用户访问这些内容。

  • 5.1.1(ix):加密货币交易所 (crypto exchanges) 加入到提供高度监管服务的应用列表。

  • 5.1.2(i): 澄清了您必须清楚地披露个人数据将与第三方(包括第三方 AI)共享的位置,并在共享之前获得明确的许可

值得一提的是,Apple新规首次明确要求,各类应用若要把用户个人数据提供给第三方 AI,必须事先公开说明并获得用户授权

苹果公司目前正着手调整其 App Store 政策,此举被视为对即将到来的 AI 时代,尤其是对 2026 年即将发布的全新、更智能的 Siri 的战略性准备。

据传闻,下一代 Siri 将具备更强大的跨应用语音操作能力,其部分核心技术据悉将由 Google 的 Gemini 模型提供支持。

政策调整背后的考量

苹果在此时更新开发者指南,主要目标之一是加强对用户隐私的保护,特别是要防止应用程序在用户不知情或未经同意的情况下,将个人数据传输给 AI 服务提供商或其他相关公司。

这次政策修改的关键意义不在于引入了全新的数据保护概念,而在于苹果首次将 AI 相关的企业和技术明确纳入了既有的监管框架

具体变化和影响

原有的审核规则 5.1.2 (i) 已经要求开发者在分享用户数据前必须透明披露并获得用户许可,并禁止未经允许地“使用、传输或分享”个人信息。这一规定是苹果为遵守如欧盟 GDPR、加州 CCPA 等全球隐私法规的重要举措,旨在确保用户对个人数据拥有控制权。违规应用将面临被下架的风险。

新版本在这一要求的基础上加入了更具针对性的明确措辞:开发者必须清楚说明个人数据会被提供给哪些第三方——包括第三方 AI,并且在数据共享操作发生前,必须获取用户的明确授权

这一变化预计将对那些依赖 AI 技术来收集、处理用户数据以提供个性化服务或特定功能的应用程序产生影响。然而,由于“AI”是一个广阔的范畴,既包含大型语言模型(LLM),也涵盖各种机器学习技术,目前尚不清楚苹果将以何种程度和力度去执行这一新要求。

Homebrew 5.0:并行加速、MCP 加持,与 Intel 的最后倒计时 -- 肘子的 Swift 周报 #0111

issue111.webp

🚀 《肘子的 Swift 周报》

每周为你精选最值得关注的 Swift、SwiftUI 技术动态

Homebrew 5.0:并行加速、MCP 加持,与 Intel 的最后倒计时

几天前,我像往常一样在输入 brew update 后顺手执行了 brew upgrade。出乎意料的是,终端里突然出现了从未见过的画面——大量组件与工具并行下载、整齐排列、同时推进。短暂的惊讶之后,我才从新闻中得知:Homebrew 已经发布了 5.0 版本

此次更新内容相当丰富。除了默认启用并行下载外,还正式将 Raspberry Pi、ARM 迷你 PC、Windows ARM 上的 WSL2 等 ARM64/AArch64 设备纳入 Tier 1 支持,并新增多项指令与能力。其中,官方提供的本地 MCP 服务尤为引人注目。通过 brew mcp-server,开发者可以让 AI Agent 自动操作 Homebrew,意味着 brew 也顺利接入了正在兴起的 AI 工作流。这是一项颇具时代感的更新。

不过,并非所有消息都同样令人愉快。随着 macOS Tahoe 26 大概率成为最后一个支持 Intel x86_64 的版本,Homebrew 也相应调整了自身的支持策略:从 2026 年 9 月起,Intel Mac 将被降级为 Tier 3;到 2027 年 9 月(或更晚),对 Intel 的支持则可能完全终止。

不可否认,在过去十余年里,Intel 架构为苹果带来了庞大的“潜在用户”:它能原生运行 Windows,让许多本不属于 Mac 生态的用户因兼容性而选择苹果设备,为苹果的市场份额提供了关键支撑。如今,随着 Apple Silicon 的成熟,Intel Mac 注定会与 MOS 6502、PowerPC 等一同成为苹果硬件发展史上的重要篇章,在不久的将来缓缓落幕。

回顾历史,每次 CPU 架构转换都激发了苹果在产品设计上的创新灵感,催生出许多具有时代印记的经典产品——从 68k 时代的 Lisa 开创图形界面先河,到 PowerPC 时代的 iMac G3 半透明美学和彩色贝壳本,再到 Intel 时代的小白/小黑 MacBook 以及重新定义轻薄的 MacBook Air。在 M 系列芯片时代,Apple 在性能、能效与系统集成上实现了跨越式提升,但在硬件外观与工业设计语言上,尚未出现能够留下强烈时代烙印的革新之作。

期待尽早看到可以计入史册的新设计,别让我们等太久。

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

近期推荐

Grow on iOS 26:UIKit + SwiftUI 混合架构下的 Liquid Glass 适配实战

Grow 是一款在 173 个国家和地区获得 App Store 编辑推荐、拥有超过 18 万五星评价的健康管理应用。在适配 iOS 26 的 Liquid Glass 设计语言时,团队遇到了不少挑战:如何在 UIKit + SwiftUI 混合架构下实现原生的 morph 效果?如何精确控制 Scroll Edge Effect?如何处理自定义导航栏元素的动态尺寸?Grow 的开发者之一 Shuhari,分享了团队在这次适配过程中的实战经验。文章涵盖 Sheet、Navigation、Popover 等场景的改造方案,深入探讨 UIBarButtonItem 尺寸计算、CABackdropLayer 副作用处理等底层细节,还展示了如何利用 Core Text 创造“玻璃文字”效果。所有核心概念都配有完整的 Demo 工程


警惕参数化测试中的陷阱 (Pitfalls of Parameterized Tests)

参数化测试 (Parameterized Tests)是 Swift Testing 中颇具代表性的新特性,它让开发者能够在最小化重复代码的同时扩大测试覆盖范围,为同一逻辑轻松验证多组输入。然而 Alex Ozun 在大规模迁移实践中发现,这项功能虽然便捷,却也暗藏不少容易忽略的陷阱,甚至可能悄悄降低测试的有效性。文章结合多个示例展示了一些常见陷阱,并提出了如避免在 #expect 两侧重复使用测试参数、明确区分示例测试与属性测试等多项实践建议。


为任务显式指定“身份” (Task Identity)

在 SwiftUI 中,task / onAppear 会在视图“出现”时执行一次,但它们并不会像视图那样自动跟踪依赖——如果任务闭包依赖了某个状态,该状态变化后任务本身不会自动重新触发。Chris Eidhof 以加载远程图片为例,展示了这一容易被忽略的问题,并建议为任务显式指定“身份”(identity),例如使用 .task(id: url),让相关依赖(如 URL 或由多个值组合而成的复合标识)参与任务的重新执行条件,使 SwiftUI 能在依赖更新时取消旧任务并启动新任务。作者提醒,凡是在视图中使用 task / onAppear 时,都应确保相关的依赖已经体现在任务的身份(identity)中。


Objective-C API 引发的 Unicode 错误 (One Swift mistake everyone should stop making today)

Swift 已经诞生十年了,但在日常开发中开发者使用的很多 Swift API 仍只是对 Objective-C API 的简单包装,这可能会引发一些容易忽视的严重问题。Paul Hudson 在本文中就通过 replacingOccurrences(of:with:) 展示了这种情况:在处理由多个 Unicode 标量组成的字符(如国旗表情)时,该方法可能会“误拆”字符、匹配不存在的序列,从而生成完全错误的结果。Paul 的建议非常简单:在 Swift 中应优先使用原生的 replacing(_:with:),它能够正确地按字符语义处理 Unicode,避免这些诡异且难以排查的字符串错误。

随着 Foundation 在 Swift 社区重构完成,在 macOS 等平台上,对于具备类似功能的 API,通常应优先选择新 Foundation 中提供的 Swift 原生版本。这样不仅可以避免上述问题,而且也提前为跨平台做好准备。


和 Christian 一起学习 Swift 并发 (Learning About Swift Concurrency (from Matt Massicotte’s Blog) with a Zettelkasten)

Swift 的并发演进并非一帆风顺,引入 Approachable Concurrency 概念后,不同编译选项组合甚至可能得到完全不同的编译结果,理解成本也随之水涨船高。Christian Tietze 原本只打算做一个简短演示:展示如何使用卡片盒笔记法(Zettelkasten)来消化 Matt Massicotte 关于 Swift 并发的博客文章,结果在实作过程中不断撞见更深层的复杂性——例如:actor 无法直接满足带有 nonisolated 要求的 Sendable 协议,除非显式将成员标记为 nonisolatednonisolated(unsafe)。等他回过神来,视频已经录到了 80 分钟。

视频很好地呈现了“深入学技术”的真实面貌:不是线性的知识堆叠,而是充满困惑、假设以及有待日后用代码与文档验证的开放问题。同时也侧面证明,卡片盒笔记法非常适合应对 Swift 并发这类复杂且持续演进的主题,通过构建可搜索、可链接的笔记网络,承载理解在时间维度上的逐步收敛。


Claude Code Skills 功能介绍以及使用经验

Ein Verne 在本文中介绍了 Claude 新推出的 Skills 机制 —— 一种用于扩展 Claude 能力的模块化体系。相比 MCP、Slash Commands 和传统插件,Skills 更强调可组合性、可移植性以及对上下文窗口的友好使用方式。每个 Skill 都以独立文件夹的形式存在,包含名称、描述、操作指令(SKILL.md)、可执行脚本、参考文档与资源文件等。Claude 会在执行任务时自动扫描并匹配合适的技能,并通过“渐进式披露(Progressive Disclosure)”按需加载细节,从而显著降低上下文消耗。作者认为,Skills 本质上将“提示词工程”演进为“工作流工程”,让 Claude 从通用智能助手进一步迈向可维护的智能基础设施形态。


在 iOS 中集成 Rust:基于 UniFFI 的多平台工作流 (Multiplatform with Rust on iOS)

就像许多 Swift 开发者希望把代码带出苹果生态一样,iOS 本身也对其他开发语言保持着相当开放的态度。Tjeerd in 't Veen 在这篇文章中分享了一份详实的 Rust + iOS 集成指南,展示如何通过 Mozilla 的 UniFFI 将 Rust 代码优雅地接入到 iOS 项目中。UniFFI 能将 Rust 的 enum 自动映射为 Swift enum,并把函数名从 snake_case 转为 camelCase,让 Rust 模块在 Swift 侧看起来就像原生 API。

文章给出了一整套可落地的工作流:从创建 Rust 库、为多种 iOS 架构构建静态库、打包 XCFramework,到最终封装成 Swift Package,每一步都有详细说明与常见陷阱提示。这套方案不仅让 iOS 工程可以像使用普通 Swift 包一样消费 Rust 逻辑,也为后续在 Android 等平台复用同一份 Rust 代码打下了良好基础。

工具

VisualDiffer 2:从 Objective-C 到 Swift 的重生

Davide Ficano 将其经营多年的 macOS 文件对比工具 VisualDiffer 完全开源,并从 Objective-C 彻底重写为 Swift。这不是简单的语言迁移或 AI 辅助转换,而是一次从零开始的手工重构。

核心功能保持不变:

  • 🟩 直观对比 - 并排展示目录差异,用颜色标识新增、修改或缺失的文件
  • 🧩 深入分析 - 支持文件级别的逐行对比(基于 UNIX diff)
  • 🧹 智能过滤 - 自动排除版本控制文件(.git、.svn)和系统文件(.DS_Store)
  • 性能优化 - 支持多种对比策略,从快速的日期/大小对比到精确的逐字节对比

Reddit 上,作者坦言自己依旧非常欣赏 Objective-C,但 Swift 的潜力让他愿意承受迁移的巨大成本。UI 层(特别是 NSTableView 与 delegate 模式)的重写过程尤为艰难,早期充满了并发属性标注,但随着理解加深,Swift 的优势逐渐显现。


FSWatcher:高性能的 Swift 原生文件系统监控库

十里 在开发图片压缩工具 Zipic 时,需要实时感知图片文件变化以便进行及时处理,为此开发了 FSWatcher。这是一个基于 macOS/iOS 底层 kqueue 机制的文件系统监控库,采用事件驱动而非轮询方式,资源消耗极低。

核心特性:

  • 🎯 智能过滤:支持按文件类型、大小、修改时间等多维度过滤,并可链式组合
  • 🔍 预测性忽略:自动识别并跳过自身生成的输出文件(如 *_compressed.jpg),避免循环触发
  • 📁 递归监控:可监控整棵目录树,支持深度限制与排除规则
  • 现代 API:完整支持 Combine、async/await 以及传统闭包回调模式

该库非常适合作为图片处理流程的监听器、开发工具的热重载组件,或构建轻量化自动备份系统等需要实时文件变动感知的场景。


SFSymbolKit:零维护的类型安全 SF Symbols 库

市面上已有不少用于改进 SF Symbols 使用体验的库,但 LiYanan 的 SFSymbolKit 仍然颇具特色:所有符号与可用性信息都由工具直接从系统框架自动生成,一键即可完成更新,真正做到无需人工维护。

核心优势:

  • 数据源可靠:直接读取 /System/Library/PrivateFrameworks/SFSymbols.framework/,与系统 100% 同步
  • 完全自动化:运行 ./update_symbols.sh 即可更新,无需手动添加新符号
  • 版本感知:自动生成 @available 属性,编译时检查符号兼容性
  • 用户自助:任何人都可以在本地更新,不依赖作者发版

往期内容

💝 支持与反馈

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

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

🚀 拓展 Swift 视野

❌