阅读视图

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

《Flutter全栈开发实战指南:从零到高级》- 10 -状态管理setState与InheritedWidget

状态管理:setState与InheritedWidget

深入理解Flutter状态管理的基石,掌握setState与InheritedWidget的核心原理与应用场景

在Flutter应用开发中,状态管理是一个无法回避的核心话题。无论是简单的计数器应用,还是复杂的企业级应用,都需要有效地管理应用状态。下面我们将深入探讨Flutter状态管理的两个基础但极其重要的概念:setStateInheritedWidget

1. 什么是状态管理?

在开始具体的技术细节之前,我们先理解一下什么是状态管理。简单来说,状态就是应用中会发生变化的数据。比如:

  • 用户点击按钮的次数
  • 从网络加载的数据列表
  • 用户的登录信息
  • 应用的主题设置

状态管理就是如何存储、更新和传递这些变化数据的一套方法和架构

为什么需要状态管理?

想象一下,如果没有良好的状态管理,我们的代码会变成什么样子:

// 反面案例
class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  int _counter = 0;
  String _userName = '';
  bool _isDarkMode = false;
  List<String> _items = [];
  
  // 多个状态变量和方法混在一起
  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }
  
  void _loadUserData() {
    // 加载用户数据
  }
  
  void _toggleTheme() {
    setState(() {
      _isDarkMode = !_isDarkMode;
    });
  }
  
  // ... 更多方法
  
  @override
  Widget build(BuildContext context) {
    // 构建UI,传递状态到各个子组件
    return Container(
      child: Column(
        children: [
          CounterDisplay(counter: _counter, onIncrement: _incrementCounter),
          UserProfile(name: _userName),
          ThemeToggle(isDark: _isDarkMode, onToggle: _toggleTheme),
          // ... 更多组件
        ],
      ),
    );
  }
}

这种方式的问题在于:

  1. 代码耦合度高:所有状态逻辑都集中在同一个类中
  2. 难以维护:随着功能增加,代码变得越来越复杂
  3. 状态共享困难:需要在组件树中层层传递状态和回调
  4. 测试困难:业务逻辑和UI渲染紧密耦合

2. setState:最基础的状态管理

2.1 setState的基本用法

setState是Flutter中最基础、最常用的状态管理方式。它是StatefulWidget的核心方法,用于通知框架状态已发生变化,需要重新构建UI。

让我们通过一个经典的计数器示例来理解setState

import 'package:flutter/material.dart';

class CounterApp extends StatefulWidget {
  @override
  _CounterAppState createState() => _CounterAppState();
}

class _CounterAppState extends State<CounterApp> {
  // 定义状态变量
  int _counter = 0;

  // 状态修改方法
  void _incrementCounter() {
    setState(() {
      // 在setState回调中更新状态
      _counter++;
    });
  }
  
  void _decrementCounter() {
    setState(() {
      _counter--;
    });
  }
  
  void _resetCounter() {
    setState(() {
      _counter = 0;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('计数器示例'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              '当前计数:',
              style: Theme.of(context).textTheme.headline4,
            ),
            Text(
              '$_counter', // 显示状态
              style: Theme.of(context).textTheme.headline2,
            ),
            SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: _decrementCounter, // 绑定状态修改方法
                  child: Text('减少'),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: _resetCounter,
                  child: Text('重置'),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: _incrementCounter,
                  child: Text('增加'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

2.2 setState的工作原理

为了更好地理解setState的工作原理,先看一下其内部机制:

// 简化的setState源码理解
@protected
void setState(VoidCallback fn) {
  // 1. 执行回调函数,更新状态
  fn();
  
  // 2. 标记当前Element为dirty(脏状态)
  _element.markNeedsBuild();
  
  // 3. 调度新的构建帧
  SchedulerBinding.instance!.scheduleFrame();
}

setState执行流程

┌─────────────────┐     ┌──────────────────┐     ┌──────────────────┐
│   调用setState  │───▶ │ 执行回调更新状态  │───▶│ 标记Element为dirty│
└─────────────────┘     └──────────────────┘     └──────────────────┘
         │                                              │
         │                                              ▼
         │                                    ┌──────────────────┐
         │                                    │ 调度新的构建帧    │
         │                                    └──────────────────┘
         │                                              │
         ▼                                              ▼
┌─────────────────┐                            ┌──────────────────┐
│  状态已更新      │                            │ 下一帧重建Widget  │
│  但UI未更新      │                            │    更新UI        │
└─────────────────┘                            └──────────────────┘

2.3 setState的适用场景

setState最适合以下场景:

  1. 局部状态管理:只在当前组件内部使用的状态
  2. 简单的UI交互:如按钮点击、表单输入等
  3. 原型开发:快速验证想法和功能
  4. 小型应用:组件数量少、状态简单的应用

2.4 setState的局限性

虽然setState简单易用,但在复杂应用中会暴露出很多问题:

// setState局限性
class ComplexApp extends StatefulWidget {
  @override
  _ComplexAppState createState() => _ComplexAppState();
}

class _ComplexAppState extends State<ComplexApp> {
  // 问题1:状态变量过多,难以管理
  int _counter = 0;
  String _userName = '';
  String _userEmail = '';
  bool _isLoggedIn = false;
  List<String> _products = [];
  bool _isLoading = false;
  String _errorMessage = '';
  
  // 问题2:业务逻辑混杂在UI代码中
  void _loginUser(String email, String password) async {
    setState(() {
      _isLoading = true;
      _errorMessage = '';
    });
    
    try {
      // 模拟接口请求
      final user = await AuthService.login(email, password);
      setState(() {
        _isLoggedIn = true;
        _userName = user.name;
        _userEmail = user.email;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _isLoading = false;
        _errorMessage = '登录失败: $e';
      });
    }
  }
  
  // 问题3:需要在组件树中层层传递回调
  Widget _buildUserProfile() {
    return UserProfile(
      userName: _userName,
      userEmail: _userEmail,
      onUpdate: (String newName, String newEmail) {
        setState(() {
          _userName = newName;
          _userEmail = newEmail;
        });
      },
    );
  }
  
  @override
  Widget build(BuildContext context) {
    // 构建方法变得极为复杂
    return Container(
      // ... 大量UI代码
    );
  }
}

setState的主要局限性

  1. 状态分散:多个无关状态混杂在同一个类中
  2. 逻辑耦合:业务逻辑和UI渲染代码紧密耦合
  3. 传递麻烦:需要手动将状态和回调传递给子组件
  4. 测试困难:很难单独测试业务逻辑
  5. 性能问题:每次setState都会重新build整个子树

3. 状态提升

3.1 什么是状态提升?

状态提升是React和Flutter中常见的设计模式,指的是将状态从子组件移动到其父组件中,使得多个组件可以共享同一状态。

3.2 让我们通过一个温度转换器的例子来理解状态提升

// 温度输入组件 - 无状态组件
class TemperatureInput extends StatelessWidget {
  final TemperatureScale scale;
  final double temperature;
  final ValueChanged<double> onTemperatureChanged;

  const TemperatureInput({
    Key? key,
    required this.scale,
    required this.temperature,
    required this.onTemperatureChanged,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return TextField(
      decoration: InputDecoration(
        labelText: scale == TemperatureScale.celsius ? '摄氏度' : '华氏度',
      ),
      keyboardType: TextInputType.number,
      onChanged: (value) {
        final temperature = double.tryParse(value);
        if (temperature != null) {
          onTemperatureChanged(temperature);
        }
      },
    );
  }
}

// 温度显示组件 - 无状态组件
class TemperatureDisplay extends StatelessWidget {
  final double celsius;
  final double fahrenheit;

  const TemperatureDisplay({
    Key? key,
    required this.celsius,
    required this.fahrenheit,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('摄氏度: ${celsius.toStringAsFixed(2)}°C'),
        Text('华氏度: ${fahrenheit.toStringAsFixed(2)}°F'),
        _getTemperatureMessage(celsius),
      ],
    );
  }
  
  Widget _getTemperatureMessage(double celsius) {
    if (celsius >= 100) {
      return Text('水会沸腾', style: TextStyle(color: Colors.red));
    } else if (celsius <= 0) {
      return Text('水会结冰', style: TextStyle(color: Colors.blue));
    } else {
      return Text('水是液态', style: TextStyle(color: Colors.green));
    }
  }
}

// 主组件 - 管理状态
class TemperatureConverter extends StatefulWidget {
  @override
  _TemperatureConverterState createState() => _TemperatureConverterState();
}

class _TemperatureConverterState extends State<TemperatureConverter> {
  // 状态提升:温度值由父组件管理
  double _celsius = 0.0;

  // 转换方法
  double get _fahrenheit => _celsius * 9 / 5 + 32;
  
  void _handleCelsiusChange(double celsius) {
    setState(() {
      _celsius = celsius;
    });
  }
  
  void _handleFahrenheitChange(double fahrenheit) {
    setState(() {
      _celsius = (fahrenheit - 32) * 5 / 9;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('温度转换器')),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          children: [
            // 摄氏度输入
            TemperatureInput(
              scale: TemperatureScale.celsius,
              temperature: _celsius,
              onTemperatureChanged: _handleCelsiusChange,
            ),
            SizedBox(height: 20),
            // 华氏度输入
            TemperatureInput(
              scale: TemperatureScale.fahrenheit,
              temperature: _fahrenheit,
              onTemperatureChanged: _handleFahrenheitChange,
            ),
            SizedBox(height: 20),
            // 温度显示
            TemperatureDisplay(
              celsius: _celsius,
              fahrenheit: _fahrenheit,
            ),
          ],
        ),
      ),
    );
  }
}

enum TemperatureScale { celsius, fahrenheit }

状态提升的架构图

┌─────────────────────────────────────┐
│        TemperatureConverter          │
│                                      │
│  ┌─────────────────────────────────┐ │
│  │           State                 │ │
│  │   double _celsius               │ │
│  │                                 │ │
│  │   void _handleCelsiusChange()   │ │
│  │   void _handleFahrenheitChange()│ │
│  └─────────────────────────────────┘ │
│              │              │        │
│              ▼              ▼        │
│  ┌────────────────┐ ┌────────────────┐
│  │TemperatureInput│ │TemperatureInput│
│  │(Celsius)       │ │(Fahrenheit)    │
└──┼────────────────┘ └────────────────┘
   │
   ▼
┌─────────────────┐
│TemperatureDisplay│
└─────────────────┘

3.3 状态提升的优势

  1. 单一数据源:所有子组件使用同一个状态源
  2. 数据一致性:避免状态不同步的问题
  3. 易于调试:状态变化的位置集中,易追踪
  4. 组件复用:子组件成为无状态组件,易复用

当组件层次较深时,状态提升会导致"prop drilling"问题:

// 问题示例
class App extends StatefulWidget {
  @override
  _AppState createState() => _AppState();
}

class _AppState extends State<App> {
  User _user = User();
  
  @override
  Widget build(BuildContext context) {
    return UserProvider(
      user: _user,
      child: HomePage(
        user: _user, // 需要层层传递
        onUserUpdate: (User newUser) {
          setState(() {
            _user = newUser;
          });
        },
      ),
    );
  }
}

class HomePage extends StatelessWidget {
  final User user;
  final ValueChanged<User> onUserUpdate;
  
  const HomePage({required this.user, required this.onUserUpdate});
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Header(
        user: user, // 继续传递
        onUserUpdate: onUserUpdate, // 继续传递
        child: Content(
          user: user, // 还要传递
          onUserUpdate: onUserUpdate, // 还要传递
        ),
      ),
    );
  }
}

// 中间可能还有多层组件...

这正是InheritedWidget要解决的问题。

4. InheritedWidget:状态共享

4.1 InheritedWidget的基本概念

InheritedWidget是Flutter中用于在组件树中高效向下传递数据的特殊Widget。它允许子组件直接访问祖先组件中的数据,而无需显式地通过构造函数传递。

4.2 InheritedWidget的工作原理

先创建一个简单的InheritedWidget

// InheritedWidget示例
class SimpleInheritedWidget extends InheritedWidget {
  // 要共享的数据
  final int counter;
  final VoidCallback onIncrement;

  const SimpleInheritedWidget({
    Key? key,
    required this.counter,
    required this.onIncrement,
    required Widget child,
  }) : super(key: key, child: child);

  // 静态方法,方便子组件获取实例
  static SimpleInheritedWidget of(BuildContext context) {
    final SimpleInheritedWidget? result = 
        context.dependOnInheritedWidgetOfExactType<SimpleInheritedWidget>();
    assert(result != null, 'No SimpleInheritedWidget found in context');
    return result!;
  }

  // 决定是否通知依赖的组件重建
  @override
  bool updateShouldNotify(SimpleInheritedWidget oldWidget) {
    // 只有当counter发生变化时,才通知依赖的组件重建
    return counter != oldWidget.counter;
  }
}

InheritedWidget的工作流程

┌──────────────────┐
│InheritedWidget   │
│                  │
│ - 存储共享数据   │
│ - updateShouldNotify│
└─────────┬────────┘
          │
          │ 1. 提供数据
          ▼
┌──────────────────┐
│   BuildContext   │
│                  │
│ - inheritFromWidgetOfExactType │
│ - dependOnInheritedWidgetOfExactType │
└─────────┬────────┘
          │
          │ 2. 注册依赖
          ▼
┌──────────────────┐
│   子组件         │
│                  │
│ - 通过of方法获取数据│
│ - 自动注册为依赖者 │
└──────────────────┘

4.3 使用InheritedWidget重构计数器

让我们用InheritedWidget重构之前的计数器应用:

// 计数器状态类
class CounterState {
  final int count;
  final VoidCallback increment;
  final VoidCallback decrement;
  final VoidCallback reset;

  CounterState({
    required this.count,
    required this.increment,
    required this.decrement,
    required this.reset,
  });
}

// 计数器InheritedWidget
class CounterInheritedWidget extends InheritedWidget {
  final CounterState counterState;

  const CounterInheritedWidget({
    Key? key,
    required this.counterState,
    required Widget child,
  }) : super(key: key, child: child);

  static CounterInheritedWidget of(BuildContext context) {
    final CounterInheritedWidget? result = 
        context.dependOnInheritedWidgetOfExactType<CounterInheritedWidget>();
    assert(result != null, 'No CounterInheritedWidget found in context');
    return result!;
  }

  @override
  bool updateShouldNotify(CounterInheritedWidget oldWidget) {
    return counterState.count != oldWidget.counterState.count;
  }
}

// 计数器显示组件 - 无需传递props
class CounterDisplay extends StatelessWidget {
  const CounterDisplay({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // 直接通过InheritedWidget获取状态
    final counterState = CounterInheritedWidget.of(context).counterState;
    
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text(
          '当前计数:',
          style: Theme.of(context).textTheme.headline4,
        ),
        Text(
          '${counterState.count}',
          style: Theme.of(context).textTheme.headline2,
        ),
      ],
    );
  }
}

// 计数器按钮组件 - 无需传递回调
class CounterButtons extends StatelessWidget {
  const CounterButtons({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // 直接通过InheritedWidget获取方法
    final counterState = CounterInheritedWidget.of(context).counterState;
    
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        ElevatedButton(
          onPressed: counterState.decrement,
          child: Text('减少'),
        ),
        SizedBox(width: 20),
        ElevatedButton(
          onPressed: counterState.reset,
          child: Text('重置'),
        ),
        SizedBox(width: 20),
        ElevatedButton(
          onPressed: counterState.increment,
          child: Text('增加'),
        ),
      ],
    );
  }
}

// 主组件
class CounterAppWithInherited extends StatefulWidget {
  @override
  _CounterAppWithInheritedState createState() => 
      _CounterAppWithInheritedState();
}

class _CounterAppWithInheritedState extends State<CounterAppWithInherited> {
  int _count = 0;

  void _increment() {
    setState(() {
      _count++;
    });
  }

  void _decrement() {
    setState(() {
      _count--;
    });
  }

  void _reset() {
    setState(() {
      _count = 0;
    });
  }

  @override
  Widget build(BuildContext context) {
    // 创建状态对象
    final counterState = CounterState(
      count: _count,
      increment: _increment,
      decrement: _decrement,
      reset: _reset,
    );

    // 使用InheritedWidget包装整个子树
    return CounterInheritedWidget(
      counterState: counterState,
      child: Scaffold(
        appBar: AppBar(title: Text('InheritedWidget计数器')),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              CounterDisplay(), // 无需传递任何参数
              SizedBox(height: 20),
              CounterButtons(), // 无需传递任何参数
            ],
          ),
        ),
      ),
    );
  }
}

4.4 InheritedWidget的深层含义

4.4.1 dependOnInheritedWidgetOfExactType vs getElementForInheritedWidgetOfExactType

Flutter提供了两种获取InheritedWidget的方法:

// 方法1:注册依赖关系,当InheritedWidget更新时会重建
static CounterInheritedWidget of(BuildContext context) {
  return context.dependOnInheritedWidgetOfExactType<CounterInheritedWidget>()!;
}

// 方法2:不注册依赖关系,只是获取引用
static CounterInheritedWidget of(BuildContext context) {
  final element = context.getElementForInheritedWidgetOfExactType<CounterInheritedWidget>();
  return element?.widget as CounterInheritedWidget;
}

区别

  • dependOnInheritedWidgetOfExactType建立依赖关系,当InheritedWidget更新时,调用该方法的组件会重建
  • getElementForInheritedWidgetOfExactType不建立依赖关系,只是获取当前值的引用,适合在回调或初始化时使用
4.4.2 updateShouldNotify的优化

updateShouldNotify方法对于性能优化至关重要:

@override
bool updateShouldNotify(CounterInheritedWidget oldWidget) {
  // 优化前:任何变化都通知
  // return true;
  
  // 优化后:只有count变化才通知
  return counterState.count != oldWidget.counterState.count;
  
  // 更精细的控制
  // return counterState.count != oldWidget.counterState.count ||
  //        counterState.someOtherProperty != oldWidget.counterState.someOtherProperty;
}

5. 实战案例:构建主题切换应用

通过一个完整的主题切换应用来综合运用以上所学知识:

import 'package:flutter/material.dart';

// 主题数据类
class AppTheme {
  final ThemeData themeData;
  final String name;

  const AppTheme({
    required this.themeData,
    required this.name,
  });
}

// 预定义主题
class AppThemes {
  static final light = AppTheme(
    name: '浅色主题',
    themeData: ThemeData.light().copyWith(
      primaryColor: Colors.blue,
      colorScheme: ColorScheme.light(
        primary: Colors.blue,
        secondary: Colors.green,
      ),
    ),
  );

  static final dark = AppTheme(
    name: '深色主题',
    themeData: ThemeData.dark().copyWith(
      primaryColor: Colors.blueGrey,
      colorScheme: ColorScheme.dark(
        primary: Colors.blueGrey,
        secondary: Colors.green,
      ),
    ),
  );

  static final custom = AppTheme(
    name: '自定义主题',
    themeData: ThemeData(
      primaryColor: Colors.purple,
      colorScheme: ColorScheme.light(
        primary: Colors.purple,
        secondary: Colors.orange,
      ),
      brightness: Brightness.light,
    ),
  );
}

// 应用状态类
class AppState {
  final AppTheme currentTheme;
  final Locale currentLocale;
  final bool isLoggedIn;
  final String userName;

  const AppState({
    required this.currentTheme,
    required this.currentLocale,
    required this.isLoggedIn,
    required this.userName,
  });

  // 拷贝更新方法
  AppState copyWith({
    AppTheme? currentTheme,
    Locale? currentLocale,
    bool? isLoggedIn,
    String? userName,
  }) {
    return AppState(
      currentTheme: currentTheme ?? this.currentTheme,
      currentLocale: currentLocale ?? this.currentLocale,
      isLoggedIn: isLoggedIn ?? this.isLoggedIn,
      userName: userName ?? this.userName,
    );
  }
}

// 应用InheritedWidget
class AppInheritedWidget extends InheritedWidget {
  final AppState appState;
  final ValueChanged<AppTheme> onThemeChanged;
  final ValueChanged<Locale> onLocaleChanged;
  final VoidCallback onLogin;
  final VoidCallback onLogout;

  const AppInheritedWidget({
    Key? key,
    required this.appState,
    required this.onThemeChanged,
    required this.onLocaleChanged,
    required this.onLogin,
    required this.onLogout,
    required Widget child,
  }) : super(key: key, child: child);

  static AppInheritedWidget of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<AppInheritedWidget>()!;
  }

  @override
  bool updateShouldNotify(AppInheritedWidget oldWidget) {
    return appState.currentTheme != oldWidget.appState.currentTheme ||
           appState.currentLocale != oldWidget.appState.currentLocale ||
           appState.isLoggedIn != oldWidget.appState.isLoggedIn ||
           appState.userName != oldWidget.appState.userName;
  }
}

// 主题切换组件
class ThemeSwitcher extends StatelessWidget {
  const ThemeSwitcher({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final app = AppInheritedWidget.of(context);
    
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '主题设置',
              style: Theme.of(context).textTheme.headline6,
            ),
            SizedBox(height: 10),
            Wrap(
              spacing: 10,
              children: [
                _buildThemeButton(
                  context,
                  AppThemes.light,
                  app.appState.currentTheme.name == AppThemes.light.name,
                  app.onThemeChanged,
                ),
                _buildThemeButton(
                  context,
                  AppThemes.dark,
                  app.appState.currentTheme.name == AppThemes.dark.name,
                  app.onThemeChanged,
                ),
                _buildThemeButton(
                  context,
                  AppThemes.custom,
                  app.appState.currentTheme.name == AppThemes.custom.name,
                  app.onThemeChanged,
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildThemeButton(
    BuildContext context,
    AppTheme theme,
    bool isSelected,
    ValueChanged<AppTheme> onChanged,
  ) {
    return FilterChip(
      label: Text(theme.name),
      selected: isSelected,
      onSelected: (selected) {
        if (selected) {
          onChanged(theme);
        }
      },
      backgroundColor: isSelected 
          ? theme.themeData.primaryColor 
          : Theme.of(context).chipTheme.backgroundColor,
      labelStyle: TextStyle(
        color: isSelected ? Colors.white : null,
      ),
    );
  }
}

// 用户信息组件
class UserInfo extends StatelessWidget {
  const UserInfo({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final app = AppInheritedWidget.of(context);
    
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '用户信息',
              style: Theme.of(context).textTheme.headline6,
            ),
            SizedBox(height: 10),
            if (app.appState.isLoggedIn) ...[
              Text('用户名: ${app.appState.userName}'),
              SizedBox(height: 10),
              ElevatedButton(
                onPressed: app.onLogout,
                child: Text('退出登录'),
              ),
            ] else ...[
              Text('未登录'),
              SizedBox(height: 10),
              ElevatedButton(
                onPressed: app.onLogin,
                child: Text('模拟登录'),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

// 主页面
class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('主题切换应用'),
        elevation: 0,
      ),
      body: SingleChildScrollView(
        padding: EdgeInsets.all(16.0),
        child: Column(
          children: [
            // 欢迎信息
            Card(
              child: Padding(
                padding: EdgeInsets.all(16.0),
                child: Column(
                  children: [
                    Text(
                      '欢迎使用Flutter主题切换示例',
                      style: Theme.of(context).textTheme.headline5,
                    ),
                    SizedBox(height: 10),
                    Text(
                      '这是一个演示setState和InheritedWidget的综合示例应用。'
                      '您可以通过下方的控件切换应用主题和查看用户状态。',
                      style: Theme.of(context).textTheme.bodyText2,
                    ),
                  ],
                ),
              ),
            ),
            SizedBox(height: 20),
            // 主题切换
            ThemeSwitcher(),
            SizedBox(height: 20),
            // 用户信息
            UserInfo(),
            SizedBox(height: 20),
            // 内容示例
            _buildContentExample(context),
          ],
        ),
      ),
    );
  }

  Widget _buildContentExample(BuildContext context) {
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '内容示例',
              style: Theme.of(context).textTheme.headline6,
            ),
            SizedBox(height: 10),
            Text('这里展示了当前主题下的各种UI元素样式。'),
            SizedBox(height: 20),
            Wrap(
              spacing: 10,
              runSpacing: 10,
              children: [
                ElevatedButton(
                  onPressed: () {},
                  child: Text('主要按钮'),
                ),
                OutlinedButton(
                  onPressed: () {},
                  child: Text('边框按钮'),
                ),
                TextButton(
                  onPressed: () {},
                  child: Text('文本按钮'),
                ),
              ],
            ),
            SizedBox(height: 20),
            LinearProgressIndicator(
              value: 0.7,
              backgroundColor: Colors.grey[300],
            ),
            SizedBox(height: 10),
            CircularProgressIndicator(),
          ],
        ),
      ),
    );
  }
}

// 主应用
class ThemeSwitcherApp extends StatefulWidget {
  @override
  _ThemeSwitcherAppState createState() => _ThemeSwitcherAppState();
}

class _ThemeSwitcherAppState extends State<ThemeSwitcherApp> {
  AppState _appState = AppState(
    currentTheme: AppThemes.light,
    currentLocale: const Locale('zh', 'CN'),
    isLoggedIn: false,
    userName: '',
  );

  void _changeTheme(AppTheme newTheme) {
    setState(() {
      _appState = _appState.copyWith(currentTheme: newTheme);
    });
  }

  void _changeLocale(Locale newLocale) {
    setState(() {
      _appState = _appState.copyWith(currentLocale: newLocale);
    });
  }

  void _login() {
    setState(() {
      _appState = _appState.copyWith(
        isLoggedIn: true,
        userName: 'Flutter用户',
      );
    });
  }

  void _logout() {
    setState(() {
      _appState = _appState.copyWith(
        isLoggedIn: false,
        userName: '',
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return AppInheritedWidget(
      appState: _appState,
      onThemeChanged: _changeTheme,
      onLocaleChanged: _changeLocale,
      onLogin: _login,
      onLogout: _logout,
      child: MaterialApp(
        title: '主题切换示例',
        theme: _appState.currentTheme.themeData,
        locale: _appState.currentLocale,
        home: HomePage(),
        debugShowCheckedModeBanner: false,
      ),
    );
  }
}

6. 性能优化

6.1 避免不必要的重建

使用InheritedWidget时,要注意避免不必要的组件重建:

// 优化前:整个子树都会重建
@override
bool updateShouldNotify(AppInheritedWidget oldWidget) {
  // 总是通知重建
  return true; 
}

// 优化后:只有相关数据变化时才重建
@override
bool updateShouldNotify(AppInheritedWidget oldWidget) {
  return appState.currentTheme != oldWidget.appState.currentTheme;
  // 或者其他需要监听的状态变化
}

6.2 使用Consumer模式

对于复杂的应用,可以使用Consumer模式来进一步优化:

// 自定义Consumer组件
class ThemeConsumer extends StatelessWidget {
  final Widget Function(BuildContext context, AppTheme theme) builder;

  const ThemeConsumer({
    Key? key,
    required this.builder,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final theme = AppInheritedWidget.of(context).appState.currentTheme;
    return builder(context, theme);
  }
}

// 示例
ThemeConsumer(
  builder: (context, theme) {
    return Container(
      color: theme.themeData.primaryColor,
      child: Text(
        '使用Consumer模式',
        style: theme.themeData.textTheme.headline6,
      ),
    );
  },
)

6.3 组合使用setState和InheritedWidget

在实际应用中,很多组件都是组合使用的。

class HybridApp extends StatefulWidget {
  @override
  _HybridAppState createState() => _HybridAppState();
}

class _HybridAppState extends State<HybridApp> {
  // 全局状态 - 使用InheritedWidget共享
  final GlobalAppState _globalState = GlobalAppState();
  
  // 局部状态 - 使用setState管理
  int _localCounter = 0;

  @override
  Widget build(BuildContext context) {
    return GlobalStateInheritedWidget(
      state: _globalState,
      child: Scaffold(
        body: Column(
          children: [
            // 使用全局状态的组件
            GlobalUserInfo(),
            // 使用局部状态的组件
            LocalCounter(
              count: _localCounter,
              onIncrement: () {
                setState(() {
                  _localCounter++;
                });
              },
            ),
          ],
        ),
      ),
    );
  }
}

7. 总结与对比

7.1 setState vs InheritedWidget 对比

特性 setState InheritedWidget
适用场景 局部状态、简单交互 全局状态、跨组件共享
使用复杂度 简单直接 相对复杂
性能影响 重建整个子树 精确控制重建范围
测试难度 相对困难 相对容易

7.2 如何选择?

使用setState

  • 状态只在单个组件内部使用
  • 应用简单,组件层次浅
  • 状态变化频率低

使用InheritedWidget

  • 状态需要在多个组件间共享
  • 组件层次深,避免prop drilling
  • 需要精确控制重建范围

7.3 更高级的状态管理

  1. Provider:基于InheritedWidget的封装,更易用的状态管理
  2. Bloc/RxDart:响应式编程模式的状态管理
  3. Riverpod:Provider的改进版本,编译安全的状态管理
  4. GetX:轻量级但功能全面的状态管理解决方案

通过以上内容,我们掌握了Flutter状态管理的基础:setStateInheritedWidget。这两种方案虽然基础,但它们是理解更复杂状态管理方案的基础。记住:一定要多写!!!一定要多写!!!一定要多写!!! 希望本文对你理解Flutter状态管理有所帮助!如果你觉得有用,请一键三连(点赞、关注、收藏)

Swift 泛型深度指南 ——从“交换两个值”到“通用容器”的代码复用之路

为什么需要泛型

  1. 无泛型时代的“粘贴式”编程
// 只能交换 Int
func swapTwoInts(_ a: inout Int, _ b: inout Int) {
    let temp = a
    a = b
    b = temp
}

// 复制粘贴,改个类型名
func swapTwoStrings(_ a: inout String, _ b: inout String) {
    let temp = a
    a = b
    b = temp
}

问题:

  • 代码完全一样,仅类型不同
  • 维护 N 份副本,牵一发而动全身

泛型函数:写一次,跑所有类型

  1. 语法与占位符 T
// <T> 声明一个“占位符类型”
// 编译器每次调用时把 T 替换成真实类型
func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
    let temp = a
    a = b
    b = temp
}
  1. 调用方式:编译器自动推断 T
var x = 3, y = 5
swapTwoValues(&x, &y)          // T 被推断为 Int

var s1 = "A", s2 = "B"
swapTwoValues(&s1, &s2)        // T 被推断为 String

要点:

  • 占位符名可以是任意合法标识符,习惯单字母 TUV
  • 同一函数签名里所有 T 必须是同一真实类型,不允许 swapTwoValues(3, "hello")

泛型类型:自定义可复用容器

  1. 非泛型版 Int 栈
struct IntStack {
    private var items: [Int] = []
    mutating func push(_ item: Int) { items.append(item) }
    mutating func pop() -> Int { items.removeLast() }
}
  1. 泛型版 Stack
struct Stack<Element> {               // Element 为占位符
    private var items: [Element] = []
    mutating func push(_ item: Element) { items.append(item) }
    mutating func pop() -> Element { items.removeLast() }
}

// 用法
var intStack = Stack<Int>()
intStack.push(1)
intStack.push(2)
print(intStack.pop())   // 2

var strStack = Stack<String>()
strStack.push("🍎")
strStack.push("🍌")
print(strStack.pop())   // 🍌

类型约束:给“任意类型”划边界

  1. 场景:在容器里查找元素索引
// 编译失败版:并非所有 T 都支持 ==
func findIndex<T>(of valueToFind: T, in array: [T]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind {          // ❌ 错误:T 不一定能比较
            return index
        }
    }
    return nil
}
  1. 加入 Equatable 约束
func findIndex<T: Equatable>(of valueToFind: T, in array: [T]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind {          // ✅ 现在合法
            return index
        }
    }
    return nil
}

小结:

  • 语法 <T: SomeProtocol><T: SomeClass>
  • 可同时约束多个占位符:<T: Equatable, U: Hashable>

关联类型(associatedtype):协议里的“泛型”

  1. 定义容器协议
protocol Container {
    associatedtype Item                // 占位名
    mutating func append(_ item: Item)
    var count: Int { get }
    subscript(index: Int) -> Item { get }
}
  1. 让泛型 Stack 直接 conform
extension Stack: Container {
    // 编译器自动推断:
    // append 参数 item 类型 = Element
    // subscript 返回值类型 = Element
    // 故 Item == Element,无需手写 typealias
}
  1. 给关联类型加约束
protocol EquatableContainer: Container where Item: Equatable { }
// 现在所有 Item 必须支持 ==,可直接在扩展里使用 ==/!=

泛型 where 子句:更细粒度的约束

  1. 函数级 where
// 检查两个容器是否完全一致(顺序、元素、数量)
func allItemsMatch<C1: Container, C2: Container>
    (_ someContainer: C1, _ anotherContainer: C2) -> Bool
    where C1.Item == C2.Item, C1.Item: Equatable
{
    if someContainer.count != anotherContainer.count { return false }
    for i in 0..<someContainer.count {
        if someContainer[i] != anotherContainer[i] { return false }
    }
    return true
}
  1. 扩展级 where
extension Stack where Element: Equatable {
    // 仅当元素可比较时才出现该方法
    func isTop(_ item: Element) -> Bool {
        guard let top = items.last else { return false }
        return top == item
    }
}
  1. 下标也能泛型 + where
extension Container {
    // 接收一组索引,返回对应元素数组
    subscript<Indices: Sequence>(indices: Indices) -> [Item]
        where Indices.Element == Int
    {
        var result: [Item] = []
        for index in indices {
            result.append(self[index])
        }
        return result
    }
}

隐式约束与 Copyable(Swift 5.9+)

Swift 自动为泛型参数加上 Copyable 约束,以便值能被多次使用。

若你明确允许“可复制或不可复制”,用前缀 ~ 抑制隐式约束:

func consumeOnce<T>(_ x: consuming T) where T: ~Copyable {
    // x 只能被移动一次,不能再复制
}

示例代码

把下面代码一次性粘进 Playground,逐行跑通:

import Foundation

// 1. 通用交换
func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
    let t = a; a = b; b = t
}

// 2. 泛型栈
struct Stack<Element> {
    private var items: [Element] = []
    mutating func push(_ e: Element) { items.append(e) }
    mutating func pop() -> Element { items.removeLast() }
}
extension Stack: Container {
    typealias Item = Element
    mutating func append(_ item: Element) { push(item) }
    var count: Int { items.count }
    subscript(i: Int) -> Element { items[i] }
}

// 3. 协议
protocol Container {
    associatedtype Item
    mutating func append(_ item: Item)
    var count: Int { get }
    subscript(index: Int) -> Item { get }
}

// 4. 扩展约束
extension Stack where Element: Equatable {
    func isTop(_ e: Element) -> Bool { items.last == e }
}

// 5. 测试
var s = Stack<Int>()
s.push(10)
s.push(20)
print(s.isTop(20))        // true
print(s.pop())            // 20

var a: [String] = ["A","B","C"]
var b: Array<String> = ["A","B","C"]
print(allItemsMatch(a, b)) // true

总结与实战扩展

  1. 泛型 = 代码的“模板引擎”

    把“类型”也当成参数,一次性书写,多处复用,减少 BUG

  2. 约束是“接口隔离”的利器

    与其写长篇文档说明“请传能比较的值”,不如让编译器帮你拦下来:<T: Equatable>

  3. 关联类型让协议“带泛型”

    协议不再只能定义“行为”,还能定义“元素类型”,使协议与泛型容器无缝衔接。

  4. where 子句是“需求说明书”

    函数、扩展、下标都能写 where,把“调用前提”写进类型系统,用不合法的状态直接编译不过,比运行时断言更早暴露问题。

  5. 实战场景举例

    • JSON 解析层:写一个 Decoder<T: Decodable>,一份代码支持所有模型

    -缓存框架:定义 Cache<Key: Hashable, Value>,Key 约束为 Hashable,Value 任意类型

    • 网络请求:Request<Resp: Decodable>,响应体自动解析为任意模型

Swift 中的不透明类型与装箱协议类型:概念、区别与实践

前言

Swift 提供了两种隐藏类型信息的方式:不透明类型(opaque type) 和 装箱协议类型(boxed protocol type)。

它们都用于隐藏具体类型,但在类型身份、性能、灵活性等方面有本质区别。

不透明类型(Opaque Types)

基本概念

不透明类型允许函数返回一个遵循某个协议的具体类型,但调用者无法知道具体是什么类型。编译器知道类型信息,但调用者不知道。

使用关键字 some 来声明不透明类型。

protocol Shape {
    func draw() -> String
}

struct Square: Shape {
    func draw() -> String {
        return "■"
    }
}

func makeSquare() -> some Shape {
    return Square()
}

调用者知道返回的是一个 Shape,但不知道它是 Square

与泛型的区别

泛型是调用者决定类型,不透明类型是实现者决定类型。

// 泛型:调用者决定类型
func maxNum<T: Comparable>(_ x: T, _ y: T) -> T {
    // 实现者,不知道T是什么类型,只知道T可以进行比较
    return x > y ? x : y
}

// 调用者传入1,3。编译器会自动推断T为Int
// 也可以补全写法 maxNum<Int>(1, 3)
let maxInt = maxNum(1,3)


// 不透明类型:实现者决定类型
func makeShape() -> some Shape {
    // 实现者 决定最终返回的具体类型
    return Square()
}
// 下面的写法报错,调用者不能指定类型
// Cannot convert value of type 'some Shape' to specified type 'Square'
//let shape: Square = makeShape()
// 只能用some Shape
let shape: some Shape = makeShape()

不透明类型的限制:必须返回单一类型

struct FlippedShape: Shape {
    var shape: Shape
    init(_ shape:  Shape) {
        self.shape = shape
    }
    func draw() -> String {
        let lines = shape.draw().split(separator: "\n")
        return lines.reversed().joined(separator: "\n")
    }
}
// ❌ 错误:返回了不同类型
// Function declares an opaque return type 'some Shape', but the return statements in its body do not have matching underlying types
func invalidFlip<T: Shape>(_ shape: T) -> some Shape {
    if shape is Square {
        return shape // 返回的是 T
    }
    return FlippedShape(shape) // 返回的是 FlippedShape<T>
}

✅ 正确做法:统一返回类型

func flip<T: Shape>(_ shape: T) -> some Shape {
    return FlippedShape(shape) // 总是返回 FlippedShape<T>
}

泛型与不透明类型结合使用

struct RepeatedShape: Shape {
    let shape: Shape
    let count: Int
    
    func draw() -> String {
        return (0..<count).map { _ in shape.draw() }.joined(separator: "\n")
    }
}

func repeatShape<T: Shape>(shape: T, count: Int) -> some Shape {
    return RepeatedShape(shape: shape, count: count)
}

虽然 T 是泛型,但返回类型始终是 RepeatedShape<T>,满足单一类型要求。

装箱协议类型(Boxed Protocol Types)

基本概念

装箱协议类型使用 any 关键字,表示“任意遵循该协议的类型”,类型信息在运行时确定,类型身份不保留。

let shape: any Shape = Square()

any Shape 可以存储任何遵循 Shape 的类型,类似于 Objective-C 的 id<Protocol>

使用示例

struct VerticalShapes {
    var shapes: [any Shape] // 可以存储不同类型的 Shape

    func draw() -> String {
        return shapes.map { $0.draw() }.joined(separator: "\n")
    }
}

类型擦除与运行时类型检查

struct Triangle: Shape {
    func draw() -> String {
        return "🔺"
    }
}

let shapes: [any Shape] = [Square(), Triangle()]

for shape in shapes {
    if let square = shape as? Square {
        print("这是一个方块:\(square.draw())")
    }
}

不透明类型 vs 装箱协议类型

特性 不透明类型 some 装箱协议类型 any
类型身份 保留(编译期已知) 不保留(运行时确定)
灵活性 低(只能返回一种类型) 高(可返回多种类型)
性能 更好(无运行时开销) 有装箱开销
是否支持协议关联类型 ✅ 支持 ❌ 不支持

代码示例对比

不透明类型版本

func flip<T: Shape>(_ shape: T) -> some Shape {
    return FlippedShape(shape)
}

let flipped = flip(Square())
let doubleFlipped = flip(flipped) // ✅ 支持嵌套

装箱协议类型版本

func protoFlip(_ shape: any Shape) -> any Shape {
    return FlippedShape(shape)
}

let flipped = protoFlip(Square())
let doubleFlipped = protoFlip(flipped)  // ✅ 支持嵌套

不透明参数类型(some 作为参数)

func drawTwice(_ shape: some Shape) {
    print(shape.draw())
    print(shape.draw())
}

等价于泛型函数:

func drawTwice<S: Shape>(_ shape: S) {
    print(shape.draw())
    print(shape.draw())
}

总结与最佳实践

使用场景 推荐类型
隐藏实现细节,返回单一类型 some Protocol
存储多种协议类型 any Protocol
需要运行时类型判断 any Protocol

在实际项目中的应用

SwiftUI 中的不透明类型

var body: some View {
    VStack {
        Text("Hello")
        Image(systemName: "star")
    }
}

body 返回的是某个具体类型(如 _VStack<TupleView<(Text, Image)>>),但调用者无需关心。

网络层返回模型抽象

protocol Model {
    static func parse(from data: Data) -> Self?
}

func fetch<T: Model>(_ type: T.Type) -> some Model {
    // 隐藏具体模型类型,返回某个遵循 Model 的类型
}

结语

不透明类型和装箱协议类型是 Swift 类型系统中非常强大的工具,它们各自解决了不同层面的抽象问题:

  • some 更适合封装实现细节,提供类型安全的抽象接口;
  • any 更适合运行时灵活性,但牺牲类型信息和性能。

理解它们的本质区别,能帮助你在设计 API、构建模块时做出更合理的架构决策。

参考资料

惊险但幸运,两次!| 肘子的 Swift 周报 #0109

issue109.webp

📮 想持续关注 Swift 技术前沿?

每周一期《肘子的 Swift 周报》,为你精选本周最值得关注的 Swift、SwiftUI 技术文章、开源项目和社区动态。

一起构建更好的 Swift 应用!🚀

惊险但幸运,两次!

我的岳父岳母的身体一向很好,尤其是岳父,八十多岁了从未住过院。但在短短的时间里,他们先后经历了两场惊魂时刻——好在结果都十分幸运。

先是岳母。那天我们刚从机场接她回家,发现她脸色不好,自述昨晚没睡好,似乎又有肾结石发作的迹象。正当我们决定取消外出用餐时,她突然开始剧烈发抖,但额头仍是凉的。测量血氧后发现指标在快速下降——即使立即开始吸氧,最低时也掉到了 78。

救护车到达时(约 40 分钟后),体温已升至 39.8℃;送医时更是接近 41℃。人的状态非常差。幸运的是,在此前她提到过肾结石的不适,医生据此快速展开检查,确诊为尿源性脓毒症——结石堵住输尿管,细菌在尿液中迅速繁殖,最终进入血液。 医生直言,若再晚些送来,后果可能不堪设想。想到她刚下飞机,我们也不禁后怕——幸亏航班没有延误,幸亏那天决定乘坐早班机。经过紧急手术引流后,她的状况在第二天便明显好转,数日后顺利出院。

不久之后,又轮到岳父经历一次“惊险但幸运”的考验。 在照顾岳母期间,他出现咳嗽并伴随低热。CT 显示只是支气管扩张,但考虑到年龄,我们坚持让他住院观察。 在例行心电图检查中,医生意外发现他有房颤,心率在 130–170 之间剧烈波动。令人吃惊的是,他本人毫无不适感。事后他说,今年 5 月体检时医生就提醒过有房颤,但他没当回事,也没有告诉我们。 由于心率过快,治疗重心立即转为控制心律。幸运再次眷顾——在使用胺碘酮(Amiodarone)数小时后,心率逐渐恢复至 80 以下,成功复律。如今虽然仍在住院,但情况稳定,只需出院后按医嘱服用控制心率与抗凝药物即可。这次的阴差阳错,也让他在不经意间避免了房颤可能带来的更严重后果。

短短一周内的两次意外,让人既感叹生命的脆弱,也更懂得珍惜当下。幸运往往属于那些及时行动、认真对待健康警示的人。愿我们都能对自己和家人的身体多一分关注,少一分侥幸。

前一期内容全部周报列表

近期推荐

让 onChange 同时监听多个值 onChange(anyOf:initial:_:)

onChange 是 SwiftUI 中常用的状态监听工具,开发者可以通过它在视图中针对某个值的变化执行逻辑。不过,尽管该修饰符历经多次改进,它仍然只能同时观察一个值。若要在多个状态变更时执行相同操作,就需要重复添加多个内容相同的 onChange 闭包。在本文中,Matt Comi 借助 variadic generics(可变参数泛型),让 onChange 能够同时监听多个值,从而构建出一个更高效、更优雅的解决方案。


SwiftUI 中的滚动吸附 (ScrollView snapping in SwiftUI)

从 iOS 17 开始,开发者可以通过 scrollTargetBehavior 控制 ScrollView 的滚动行为,比如按页面吸附或按视图吸附。Natalia Panferova 在这篇文章中详细探讨了该 API 的用法,并分享了实践经验和需要注意的陷阱:视图对齐模式要求每个吸附目标适配可见区域;当单个项目尺寸超过容器大小时,滚动会感觉"卡住";对于超大项目,需要考虑自定义 ScrollTargetBehavior

精确掌控 SwiftUI 滚动:自定义 Paging 实现 一文中,我也介绍了如何通过自定义 ScrollTargetBehavior 解决横屏模式(Landscape)下滚动偏移、页面对齐不精确的问题。


Swift 类型检查器改进路线图 (Roadmap for improving the type checker)

作为 Swift 开发者,你一定不会对那句 "unable to type-check this expression in reasonable time" 感到陌生。为什么会出现这个编译错误?Swift 又将如何减少这种情况的发生?Swift 核心团队成员 Slava Pestov 在这份路线图中给出了详细的解释与改进方向。

该问题源自约束求解(constraint solving)的指数时间复杂度。为防止编译器陷入无休止的回溯,Swift 设定了两项限制:析取选择次数上限(100 万次) 和 求解器内存上限(512 MB)。Swift 6.2 通过优化底层算法实现了初步提速,而 Swift 6.3 引入的新析取选择算法与内存优化带来了显著改进:同一测试项目的类型检查时间从 42 秒降至 10 秒。Swift 团队的目标是在不牺牲语言表达力的前提下,通过算法与实现层的持续优化,将“指数最坏情况”压缩到更少、更边缘的实际场景中。


iOS Sheet 的现代化演进 (Playing with Sheet (on iOS))

在 iOS 上,Sheet 曾经意味着“全屏接管”——从底部滑出、阻挡一切、等待用户点击“完成”。但如今,这种模式已经过去。Apple 重新定义了内容的呈现方式:在新的设计哲学下,Sheet 不再是中断,而是节奏的一部分——它可以滑动、漂浮、扩展、折叠,让界面保持连贯与呼吸感。Danny Bolella 从多个层面展示了现代 Sheet 的可定制特性,并通过实例演示了这些特性如何让 Sheet 从“流程中断”转变为“上下文扩展”。

针对 Sheet 无法自动适应内容高度的问题,开发者可以通过 GeometryReader 测量内容高度并结合 .id() 刷新的技巧来实现动态高度调整。


iOS 性能优化:Apple 工程师问答精选 (Optimize Your App's Speed and Efficiency: Q&A)

Anton Gubarenko 整理了 Apple “Optimize your app’s speed and efficiency” 活动中的 Q&A 内容,涵盖 SwiftUI 性能(闭包捕获优化、Observable 使用、@Binding vs let)、Liquid Glass 容器(GlassEffectContainer 的最佳实践)、Foundation Models 框架(多语言支持、并发使用、延迟优化)以及 Instruments 工具(Hitch vs Hang、新增 Power Profiler)等关键领域。

本次 Q&A 展现了 Apple 工程团队在性能调优层面的实践取向:通过细节分析与工具驱动,让优化从“黑盒经验”转变为“可度量、可验证的工程流程”。


Swift 中禁用单个弃用警告的技巧 (Workaround: how to silence individual deprecation warnings in Swift)

开发中难免必须使用一些被软废弃(deprecated)的 API。在开启“警告即错误”(-warnings-as-errors)后,将面临无法编译的窘境。不同于 Objective-C 可以使用 #pragma clang diagnostic 针对特定代码段禁用警告,Swift 至今没有等效机制。

Jesse Squires 分享了一个巧妙的解决方案:定义一个协议包装弃用 API,在扩展中实现时标记为 @available(*, deprecated),然后通过类型转换为协议类型来调用。编译器会通过协议见证表查找方法,从而“隐藏”弃用警告。虽然方案略显冗长,但对于必须使用遗留 API 的纯 Swift 项目很实用。


深入 Swift 底层:从二进制优化到逆向工程

以下几篇文章都偏硬核,适合关注工具链、运行时与二进制层面细节的读者。

工具

Swift Stream IDE - 跨平台 Swift 开发扩展

Swift Stream IDE 是由 Mikhail Isaev 开发的功能强大的 VS Code 扩展,旨在让开发者能够流畅地构建面向非 Apple 平台的 Swift 项目。它基于 VS Code 的 Dev Containers 扩展,将编译器、依赖与工具链完整封装在 Docker 容器中,实现了真正一致、可移植的跨平台 Swift 开发体验。目前,Swift Stream 已支持多种项目类型,包括 Web(WebAssembly)、服务器(Vapor/Hummingbird)、Android(库开发)、嵌入式(ESP32-C6、树莓派等)等多种项目类型。

The Swift Android Setup I Always Wanted 一文中,Mikhail 还演示了如何结合 Swift Stream IDE、swift-android-sdk 与 JNIKit,高效构建 Android 原生库。

容器化开发的优势包括:保持宿主机环境整洁、确保旧项目的可编译性、支持跨平台一致的开发体验,以及可通过 SSH 远程连接到更强大的机器进行开发。


AnyLanguageModel - 统一 Swift 大模型开发范式

或许许多开发者不会在第一时间使用 WWDC 2025 上推出的 Foundation Models 框架,但大多数人都对它的 API 印象深刻——这一框架显著简化了与大模型的交互流程,并充分发挥了 Swift 语言特性。

Mattt 开发的 AnyLanguageModel 实现了与 Foundation Models 框架完全兼容的 API:只需将 import FoundationModels 换成 import AnyLanguageModel,就能在不改动上层业务代码的前提下,接入多家云端及本地模型后端(OpenAI、Anthropic、Ollama、llama.cpp、Core ML、MLX 以及 Foundation Models)。此外,该库还利用 Swift 6.1 的 Traits 特性,可按需引入重依赖,从而显著降低二进制体积并提升构建速度。


Metal Lab - 基于 Apple Metal API 生态的中文图形学社区

在中文互联网上,关于 Metal 的教程资源非常稀少,尽管 Apple 官方提供了详尽的文档和示例代码,但这些文档对于国内开发者而言往往语言晦涩、结构复杂,缺乏通俗易懂的入门指导,初学者常常感到难以上手。Metal Lab 提供的教程文档旨在填补这一空白,为中文开发者提供一份系统性、易懂、循序渐进的 Metal 入门资料,帮助他们从零开始掌握 Metal 编程的精髓。无论是游戏开发、图形渲染,还是计算机视觉应用,这都是一份值得收藏的中文资源。

往期内容

THANK YOU

如果你觉得这份周报或者我的文章对你有所帮助,欢迎 点赞 并将其 转发 给更多的朋友。

📮 想持续关注 Swift 技术前沿?

每周一期《肘子的 Swift 周报》,为你精选本周最值得关注的 Swift、SwiftUI 技术文章、开源项目和社区动态。

一起构建更好的 Swift 应用!🚀

《Flutter全栈开发实战指南:从零到高级》- 09 -常用UI组件库实战

《Flutter全栈开发实战指南:从零到高级》- 09 -常用UI组件库深度解析与实战

1. 前言:UI组件库在Flutter开发中的核心地位

在Flutter应用开发中,UI组件库构成了应用界面的基础版块块。就像建筑工人使用标准化的砖块、门窗和楼梯来快速建造房屋一样,Flutter开发者使用组件库来高效构建应用界面。

组件库的核心价值:

  • 提高开发效率,减少重复代码
  • 保证UI一致性
  • 降低设计和技术门槛
  • 提供最佳实践和性能优化

2. Material Design组件

2.1 Material Design设计架构

Material Design是Google推出的设计语言,它的核心思想是将数字界面视为一种特殊的"材料" 。这种材料具有物理特性:可以滑动、折叠、展开,有阴影和深度,遵循真实的物理规律。

Material Design架构层次:

┌─────────────────┐
     动效层         提供有意义的过渡和反馈
├─────────────────┤
   组件层           按钮卡片对话框等UI元素
├─────────────────┤
   颜色/字体层      色彩系统和字体层级
├─────────────────┤
   布局层           栅格系统和间距规范
└─────────────────┘

2.2 核心布局组件详解

2.2.1 Scaffold:应用骨架组件

Scaffold是Material应用的基础布局结构,它协调各个视觉元素的位置关系。

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('应用标题'),
        actions: [
          IconButton(icon: Icon(Icons.search), onPressed: () {})
        ],
      ),
      drawer: Drawer(
        child: ListView(
          children: [/* 抽屉内容 */]
        ),
      ),
      body: Center(child: Text('主要内容')),
      bottomNavigationBar: BottomNavigationBar(
        items: [/* 导航项 */],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        child: Icon(Icons.add),
      ),
    );
  }
}

Scaffold组件关系图:

Scaffold
├── AppBar (顶部应用栏)
├── Drawer (侧边抽屉)
├── Body (主要内容区域)
├── BottomNavigationBar (底部导航)
└── FloatingActionButton (悬浮按钮)
2.2.2 Container:多功能容器组件

Container是Flutter中最灵活的布局组件,可以理解为HTML中的div元素。

Container(
  width: 200,
  height: 100,
  margin: EdgeInsets.all(16),
  padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
  decoration: BoxDecoration(
    color: Colors.blue[50],
    borderRadius: BorderRadius.circular(12),
    boxShadow: [
      BoxShadow(
        color: Colors.grey.withOpacity(0.5),
        blurRadius: 5,
        offset: Offset(0, 3),
      )
    ],
  ),
  child: Text('容器内容'),
)

Container布局流程:

graph TD
    A[Container创建] --> B{有子组件?}
    B -->|是| C[包裹子组件]
    B -->|否| D[填充可用空间]
    C --> E[应用约束条件]
    D --> E
    E --> F[应用装饰效果]
    F --> G[渲染完成]

2.3 表单组件深度实战

表单是应用中最常见的用户交互模式,Flutter提供了完整的表单解决方案。

2.3.1 表单验证架构
class LoginForm extends StatefulWidget {
  @override
  _LoginFormState createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            controller: _emailController,
            decoration: InputDecoration(
              labelText: '邮箱',
              border: OutlineInputBorder(),
            ),
            validator: (value) {
              if (value == null || value.isEmpty) {
                return '邮箱不能为空';
              }
              if (!RegExp(r'^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$')
                  .hasMatch(value)) {
                return '请输入有效的邮箱地址';
              }
              return null;
            },
          ),
          SizedBox(height: 16),
          TextFormField(
            controller: _passwordController,
            obscureText: true,
            decoration: InputDecoration(
              labelText: '密码',
              border: OutlineInputBorder(),
            ),
            validator: (value) {
              if (value == null || value.isEmpty) {
                return '密码不能为空';
              }
              if (value.length < 6) {
                return '密码至少6位字符';
              }
              return null;
            },
          ),
          SizedBox(height: 24),
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                _performLogin();
              }
            },
            child: Text('登录'),
          ),
        ],
      ),
    );
  }

  void _performLogin() {
    // 执行登录逻辑
  }
}

表单验证流程图:

sequenceDiagram
    participant U as 用户
    participant F as Form组件
    participant V as 验证器
    participant S as 提交逻辑

    U->>F: 点击提交按钮
    F->>V: 调用验证器
    V->>V: 检查每个字段
    alt 验证通过
        V->>F: 返回null
        F->>S: 执行提交逻辑
        S->>U: 显示成功反馈
    else 验证失败
        V->>F: 返回错误信息
        F->>U: 显示错误提示
    end

3. Cupertino风格组件:iOS原生体验

3.1 Cupertino

Cupertino设计语言基于苹果的Human Interface Guidelines,强调清晰、遵从和深度。

Cupertino设计原则:

  • 清晰度:文字易读,图标精确
  • 遵从性:内容优先,UI辅助
  • 深度:层级分明,动效过渡自然

3.2 Cupertino组件实战

3.2.1 Cupertino页面架构
import 'package:flutter/cupertino.dart';

class CupertinoStylePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: Text('iOS风格页面'),
        trailing: CupertinoButton(
          child: Icon(CupertinoIcons.add),
          onPressed: () {},
        ),
      ),
      child: SafeArea(
        child: ListView(
          children: [
            CupertinoListSection(
              children: [
                CupertinoListTile(
                  title: Text('设置'),
                  leading: Icon(CupertinoIcons.settings),
                  trailing: CupertinoListTileChevron(),
                  onTap: () {},
                ),
                CupertinoListTile(
                  title: Text('通知'),
                  leading: Icon(CupertinoIcons.bell),
                  trailing: CupertinoSwitch(
                    value: true,
                    onChanged: (value) {},
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Cupertino页面结构图:

CupertinoPageScaffold
├── CupertinoNavigationBar
│   ├── leading (左侧按钮)
│   ├── middle (标题)
│   └── trailing (右侧按钮)
└── child (主要内容)
    └── SafeArea
        └── ListView
            └── CupertinoListSection
                ├── CupertinoListTile
                └── CupertinoListTile
3.2.2 自适应开发模式

在跨平台开发中,提供平台原生的用户体验非常重要。

class AdaptiveComponent {
  static Widget buildButton({
    required BuildContext context,
    required String text,
    required VoidCallback onPressed,
  }) {
    final isIOS = Theme.of(context).platform == TargetPlatform.iOS;
    
    if (isIOS) {
      return CupertinoButton(
        onPressed: onPressed,
        child: Text(text),
      );
    } else {
      return ElevatedButton(
        onPressed: onPressed,
        child: Text(text),
      );
    }
  }

  static void showAlert({
    required BuildContext context,
    required String title,
    required String content,
  }) {
    final isIOS = Theme.of(context).platform == TargetPlatform.iOS;
    
    if (isIOS) {
      showCupertinoDialog(
        context: context,
        builder: (context) => CupertinoAlertDialog(
          title: Text(title),
          content: Text(content),
          actions: [
            CupertinoDialogAction(
              child: Text('确定'),
              onPressed: () => Navigator.pop(context),
            ),
          ],
        ),
      );
    } else {
      showDialog(
        context: context,
        builder: (context) => AlertDialog(
          title: Text(title),
          content: Text(content),
          actions: [
            TextButton(
              child: Text('确定'),
              onPressed: () => Navigator.pop(context),
            ),
          ],
        ),
      );
    }
  }
}

平台适配流程图:

graph LR
    A[组件初始化] --> B{检测运行平台}
    B -->|iOS| C[使用Cupertino组件]
    B -->|Android| D[使用Material组件]
    C --> E[渲染iOS风格UI]
    D --> F[渲染Material风格UI]

4. 第三方UI组件库

4.1 第三方库选择标准与架构

在选择第三方UI库时,需要有一定系统的评估标准。当然这些评估标准也没有定式,适合自己的才是最重要的~~~

第三方库评估矩阵:

评估维度 权重 评估标准
维护活跃度 30% 最近更新、Issue响应
文档完整性 25% API文档、示例代码
测试覆盖率 20% 单元测试、集成测试
社区生态 15% Star数、贡献者
性能表现 10% 内存占用、渲染性能

4.2 状态管理库集成

状态管理是复杂应用的核心,Provider是目前最流行的解决方案之一。

import 'package:provider/provider.dart';

// 用户数据模型
class UserModel with ChangeNotifier {
  String _name = '默认用户';
  int _age = 0;

  String get name => _name;
  int get age => _age;

  void updateUser(String newName, int newAge) {
    _name = newName;
    _age = newAge;
    notifyListeners(); // 通知监听者更新
  }
}

// 主题数据模型
class ThemeModel with ChangeNotifier {
  bool _isDarkMode = false;

  bool get isDarkMode => _isDarkMode;
  
  void toggleTheme() {
    _isDarkMode = !_isDarkMode;
    notifyListeners();
  }
}

// 应用入口配置
void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => UserModel()),
        ChangeNotifierProvider(create: (_) => ThemeModel()),
      ],
      child: MyApp(),
    ),
  );
}

// 使用Provider的页面
class ProfilePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('用户资料'),
      ),
      body: Consumer2<UserModel, ThemeModel>(
        builder: (context, user, theme, child) {
          return Column(
            children: [
              ListTile(
                title: Text('用户名: ${user.name}'),
                subtitle: Text('年龄: ${user.age}'),
              ),
              SwitchListTile(
                title: Text('深色模式'),
                value: theme.isDarkMode,
                onChanged: (value) => theme.toggleTheme(),
              ),
            ],
          );
        },
      ),
    );
  }
}

Provider状态管理架构图:

graph TB
    A[数据变更] --> B[notifyListeners]
    B --> C[Provider监听到变化]
    C --> D[重建依赖的Widget]
    D --> E[UI更新]
    
    F[用户交互] --> G[调用Model方法]
    G --> A
    
    subgraph "Provider架构"
        H[ChangeNotifierProvider] --> I[数据提供]
        I --> J[Consumer消费]
        J --> K[UI构建]
    end

5. 自定义组件开发:构建专属设计系统

5.1 自定义组件设计方法论

开发自定义组件需要遵循系统化的设计流程。

组件开发生命周期:

需求分析 → API设计 → 组件实现 → 测试验证 → 文档编写 → 发布维护

5.2 实战案例:可交互评分组件开发

下面开发一个支持点击、滑动交互的动画评分组件。

// 动画评分组件
class InteractiveRatingBar extends StatefulWidget {
  final double initialRating;
  final int itemCount;
  final double itemSize;
  final Color filledColor;
  final Color unratedColor;
  final ValueChanged<double> onRatingChanged;

  const InteractiveRatingBar({
    Key? key,
    this.initialRating = 0.0,
    this.itemCount = 5,
    this.itemSize = 40.0,
    this.filledColor = Colors.amber,
    this.unratedColor = Colors.grey,
    required this.onRatingChanged,
  }) : super(key: key);

  @override
  _InteractiveRatingBarState createState() => _InteractiveRatingBarState();
}

class _InteractiveRatingBarState extends State<InteractiveRatingBar>
    with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  late Animation<double> _animation;
  double _currentRating = 0.0;
  bool _isInteracting = false;

  @override
  void initState() {
    super.initState();
    _currentRating = widget.initialRating;
    _animationController = AnimationController(
      duration: const Duration(milliseconds: 300),
      vsync: this,
    );
    _animation = Tween<double>(
      begin: widget.initialRating,
      end: widget.initialRating,
    ).animate(CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeOut,
    ));
  }

  void _updateRating(double newRating) {
    setState(() {
      _currentRating = newRating;
    });
    _animateTo(newRating);
    widget.onRatingChanged(newRating);
  }

  void _animateTo(double targetRating) {
    _animation = Tween<double>(
      begin: _currentRating,
      end: targetRating,
    ).animate(CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeOut,
    ));
    _animationController.forward(from: 0.0);
  }

  double _calculateRatingFromOffset(double dx) {
    final itemWidth = widget.itemSize;
    final totalWidth = widget.itemCount * itemWidth;
    final rating = (dx / totalWidth) * widget.itemCount;
    return rating.clamp(0.0, widget.itemCount.toDouble());
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return GestureDetector(
          onPanDown: (details) {
            _isInteracting = true;
            final rating = _calculateRatingFromOffset(details.localPosition.dx);
            _updateRating(rating);
          },
          onPanUpdate: (details) {
            final rating = _calculateRatingFromOffset(details.localPosition.dx);
            _updateRating(rating);
          },
          onPanEnd: (details) {
            _isInteracting = false;
          },
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: List.generate(widget.itemCount, (index) {
              return _buildRatingItem(index);
            }),
          ),
        );
      },
    );
  }

  Widget _buildRatingItem(int index) {
    final ratingValue = _animation.value;
    final isFilled = index < ratingValue;
    final fillAmount = (ratingValue - index).clamp(0.0, 1.0);

    return CustomPaint(
      size: Size(widget.itemSize, widget.itemSize),
      painter: _StarPainter(
        fill: fillAmount,
        filledColor: widget.filledColor,
        unratedColor: widget.unratedColor,
      ),
    );
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }
}

// 自定义星星绘制器
class _StarPainter extends CustomPainter {
  final double fill;
  final Color filledColor;
  final Color unratedColor;

  _StarPainter({
    required this.fill,
    required this.filledColor,
    required this.unratedColor,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = unratedColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2.0;

    final fillPaint = Paint()
      ..color = filledColor
      ..style = PaintingStyle.fill;

    // 绘制星星路径
    final path = _createStarPath(size);
    
    // 绘制未填充的轮廓
    canvas.drawPath(path, paint);
    
    // 绘制填充部分
    if (fill > 0) {
      canvas.save();
      final clipRect = Rect.fromLTWH(0, 0, size.width * fill, size.height);
      canvas.clipRect(clipRect);
      canvas.drawPath(path, fillPaint);
      canvas.restore();
    }
  }

  Path _createStarPath(Size size) {
    final path = Path();
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2;
    
    // 五角星绘制算法
    for (int i = 0; i < 5; i++) {
      final angle = i * 4 * pi / 5 - pi / 2;
      final point = center + Offset(cos(angle) * radius, sin(angle) * radius);
      if (i == 0) {
        path.moveTo(point.dx, point.dy);
      } else {
        path.lineTo(point.dx, point.dy);
      }
    }
    path.close();
    return path;
  }

  @override
  bool shouldRepaint(covariant _StarPainter oldDelegate) {
    return fill != oldDelegate.fill ||
        filledColor != oldDelegate.filledColor ||
        unratedColor != oldDelegate.unratedColor;
  }
}

自定义组件交互流程图:

sequenceDiagram
    participant U as 用户
    participant G as GestureDetector
    participant A as AnimationController
    participant C as CustomPainter
    participant CB as 回调函数

    U->>G: 手指按下/移动
    G->>G: 计算对应评分
    G->>A: 启动动画
    A->>C: 触发重绘
    C->>C: 根据fill值绘制
    G->>CB: 调用onRatingChanged
    CB->>U: 更新外部状态

5.3 组件性能优化策略

性能优化是自定义组件开发的非常重要的一环。

组件优化:

优化方法 适用场景 实现方式
const构造函数 静态组件 使用const创建widget
RepaintBoundary 复杂绘制 隔离重绘区域
ValueKey 列表优化 提供唯一标识
缓存策略 重复计算 缓存计算结果
// 优化后的组件示例
class OptimizedComponent extends StatelessWidget {
  const OptimizedComponent({
    Key? key,
    required this.data,
  }) : super(key: key);

  final ExpensiveData data;

  @override
  Widget build(BuildContext context) {
    return RepaintBoundary(
      child: Container(
        child: _buildExpensiveContent(),
      ),
    );
  }

  Widget _buildExpensiveContent() {
    // 复杂绘制逻辑
    return CustomPaint(
      painter: _ExpensivePainter(data),
    );
  }
}

class _ExpensivePainter extends CustomPainter {
  final ExpensiveData data;
  
  _ExpensivePainter(this.data);

  @override
  void paint(Canvas canvas, Size size) {
    // 复杂绘制操作
  }

  @override
  bool shouldRepaint(covariant _ExpensivePainter oldDelegate) {
    return data != oldDelegate.data;
  }
}

6. 综合实战:电商应用商品列表页面

下面构建一个完整的电商商品列表页面,综合运用各种UI组件。

// 商品数据模型
class Product {
  final String id;
  final String name;
  final String description;
  final double price;
  final double originalPrice;
  final String imageUrl;
  final double rating;
  final int reviewCount;
  final bool isFavorite;

  Product({
    required this.id,
    required this.name,
    required this.description,
    required this.price,
    required this.originalPrice,
    required this.imageUrl,
    required this.rating,
    required this.reviewCount,
    this.isFavorite = false,
  });

  Product copyWith({
    bool? isFavorite,
  }) {
    return Product(
      id: id,
      name: name,
      description: description,
      price: price,
      originalPrice: originalPrice,
      imageUrl: imageUrl,
      rating: rating,
      reviewCount: reviewCount,
      isFavorite: isFavorite ?? this.isFavorite,
    );
  }
}

// 商品列表页面
class ProductListPage extends StatefulWidget {
  @override
  _ProductListPageState createState() => _ProductListPageState();
}

class _ProductListPageState extends State<ProductListPage> {
  final List<Product> _products = [];
  final ScrollController _scrollController = ScrollController();
  bool _isLoading = false;
  int _currentPage = 1;
  final int _pageSize = 10;

  @override
  void initState() {
    super.initState();
    _loadProducts();
    _scrollController.addListener(_scrollListener);
  }

  void _scrollListener() {
    if (_scrollController.position.pixels ==
        _scrollController.position.maxScrollExtent) {
      _loadMoreProducts();
    }
  }

  Future<void> _loadProducts() async {
    setState(() {
      _isLoading = true;
    });
    
    // 网络请求
    await Future.delayed(Duration(seconds: 1));
    
    final newProducts = List.generate(_pageSize, (index) => Product(
      id: '${_currentPage}_$index',
      name: '商品 ${_currentPage * _pageSize + index + 1}',
      description: '商品的详细描述',
      price: 99.99 + index * 10,
      originalPrice: 199.99 + index * 10,
      imageUrl: 'https://picsum.photos/200/200?random=${_currentPage * _pageSize + index}',
      rating: 3.5 + (index % 5) * 0.5,
      reviewCount: 100 + index * 10,
    ));
    
    setState(() {
      _products.addAll(newProducts);
      _isLoading = false;
      _currentPage++;
    });
  }

  Future<void> _loadMoreProducts() async {
    if (_isLoading) return;
    await _loadProducts();
  }

  void _toggleFavorite(int index) {
    setState(() {
      _products[index] = _products[index].copyWith(
        isFavorite: !_products[index].isFavorite,
      );
    });
  }

  void _onProductTap(int index) {
    final product = _products[index];
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => ProductDetailPage(product: product),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('商品列表'),
        actions: [
          IconButton(
            icon: Icon(Icons.search),
            onPressed: () {},
          ),
          IconButton(
            icon: Icon(Icons.filter_list),
            onPressed: () {},
          ),
        ],
      ),
      body: Column(
        children: [
          // 筛选栏
          _buildFilterBar(),
          // 商品列表
          Expanded(
            child: RefreshIndicator(
              onRefresh: _refreshProducts,
              child: ListView.builder(
                controller: _scrollController,
                itemCount: _products.length + 1,
                itemBuilder: (context, index) {
                  if (index == _products.length) {
                    return _buildLoadingIndicator();
                  }
                  return _buildProductItem(index);
                },
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildFilterBar() {
    return Container(
      padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      decoration: BoxDecoration(
        border: Border(bottom: BorderSide(color: Colors.grey[300]!)),
      ),
      child: Row(
        children: [
          _buildFilterChip('综合'),
          SizedBox(width: 8),
          _buildFilterChip('销量'),
          SizedBox(width: 8),
          _buildFilterChip('价格'),
          Spacer(),
          Text('${_products.length}件商品'),
        ],
      ),
    );
  }

  Widget _buildFilterChip(String label) {
    return FilterChip(
      label: Text(label),
      onSelected: (selected) {},
    );
  }

  Widget _buildProductItem(int index) {
    final product = _products[index];
    final discount = ((product.originalPrice - product.price) / 
                     product.originalPrice * 100).round();

    return Card(
      margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: InkWell(
        onTap: () => _onProductTap(index),
        child: Padding(
          padding: EdgeInsets.all(12),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              // 商品图片
              Stack(
                children: [
                  ClipRRect(
                    borderRadius: BorderRadius.circular(8),
                    child: Image.network(
                      product.imageUrl,
                      width: 100,
                      height: 100,
                      fit: BoxFit.cover,
                      errorBuilder: (context, error, stackTrace) {
                        return Container(
                          width: 100,
                          height: 100,
                          color: Colors.grey[200],
                          child: Icon(Icons.error),
                        );
                      },
                    ),
                  ),
                  if (discount > 0)
                    Positioned(
                      top: 0,
                      left: 0,
                      child: Container(
                        padding: EdgeInsets.symmetric(horizontal: 4, vertical: 2),
                        decoration: BoxDecoration(
                          color: Colors.red,
                          borderRadius: BorderRadius.only(
                            topLeft: Radius.circular(8),
                            bottomRight: Radius.circular(8),
                          ),
                        ),
                        child: Text(
                          '$discount%',
                          style: TextStyle(
                            color: Colors.white,
                            fontSize: 12,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ),
                    ),
                ],
              ),
              SizedBox(width: 12),
              // 商品信息
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      product.name,
                      style: TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.bold,
                      ),
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                    ),
                    SizedBox(height: 4),
                    Text(
                      product.description,
                      style: TextStyle(
                        fontSize: 14,
                        color: Colors.grey[600],
                      ),
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                    ),
                    SizedBox(height: 8),
                    // 评分和评论
                    Row(
                      children: [
                        _buildRatingStars(product.rating),
                        SizedBox(width: 4),
                        Text(
                          product.rating.toStringAsFixed(1),
                          style: TextStyle(fontSize: 12),
                        ),
                        SizedBox(width: 4),
                        Text(
                          '(${product.reviewCount})',
                          style: TextStyle(
                            fontSize: 12,
                            color: Colors.grey[600],
                          ),
                        ),
                      ],
                    ),
                    SizedBox(height: 8),
                    // 价格信息
                    Row(
                      children: [
                        Text(
                          ${product.price.toStringAsFixed(2)}',
                          style: TextStyle(
                            fontSize: 18,
                            fontWeight: FontWeight.bold,
                            color: Colors.red,
                          ),
                        ),
                        SizedBox(width: 8),
                        if (product.originalPrice > product.price)
                          Text(
                            ${product.originalPrice.toStringAsFixed(2)}',
                            style: TextStyle(
                              fontSize: 14,
                              color: Colors.grey,
                              decoration: TextDecoration.lineThrough,
                            ),
                          ),
                      ],
                    ),
                  ],
                ),
              ),
              // 收藏按钮
              IconButton(
                icon: Icon(
                  product.isFavorite ? Icons.favorite : Icons.favorite_border,
                  color: product.isFavorite ? Colors.red : Colors.grey,
                ),
                onPressed: () => _toggleFavorite(index),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildRatingStars(double rating) {
    return Row(
      children: List.generate(5, (index) {
        final starRating = index + 1.0;
        return Icon(
          starRating <= rating
              ? Icons.star
              : starRating - 0.5 <= rating
                  ? Icons.star_half
                  : Icons.star_border,
          color: Colors.amber,
          size: 16,
        );
      }),
    );
  }

  Widget _buildLoadingIndicator() {
    return _isLoading
        ? Padding(
            padding: EdgeInsets.all(16),
            child: Center(
              child: CircularProgressIndicator(),
            ),
          )
        : SizedBox();
  }

  Future<void> _refreshProducts() async {
    _currentPage = 1;
    _products.clear();
    await _loadProducts();
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }
}

电商列表页面架构图:

graph TB
    A[ProductListPage] --> B[AppBar]
    A --> C[Column]
    C --> D[FilterBar]
    C --> E[Expanded]
    E --> F[RefreshIndicator]
    F --> G[ListView.builder]
    
    G --> H[商品卡片]
    H --> I[商品图片]
    H --> J[商品信息]
    H --> K[收藏按钮]
    
    J --> L[商品标题]
    J --> M[商品描述]
    J --> N[评分组件]
    J --> O[价格显示]
    
    subgraph "状态管理"
        P[产品列表]
        Q[加载状态]
        R[分页控制]
    end

7. 组件性能监控与优化

7.1 性能分析工具使用

Flutter提供了丰富的性能分析工具来监控组件性能。

性能分析:

工具名称 主要功能 使用场景
Flutter DevTools 综合性能分析 开发阶段性能调试
Performance Overlay 实时性能覆盖层 UI性能监控
Timeline 帧时间线分析 渲染性能优化
Memory Profiler 内存使用分析 内存泄漏检测

7.2 性能优化技巧

// 示例
class OptimizedProductList extends StatelessWidget {
  final List<Product> products;
  final ValueChanged<int> onProductTap;
  final ValueChanged<int> onFavoriteToggle;

  const OptimizedProductList({
    Key? key,
    required this.products,
    required this.onProductTap,
    required this.onFavoriteToggle,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: products.length,
      // 为每个列表项提供唯一key
      itemBuilder: (context, index) {
        return ProductItem(
          key: ValueKey(products[index].id), // 优化列表diff
          product: products[index],
          onTap: () => onProductTap(index),
          onFavoriteToggle: () => onFavoriteToggle(index),
        );
      },
    );
  }
}

// 使用const优化的商品项组件
class ProductItem extends StatelessWidget {
  final Product product;
  final VoidCallback onTap;
  final VoidCallback onFavoriteToggle;

  const ProductItem({
    Key? key,
    required this.product,
    required this.onTap,
    required this.onFavoriteToggle,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const RepaintBoundary( // 隔离重绘区域
      child: ProductItemContent(
        product: product,
        onTap: onTap,
        onFavoriteToggle: onFavoriteToggle,
      ),
    );
  }
}

// 使用const构造函数的内容组件
class ProductItemContent extends StatelessWidget {
  const ProductItemContent({
    Key? key,
    required this.product,
    required this.onTap,
    required this.onFavoriteToggle,
  }) : super(key: key);

  final Product product;
  final VoidCallback onTap;
  final VoidCallback onFavoriteToggle;

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: onTap,
      child: Padding(
        padding: const EdgeInsets.all(12.0),
        child: Row(
          children: [
            const CachedProductImage(imageUrl: product.imageUrl),
            const SizedBox(width: 12),
            const Expanded(
              child: ProductInfo(product: product),
            ),
            const FavoriteButton(
              isFavorite: product.isFavorite,
              onToggle: onFavoriteToggle,
            ),
          ],
        ),
      ),
    );
  }
}

8. 总结

8.1 核心知识点回顾

通过本篇文章,我们系统学习了Flutter UI组件库的各个方面:

Material Design组件体系:

  • 理解了Material Design的实现原理
  • 掌握了Scaffold、Container等核心布局组件
  • 学会了表单验证和复杂列表的实现

Cupertino风格组件:

  • 了解了iOS设计规范与实现
  • 掌握了平台自适应开发模式

第三方组件库:

  • 第三方库评估标准
  • 掌握了状态管理库的集成使用
  • 了解了流行UI扩展库的应用场景

自定义组件开发:

  • 学会了组件设计的方法论
  • 掌握了自定义绘制和动画实现
  • 理解了组件性能优化的手段

8.2 实际开发建议

组件选择策略:

  1. 优先使用官方组件,保证稳定性和性能
  2. 谨慎选择第三方库,选择前先评估
  3. 适时开发自定义组件

性能优化原则:

  1. 合理使用const构造函数减少重建
  2. 为列表项提供唯一Key优化diff算法
  3. 使用RepaintBoundary隔离重绘区域
  4. 避免在build方法中执行耗时操作

如果觉得这篇文章对你有帮助,请点赞、关注、收藏支持一下!!! 你的支持是我持续创作优质内容的最大动力! 有任何问题欢迎在评论区留言讨论,我会及时回复解答。

What Auto Layout Doesn’t Allow

日常使用Snapkit做约束时经常会遇到约束更新错误或者不允许的操作,本文记录一下Autolayout中哪些操作是不被允许的

前置知识

Size Attributes and Location Attributes

Autolayout中Attributes分为两类:Size Attributes 和 Location Attributes

  • Size Attributes:表示的是尺寸信息,有Width、Height
  • Location Attributes:表示位置信息,有Leading、Left、Trailing、Right、Top、Bottom、Cente(x、y)、Baseline等等

Autolayout的本质

Autolayout的本质是如下形式的等式或不等式:

Item1.Attribute1 [Relationship] Multiplier * Item2.Attribute2 + Constant

Relationship可以是等式,比如:

  • View1.leading = 1.0 * View2.leading + 0
  • View1.width = 1.0 * View2.width + 20

也可以是不等式,比如:

View1.leading >= 1.0 * View2.trailing + 0

需要注意的是式子两边不总是有Attribute,如下所示:

  • View.height = 0.0 * NotAnAttribute + 40.0(✅)
  • View1.height = 0.5 * View2.height(✅)

上述两个约束都是正确的

规则

1. Size Attribute与Location Attribute之间不能做约束

原文:You cannot constrain a size attribute to a location attribute.

错误举例:View1.width = 1.0 * View2.trailing + 0(❌)

2. 不能对Location Attribute设置常量

原文:You cannot assign constant values to location attributes.

错误举例:View1.leading = 0.0 * NotAnAttribute + 20(❌)

3. Location Attribute中不能使用非1的mutiplier

You cannot use a nonidentity multiplier (a value other than 1.0) with location attributes.

错误举例:View1.leading = 0.5 * View2.leading + 20(❌)

4. Location Attribute中不同方向之间不能做约束

For location attributes, you cannot constrain vertical attributes to horizontal attributes.

错误举例:View1.centerX = 1 * View2.centerY + 20(❌)

5. Leading(Trailing)不能与Left(Trailing)做约束

For location attributes, you cannot constrain Leading or Trailing attributes to Left or Right attributes.

错误举例:View1.centerX = 1 * View2.centerY + 20(❌)

修改Constraints需要注意什么

除了上述规则外,在运行时也可能会更新约束,有如下这些支持的修改约束操作:

  • 激活/停用约束(Activating or deactivating a constraint)
  • 修改约束的常量部分(Changing the constraint’s constant value)
  • 修改约束的优先级(Changing the constraint’s priority)
  • 将视图从页面层级中移除(Removing a view from the view hierarchy)

SwiftUI动画之使用 navigationTransition(.zoom) 实现 Hero 动画

使用navigationTransition(.zoom(sourceID:in:))在 SwiftUI 中实现 Hero 式放大过渡

SwiftUI iOS 17 带来了新的导航过渡系统。本文将带你学习如何使用 navigationTransition(.zoom(sourceID:in:)) 实现类似 Hero 动画的平滑放大效果。


✨ 简介

在 UIKit 时代,想要实现一个“列表 → 详情页”的放大过渡动画,往往需要复杂的自定义转场或者第三方库 Hero。而从 iOS 17 开始,SwiftUI 提供了新的导航过渡 API,使得这一切都能以极少的代码实现。

navigationTransition(.zoom(sourceID:in:)) 是 Apple 新增的 API,它允许我们在两个导航页面间创建共享元素(Shared Element)动画。源视图与目标视图使用相同的 sourceID,系统就能自动识别并生成缩放过渡。


🧩 实现思路

  1. 定义一个 @Namespace 来管理共享动画空间。

  2. 在源视图(比如卡片)与目标视图(详情页)上使用相同的 id。

  3. 通过 .navigationTransition(.zoom(sourceID:in:)) 声明使用 zoom 过渡。

系统会自动在两个页面间平滑地缩放、移动这两个匹配的视图,形成 Hero 式的过渡体验。


💻 完整示例代码

import SwiftUI

struct ZoomHeroExample: View {
    @Namespace private var ns
    @State private var selected: Item? = nil
    
    let items: [Item] = [
        Item(id: "1", color: .pink, title: "粉红"),
        Item(id: "2", color: .blue, title: "湛蓝"),
        Item(id: "3", color: .orange, title: "橙色")
    ]
    
    var body: some View {
        
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: [.init(.adaptive(minimum: 120))], spacing: 12) {
                    ForEach(items) { item in
                        RoundedRectangle(cornerRadius: 12)
                            .fill(item.color)
                            .frame(height: 140)
                            .overlay(Text(item.title).foregroundColor(.white).bold())
                            .matchedTransitionSource(id: "card-" + item.id, in: ns)
                            .onTapGesture { selected = item }
                    }
                }
                .padding()
            }
            .navigationDestination(item: $selected) { item in
                // 添加 zoom 过渡
                DetailView(item: item, namespace: ns)
                    .navigationTransition(.zoom(sourceID: "card-" + item.id, in: ns))
            }
            .navigationTitle("颜色卡片")
        }
    }
}

struct DetailView: View {
    let item: Item
    let namespace: Namespace.ID
    
    var body: some View {
        VStack {
            RoundedRectangle(cornerRadius: 24)
                .fill(item.color)
                .frame(height: 420)
                .padding()
            
            Text(item.title)
                .font(.largeTitle)
                .bold()
            
            Spacer()
        }
        .navigationBarTitleDisplayMode(.inline)
    }
}

struct Item: Identifiable, Hashable {
    let id: String
    let color: Color
    let title: String
}


#Preview {
    ZoomHeroExample()
}

🎬 动画效果说明

当用户点击某个卡片:

  1. 源视图与目标视图通过相同的 sourceID 匹配。

  2. 系统自动计算两者在坐标空间中的差异。

  3. SwiftUI 执行一个 .zoom 类型的放大过渡动画,使卡片平滑地扩展到详情页。

这与 Hero 库的核心概念完全一致,但现在由 SwiftUI 原生支持。


⚙️ 常见问题

❓ 为什么动画没生效?

  • 确认源视图和目标视图的 id 一致。

  • 确保使用的是 NavigationStack 而不是旧的 NavigationView。

  • 确认系统版本 ≥ iOS 17。

⚠️ 视图闪烁或跳动?

  • 避免动态尺寸变化过大的布局。
  • 给匹配元素一个固定的 .frame() 可增强过渡的稳定性。

🔍 与matchedGeometryEffect的对比

特性 matchedGeometryEffect navigationTransition(.zoom)
匹配方式 Namespace + ID sourceID + Namespace
场景 任意动画、自定义匹配 导航过渡专用
代码复杂度 较高 极简
稳定性 手动控制 系统托管

简言之:在导航场景下优先使用 navigationTransition,其它复杂动画仍可用 matchedGeometryEffect。


💡 延伸思考

如果你想自定义更多转场样式,比如滑动、淡入淡出,可以尝试:

.navigationTransition(.slide(sourceID: "card-" + $0.id, in: ns))

或:

.navigationTransition(.zoom(sourceID: "card-" + $0.id, in: ns).combined(with: .opacity))

SwiftUI 让多个过渡组合成为可能,且依旧保持声明式风格。


🧭 总结

通过 navigationTransition(.zoom(sourceID:in:)),我们可以在 SwiftUI 中轻松实现 Hero 式放大动画。它不仅简化了过渡代码,还 seamlessly 与 NavigationStack 集成。

一句话总结:

从此以后,Hero 动画在 SwiftUI 中,不再需要 Hero。


参考链接:

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

本文概述

本文是 HarfBuzz 系列的完结篇。

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

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

1)第一篇:HarfBuzz概览

2)第二篇:HarfBuzz核心概念

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

一、hb-blob

1)定义

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

2)hb_blob_create

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

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

使用示例:

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

3)hb_blob_create_from_file

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

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

使用示例:

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

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

二、hb-face

1)定义

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

2)hb_face_create

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

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

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

3)hb_face_reference

hb_face_t的引用计数 +1

hb_face_t *
hb_face_reference (hb_face_t *face);

3)hb_face_destroy

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

void
hb_face_destroy (hb_face_t *face);

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

4)hb_face_get_upem

获取字体的upem。

unsigned int
hb_face_get_upem (const hb_face_t *face);

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

5)hb_face_reference_table

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

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

使用示例:

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

6)hb_face_collect_unicodes

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

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

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

使用示例:

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

三、hb-font

1)定义

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

2)hb_font_create & hb_font_reference & hb_font_destroy

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

3)hb_font_get_glyph_advance_for_direction

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

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

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

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

使用示例:

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

4)hb_font_set_ptem & hb_font_get_ptem

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

void
hb_font_set_ptem (hb_font_t *font,
                  float ptem);

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

使用示例:

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

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

Q:什么是 26.6 整数格式?

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

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

换算规则:2^6 = 64

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

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

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

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

5)hb_font_get_glyph

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

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

在这里插入图片描述

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

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

四、hb-buffer

1)定义

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

2)hb_buffer_create & hb_buffer_reference & hb_buffer_destroy

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

3)hb_buffer_add_utf8 & hb_buffer_add_utf16 & hb_buffer_add_utf32

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

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

使用示例:

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

4)hb_buffer_guess_segment_properties

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

void
hb_buffer_guess_segment_properties (hb_buffer_t *buffer);

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

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

五、hb-shape

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

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

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

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

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

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

hb_buffer_get_glyph_infos 签名如下:

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

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

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

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

这里需要展开介绍下cluster:

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

hb_buffer_get_glyph_positions 的签名如下:

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

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

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

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

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

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

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

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

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

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

六、完整示例

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

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

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

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

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

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

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

    hb_font_set_synthetic_slant(font, 1.0)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

概述

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

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

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

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

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

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

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

了解 String Catalog

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

核心概念

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

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

优势与特性

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

工作原理与编译机制

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

具体来说:

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

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

image.png


创建与使用指南

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

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

迁移现有项目

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

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

添加新语言支持

在 String Catalog 编辑器中:

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

关于文件命名

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

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

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

参考文献

探索字符串目录

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

我的第一款 iOS App:PingMind

一直觉得有一套合适的反思系统很重要,相关的思考可以参见上一篇文章。于是开始寻找合适的解决方案,但都没有太满意的。既然这套系统具有重要性,而市面上又没有合适的解决方案,那就很有必要自己实现一个,于是就有了 PingMind 这个 app。

Gallery image 1
Gallery image 2
Gallery image 3
Gallery image 4
Gallery image 5

具体的 features 可以参见官网,简单来说就是可以创建每日/周/月的自省问题,并进行记录,设计上尽量保障输入和回顾的便捷性。创建的内容保存在本地,使用 iCloud 同步,支持导出,不需要创建账号,开箱即用。价格上采用 IAP(In App Purchase),可以免费使用大部分功能,购买后解锁全部功能,不需要订阅。

如果你对自省感兴趣,或者也在寻找合适的自省 App,或许可以试试 PingMind。使用过程中有任何的建议和反馈,欢迎留言,或邮件与我联系。

Combine 基本使用指南

Combine 基本使用指南

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

核心概念

1. Publisher(发布者)

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

2. Subscriber(订阅者)

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

3. Operator(操作符)

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

基本使用示例

创建简单的 Publisher

import Combine

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

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

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

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

订阅 Publisher

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

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

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

常用操作符

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

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

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

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

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

处理 UI 更新

import UIKit
import Combine

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

网络请求示例

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

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

定时器示例

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

内存管理

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

最佳实践

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

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

iOS语音转换SDK相关记录

前言:

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

一、基础:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • 容易引起崩溃的点:

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

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

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

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

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

截屏2025-10-30 20.53.52.png

三、优化相关:

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

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

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

背景

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

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

简单来说:

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

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

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

相关推荐

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

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

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

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

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

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

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

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

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

问题

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

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

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

思路

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

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

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

  SemaphoreTask(this._maxCount);

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

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

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

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

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

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

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

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

解决

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

final semaphore = SemaphoreTask(1);

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

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

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

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

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

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

结果

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

并发控制

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

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

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

iOS 进阶6-Voip通信

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  3. 实现 PKPushRegistryDelegate 协议:

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

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

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

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

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

  • 权限配置

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

      xml

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

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

swift

import PushKit

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

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

swift

import CallKit

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

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

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

swift

import AgoraRtcKit

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

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

1. 音频质量优化

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

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

2. 后台保活与稳定性

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

3. 跨设备状态同步

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

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

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

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

五、常见问题与解决方案

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

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

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

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

总结

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

iOS - UIViewController 生命周期

核心概念

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

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

UIViewController

1. 初始化阶段

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

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

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

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

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

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

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

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

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

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

3. 其他(不太常用)

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

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

1. 初始化

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

2. 常用

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

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

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

3. 其他(不太常用)

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

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

回调顺序:

1. VC 的切换

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

2. VC 与 View 的交叉

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

2. viewB - willMoveToSuperview
3. viewB - didMoveToSuperview

// 出现 view
1. VC - viewWillAppear

2. viewB - willMoveToWindow
3. viewB - didMoveToWindow

4. VC - viewWillLayoutSubviews
5. VC - viewDidLayoutSubviews

6. viewB - layoutSubviews

7. VC - viewDidAppear

疑问:

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

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

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

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

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


📘 一、准备条件

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

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

🧭 二、理解签名机制

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

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

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

🛠️ 三、创建 iOS Distribution 证书

1️⃣ 登录 Apple Developer

🔗 developer.apple.com/account/res…

点击右上角的 ➕ 按钮:

image.png


2️⃣ 选择证书类型

在证书类型列表中选择:

Production → App Store and Ad Hoc

点击「Continue」。


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

打开 钥匙串访问(Keychain Access)

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

点击菜单:

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

填写信息:

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

📸 示意图(示例):

image.png


4️⃣ 上传 CSR 文件到 Apple Developer

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

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


5️⃣ 导入证书到钥匙串

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

image.png

在「钥匙串访问」搜索:

iPhone Distribution

若显示即表示导入成功 ✅


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

1️⃣ 打开 Profile 页面

🔗 developer.apple.com/account/res…

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


2️⃣ 选择 Profile 类型

选择:

App Store → App Store Connect Distribution

点击「Continue」。

image.png


3️⃣ 选择 App ID

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

点击「Continue」。


4️⃣ 选择 Distribution 证书

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


5️⃣ 命名与生成

输入名称:

YanhuoShijian_AppStore_Profile

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


6️⃣ 导入 Xcode

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


🧱 五、在 Xcode 中配置签名

打开 Flutter 项目的 iOS 工程:

open ios/Runner.xcworkspace

在 Xcode 中:

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

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

📸 示意图:

image.png


🧪 六、打包验证

执行命令:

flutter build ipa --release

如果成功,会输出:

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

🩺 七、常见问题排查

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

☁️ 八、上传到 App Store Connect

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


✅ 九、总结

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

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

❌