阅读视图

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

《Flutter全栈开发实战指南:从零到高级》- 06 -常用布局组件

Flutter常用布局

1. 引言:为什么布局系统如此重要?

比方说你要装修一间房子:你需要规划每个房间的位置、大小,考虑家具的摆放,确保空间利用合理且美观。Flutter的布局系统就是你在数字世界中的"室内设计师",它决定了每个UI元素的位置、大小和相互关系。

一个好的布局应该具备:

  • 精确的元素定位
  • 兼容自适应屏幕
  • 渲染性能高效
  • 视觉层次美观

今天就带你详细介绍Flutter的常用布局,让你熟练掌握布局系统~~~

2. Container:万能的布局容器

它是最基础也是最强大的布局组件之一。 71bbb7f5f8f06c5f6fe93e939eee55b8.png

2.1 Container的基本用法

Container(
  width: 200,                    // 设置宽度
  height: 100,                   // 设置高度
  color: Colors.blue,            // 背景颜色
  child: Text('Hello Flutter'),  // 子组件
)

这就像给文字套上了一个蓝色的相框,简单直接。

2.2 Container的装饰功能

但Container的真正威力在于它的装饰能力:

Container(
  width: 200,
  height: 100,
  decoration: BoxDecoration(
    color: Colors.white,                     // 背景色
    borderRadius: BorderRadius.circular(16), // 圆角
    boxShadow: [                             // 阴影
      BoxShadow(
        color: Colors.black12,
        blurRadius: 10,
        offset: Offset(0, 4),
      ),
    ],
    border: Border.all(                    // 边框
      color: Colors.blue,
      width: 2,
    ),
    gradient: LinearGradient(              // 渐变背景
      colors: [Colors.blue, Colors.purple],
      begin: Alignment.topLeft,
      end: Alignment.bottomRight,
    ),
  ),
  child: Center(
    child: Text(
      '精美的容器',
      style: TextStyle(
        color: Colors.white,
        fontWeight: FontWeight.bold,
      ),
    ),
  ),
)

Container的核心属性:

  • width / height:控制尺寸
  • margin:外边距,与其他组件的距离
  • padding:内边距,内容与边框的距离
  • decoration:装饰效果(颜色、边框、阴影等)
  • constraints:尺寸约束

2.3 实际应用场景

下面以具体的实际开发场景为例,带大家深入了解Container组件

场景1:用户头像容器

Container(
  width: 80,
  height: 80,
  decoration: BoxDecoration(
    color: Colors.grey[200],
    borderRadius: BorderRadius.circular(40), // 圆形
    border: Border.all(color: Colors.blue, width: 2),
    image: DecorationImage(
      image: NetworkImage('https://example.com/avatar.jpg'),
      fit: BoxFit.cover,
    ),
  ),
)

场景2:消息气泡

Container(
  constraints: BoxConstraints(
    maxWidth: 250,  // 最大宽度限制
  ),
  padding: EdgeInsets.all(12),
  decoration: BoxDecoration(
    color: Colors.blue[50],
    borderRadius: BorderRadius.only(
      topLeft: Radius.circular(16),
      topRight: Radius.circular(16),
      bottomRight: Radius.circular(4),
    ),
  ),
  child: Text('这是一条消息内容'),
)

3. Padding和Margin

Padding和Margin就像人与人之间的安全距离,它们控制着组件之间的空间关系,但作用对象不同。

3.1 Padding:内部空间

Padding是组件内容与边框之间的距离,好比相框与照片之间的留白:

Container(
  color: Colors.blue,
  child: Padding(
    padding: EdgeInsets.all(16),  // 四周都留16像素的空白
    child: Text(
      '有呼吸空间的文字',
      style: TextStyle(color: Colors.white),
    ),
  ),
)

EdgeInsets的四种用法:

// 1. 统一间距
EdgeInsets.all(16)

// 2. 分别设置上下左右
EdgeInsets.fromLTRB(10, 20, 10, 20)

// 3. 设置水平和垂直
EdgeInsets.symmetric(horizontal: 10, vertical: 20)

// 4. 只设置一边
EdgeInsets.only(left: 10, top: 5)

3.2 Margin:外部安全距离

Margin是组件与其他组件之间的距离,就像两个人谈话时的舒适距离:

Container(
  width: 100,
  height: 100,
  color: Colors.red,
  margin: EdgeInsets.all(20),  // 四周都保持20像素的距离
  child: Text('我有外边距'),
)

3.3 实际应用:卡片布局

Container(
  margin: EdgeInsets.all(16),      // 卡片与其他组件的距离
  padding: EdgeInsets.all(20),     // 卡片内容与边框的距离
  decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.circular(12),
    boxShadow: [
      BoxShadow(
        color: Colors.black12,
        blurRadius: 8,
      ),
    ],
  ),
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text(
        '产品标题',
        style: TextStyle(
          fontSize: 18,
          fontWeight: FontWeight.bold,
        ),
      ),
      SizedBox(height: 8),         // 文字之间的间距
      Text('产品描述信息...'),
    ],
  ),
)

4. Row和Column:线性布局

Row是横向布局,Column是竖向布局。它们让组件按照线性方式排列,是使用频率最高的布局组件。

4.1 Row:水平排列

Row让子组件水平排列,就像我们生活中排队买票的人群: 97750a37af503adc2ed73b58323a389f.png

Row(
  children: [
    Icon(Icons.star, color: Colors.orange),
    Icon(Icons.star, color: Colors.orange),
    Icon(Icons.star, color: Colors.orange),
    Icon(Icons.star_border, color: Colors.grey),
    Icon(Icons.star_border, color: Colors.grey),
  ],
)

Row的核心属性:

  • mainAxisAlignment:主轴对齐方式(水平方向)
  • crossAxisAlignment:交叉轴对齐方式(垂直方向)
  • mainAxisSize:主轴尺寸

4.2 主轴对齐方式

Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween, // 两端对齐,均匀分布
  children: [
    Container(width: 50, height: 50, color: Colors.red),
    Container(width: 50, height: 50, color: Colors.green),
    Container(width: 50, height: 50, color: Colors.blue),
  ],
)

MainAxisAlignment的选项:

  • start:左对齐
  • end:右对齐
  • center:居中对齐
  • spaceBetween:两端对齐,组件间隔相等
  • spaceAround:每个组件两侧间隔相等
  • spaceEvenly:组件间隔与边框间隔都相等

4.3 Column:垂直排列

Column让子组件垂直排列,就像叠放的一摞书籍: f397e34988953bbbdb8ad0d9a937f65d.png

Column(
  crossAxisAlignment: CrossAxisAlignment.start, // 左对齐
  children: [
    Text('标题', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
    SizedBox(height: 8),
    Text('副标题', style: TextStyle(fontSize: 16, color: Colors.grey)),
    SizedBox(height: 16),
    Text('内容描述...'),
  ],
)

4.4 实际应用:用户信息卡片

Row(
  children: [
    // 头像
    Container(
      width: 60,
      height: 60,
      decoration: BoxDecoration(
        color: Colors.grey[300],
        borderRadius: BorderRadius.circular(30),
      ),
      child: Icon(Icons.person, size: 30, color: Colors.grey[600]),
    ),
    
    // 间距
    SizedBox(width: 16),
    
    // 用户信息
    Expanded(  // 占据剩余空间
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('张小明', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
          SizedBox(height: 4),
          Text('高级用户体验设计师', style: TextStyle(color: Colors.grey[600])),
          SizedBox(height: 4),
          Text('2小时前在线', style: TextStyle(color: Colors.green, fontSize: 12)),
        ],
      ),
    ),
    
    // 右侧图标
    Icon(Icons.chevron_right, color: Colors.grey[400]),
  ],
)

5. Flex和Expanded:弹性布局

举个例子:Flex和Expanded就像弹簧和橡皮筋,它们让布局具有弹性,能够根据可用空间自动调整。

5.1 Flex布局基础

Flex是Row和Column的父类,提供了更灵活的布局方式: f6851a8990489db85a134b5058f86981.png

Flex(
  direction: Axis.horizontal,  // 水平排列,相当于Row
  children: [
    // 子组件
  ],
)

5.2 Expanded:占据剩余空间

Expanded让子组件占据剩余空间,就像弹簧可以拉伸:

Row(
  children: [
    Container(
      width: 80,
      height: 50,
      color: Colors.red,
    ),
    Expanded(  // 占据剩余的所有水平空间
      child: Container(
        height: 50,
        color: Colors.blue,
        child: Center(child: Text('弹性区域')),
      ),
    ),
  ],
)

5.3 Flexible:尺寸控制

Flexible提供更精细的弹性控制:

Row(
  children: [
    Flexible(
      flex: 1,  // 权重为1
      child: Container(height: 50, color: Colors.red),
    ),
    Flexible(
      flex: 2,  // 权重为2,占据两倍的空间
      child: Container(height: 50, color: Colors.green),
    ),
    Flexible(
      flex: 1,  // 权重为1
      child: Container(height: 50, color: Colors.blue),
    ),
  ],
)

Flexible vs Expanded:

  • Expanded是Flexible(fit: FlexFit.tight)的简写
  • Flexible默认是FlexFit.loose,子组件可以选择不填满空间
  • Expanded强制子组件填满空间

5.4 实际应用:比例布局

Column(
  children: [
    // 标题栏
    Container(
      height: 60,
      color: Colors.blue,
      child: Center(child: Text('仪表盘', style: TextStyle(color: Colors.white))),
    ),
    
    // 内容区域(占据剩余空间)
    Expanded(
      child: Row(
        children: [
          // 侧边栏(固定宽度)
          Container(
            width: 200,
            color: Colors.grey[100],
            child: ListView(
              children: [
                ListTile(title: Text('菜单项1')),
                ListTile(title: Text('菜单项2')),
                ListTile(title: Text('菜单项3')),
              ],
            ),
          ),
          
          // 主内容区(占据剩余空间)
          Expanded(
            child: Container(
              color: Colors.white,
              child: Center(child: Text('主内容区域')),
            ),
          ),
        ],
      ),
    ),
    
    // 底部栏
    Container(
      height: 50,
      color: Colors.grey[800],
      child: Center(child: Text('版权所有 © 2024', style: TextStyle(color: Colors.white))),
    ),
  ],
)

6. Stack:层叠布局

Stack好比透明的幻灯片,可以让多个组件重叠在一起,组合出丰富的页面视觉效果。 f4cc635cca69f396ef656e89d5e5a1d0.png

6.1 Stack基础用法

Stack(
  children: [
    // 底层背景
    Container(
      width: 200,
      height: 200,
      color: Colors.blue,
    ),
    
    // 中间层
    Positioned(
      top: 20,
      left: 20,
      child: Container(
        width: 100,
        height: 100,
        color: Colors.red,
      ),
    ),
    
    // 顶层
    Positioned(
      bottom: 20,
      right: 20,
      child: Container(
        width: 80,
        height: 80,
        color: Colors.green,
      ),
    ),
  ],
)

6.2 Positioned:精确定位

Positioned用于在Stack中精确定位子组件:

Positioned(
  top: 10,      // 距离顶部10像素
  left: 20,     // 距离左边20像素
  right: 30,    // 距离右边30像素
  bottom: 40,   // 距离底部40像素
  child: Container(color: Colors.orange),
)

6.3 Alignment:相对定位

除了Positioned,还可以使用Alignment进行相对定位:

Stack(
  alignment: Alignment.center,  // 所有子组件默认居中对齐
  children: [
    Container(width: 200, height: 200, color: Colors.blue),
    Container(
      width: 100,
      height: 100,
      color: Colors.red,
    ),
    Align(  // 单独设置对齐方式
      alignment: Alignment.bottomRight,
      child: Container(
        width: 50,
        height: 50,
        color: Colors.green,
      ),
    ),
  ],
)

6.4 实际应用:用户头像徽章

Stack(
  children: [
    // 用户头像
    Container(
      width: 80,
      height: 80,
      decoration: BoxDecoration(
        color: Colors.grey[300],
        borderRadius: BorderRadius.circular(40),
      ),
      child: Icon(Icons.person, size: 40, color: Colors.grey[600]),
    ),
    
    // 在线状态指示器
    Positioned(
      bottom: 0,
      right: 0,
      child: Container(
        width: 20,
        height: 20,
        decoration: BoxDecoration(
          color: Colors.green,
          borderRadius: BorderRadius.circular(10),
          border: Border.all(color: Colors.white, width: 2),
        ),
      ),
    ),
    
    // VIP徽章
    Positioned(
      top: 0,
      right: 0,
      child: Container(
        padding: EdgeInsets.all(4),
        decoration: BoxDecoration(
          color: Colors.orange,
          borderRadius: BorderRadius.circular(8),
        ),
        child: Text(
          'VIP',
          style: TextStyle(
            color: Colors.white,
            fontSize: 10,
            fontWeight: FontWeight.bold,
          ),
        ),
      ),
    ),
  ],
)

7. 响应式布局设计:适配各种屏幕

响应式布局能够根据不同的屏幕尺寸自动调整布局,提升用户体验。

7.1 MediaQuery:获取屏幕信息

MediaQuery可以获取屏幕尺寸、方向等信息:

class ResponsiveLayout extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 获取屏幕尺寸
    final screenWidth = MediaQuery.of(context).size.width;
    final screenHeight = MediaQuery.of(context).size.height;
    
    // 判断屏幕方向
    final isPortrait = MediaQuery.of(context).orientation == Orientation.portrait;
    
    return Container(
      width: screenWidth,
      height: screenHeight,
      color: Colors.grey[200],
      child: Center(
        child: Text(
          '屏幕尺寸: ${screenWidth.toInt()} × ${screenHeight.toInt()}\n'
          '方向: ${isPortrait ? '竖屏' : '横屏'}',
          textAlign: TextAlign.center,
        ),
      ),
    );
  }
}

7.2 LayoutBuilder:根据约束调整布局

LayoutBuilder可以根据父组件的约束动态调整布局:

LayoutBuilder(
  builder: (context, constraints) {
    // 根据可用宽度决定布局方式
    if (constraints.maxWidth > 600) {
      // 宽屏布局
      return Row(
        children: [
          Container(width: 200, color: Colors.blue, child: Text('侧边栏')),
          Expanded(child: Container(color: Colors.green, child: Text('主内容'))),
        ],
      );
    } else {
      // 窄屏布局
      return Column(
        children: [
          Container(height: 100, color: Colors.blue, child: Text('顶部导航')),
          Expanded(child: Container(color: Colors.green, child: Text('主内容'))),
        ],
      );
    }
  },
)

7.3 实际应用:响应式仪表盘

class ResponsiveDashboard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('响应式仪表盘')),
      body: LayoutBuilder(
        builder: (context, constraints) {
          final isWideScreen = constraints.maxWidth > 768;
          
          return Row(
            children: [
              // 侧边栏(在宽屏显示,窄屏隐藏)
              if (isWideScreen)
                Container(
                  width: 250,
                  color: Colors.grey[100],
                  child: ListView(
                    children: [
                      ListTile(title: Text('仪表盘')),
                      ListTile(title: Text('用户管理')),
                      ListTile(title: Text('数据分析')),
                      ListTile(title: Text('系统设置')),
                    ],
                  ),
                ),
              
              // 主内容区域
              Expanded(
                child: Container(
                  padding: EdgeInsets.all(16),
                  child: GridView.count(
                    // 根据屏幕宽度调整列数
                    crossAxisCount: isWideScreen ? 3 : 2,
                    crossAxisSpacing: 16,
                    mainAxisSpacing: 16,
                    children: [
                      _buildStatCard('用户数', '1,234', Colors.blue),
                      _buildStatCard('订单数', '567', Colors.green),
                      _buildStatCard('收入', '\$8,901', Colors.orange),
                      _buildStatCard('增长率', '12.3%', Colors.purple),
                      _buildStatCard('满意度', '98%', Colors.red),
                      _buildStatCard('活跃度', '87%', Colors.teal),
                    ],
                  ),
                ),
              ),
            ],
          );
        },
      ),
      
      // 窄屏时显示底部导航
      bottomNavigationBar: constraints.maxWidth <= 768 ? BottomNavigationBar(
        items: [
          BottomNavigationBarItem(icon: Icon(Icons.dashboard), label: '首页'),
          BottomNavigationBarItem(icon: Icon(Icons.people), label: '用户'),
          BottomNavigationBarItem(icon: Icon(Icons.settings), label: '设置'),
        ],
      ) : null,
    );
  }
  
  Widget _buildStatCard(String title, String value, Color color) {
    return Container(
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(12),
        boxShadow: [
          BoxShadow(
            color: Colors.black12,
            blurRadius: 6,
            offset: Offset(0, 2),
          ),
        ],
      ),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(
            value,
            style: TextStyle(
              fontSize: 24,
              fontWeight: FontWeight.bold,
              color: color,
            ),
          ),
          SizedBox(height: 8),
          Text(
            title,
            style: TextStyle(
              color: Colors.grey[600],
            ),
          ),
        ],
      ),
    );
  }
}

8. 实战案例:用户资料卡片页面

让我们把所有知识融合起来,创建一个完整的用户资料卡片:

class UserProfileCard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.all(16),
      padding: EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(16),
        boxShadow: [
          BoxShadow(
            color: Colors.black12,
            blurRadius: 10,
            offset: Offset(0, 4),
          ),
        ],
      ),
      child: Column(
        children: [
          // 头部:头像和基本信息
          Row(
            children: [
              // 头像区域(带徽章)
              Stack(
                children: [
                  Container(
                    width: 80,
                    height: 80,
                    decoration: BoxDecoration(
                      color: Colors.blue[100],
                      borderRadius: BorderRadius.circular(40),
                      image: DecorationImage(
                        image: NetworkImage('https://example.com/avatar.jpg'),
                        fit: BoxFit.cover,
                      ),
                    ),
                  ),
                  // 在线状态
                  Positioned(
                    bottom: 0,
                    right: 0,
                    child: Container(
                      width: 20,
                      height: 20,
                      decoration: BoxDecoration(
                        color: Colors.green,
                        borderRadius: BorderRadius.circular(10),
                        border: Border.all(color: Colors.white, width: 2),
                      ),
                    ),
                  ),
                ],
              ),
              
              // 间距
              SizedBox(width: 16),
              
              // 用户信息
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      '张小明',
                      style: TextStyle(
                        fontSize: 20,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    SizedBox(height: 4),
                    Text(
                      '高级用户体验设计师',
                      style: TextStyle(
                        color: Colors.grey[600],
                      ),
                    ),
                    SizedBox(height: 8),
                    Row(
                      children: [
                        Icon(Icons.location_on, size: 16, color: Colors.grey),
                        SizedBox(width: 4),
                        Text(
                          '北京市海淀区',
                          style: TextStyle(fontSize: 12, color: Colors.grey),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
              
              // 更多操作按钮
              IconButton(
                icon: Icon(Icons.more_vert, color: Colors.grey),
                onPressed: () {},
              ),
            ],
          ),
          
          // 分隔线
          Padding(
            padding: EdgeInsets.symmetric(vertical: 16),
            child: Divider(height: 1, color: Colors.grey[300]),
          ),
          
          // 统计信息
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              _buildStatItem('关注', '234'),
              _buildStatItem('粉丝', '1.2k'),
              _buildStatItem('作品', '56'),
              _buildStatItem('点赞', '3.4k'),
            ],
          ),
          
          // 分隔线
          Padding(
            padding: EdgeInsets.symmetric(vertical: 16),
            child: Divider(height: 1, color: Colors.grey[300]),
          ),
          
          // 操作按钮
          Row(
            children: [
              Expanded(
                child: ElevatedButton(
                  onPressed: () {},
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.blue,
                    foregroundColor: Colors.white,
                  ),
                  child: Text('关注'),
                ),
              ),
              SizedBox(width: 12),
              Expanded(
                child: OutlinedButton(
                  onPressed: () {},
                  child: Text('发消息'),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
  
  Widget _buildStatItem(String label, String value) {
    return Column(
      children: [
        Text(
          value,
          style: TextStyle(
            fontSize: 16,
            fontWeight: FontWeight.bold,
          ),
        ),
        SizedBox(height: 4),
        Text(
          label,
          style: TextStyle(
            fontSize: 12,
            color: Colors.grey[600],
          ),
        ),
      ],
    );
  }
}

9. 性能优化与最佳实践

9.1 布局性能优化

  1. 避免过度嵌套

    // ❌ 不好的做法:过度嵌套
    Container(
      child: Padding(
        padding: EdgeInsets.all(10),
        child: Container(
          child: Center(
            child: Text('Hello'),
          ),
        ),
      ),
    )
    
    // ✅ 好的做法:使用Container的padding属性
    Container(
      padding: EdgeInsets.all(10),
      child: Center(
        child: Text('Hello'),
      ),
    )
    
  2. 使用const构造函数

    // ✅ 好的做法:使用const
    const Text('静态文本')
    
    // ❌ 不好的做法:不使用const
    Text('静态文本')
    

9.2 代码优化

  1. 提取重复布局

    // 提取为独立组件
    Widget _buildListItem(String title, String subtitle) {
      return ListTile(
        title: Text(title),
        subtitle: Text(subtitle),
        trailing: Icon(Icons.chevron_right),
      );
    }
    
  2. 使用扩展方法

    extension PaddingExtension on Widget {
      Widget withPadding(EdgeInsets padding) {
        return Padding(padding: padding, child: this);
      }
    }
    
    // 使用
    Text('Hello').withPadding(EdgeInsets.all(16))
    

当然还有很多其他优化的点,这里就不一一介绍了,需要大家花时间去一步步摸索尝试~

10. 知识点总结

1e81305d8cf662284c242d04e69e3050.png

通过今天的学习,我们掌握了Flutter布局系统的核心概念:

1. 基础容器:

  • Container:万能的布局容器,支持装饰效果
  • Padding:控制内部间距
  • Margin:控制外部间距

2. 线性布局:

  • Row:水平排列组件
  • Column:垂直排列组件
  • Flex / Expanded:弹性布局,按比例分配空间

3. 层叠布局:

  • Stack:组件重叠布局
  • Positioned:在Stack中精确定位
  • Align:相对对齐定位

4. 响应式设计:

  • MediaQuery:获取屏幕信息
  • LayoutBuilder:根据约束动态布局
  • 断点设计和方向适配

重点:布局设计思维

  1. 从外到内:先确定整体结构,再细化内部组件
  2. 优先使用简单布局:能用Row/Column解决的问题不要用复杂布局
  3. 考虑扩展性:设计时要考虑不同屏幕尺寸和内容变化
  4. 性能意识:避免过度嵌套,合理使用const

写在最后的话

好的布局就像好的建筑,不仅要美观,更要实用和稳固。

布局设计是一个需要不断练习和实践的过程。多观察优秀的App界面,思考它们的布局方式,然后用自己的代码实现出来。很快你就会发现,面对任何UI设计稿,你都能轻松地用Flutter实现出来!

如果这篇教程对你有帮助,请给我点个赞 👍 支持一下! 有什么布局方面的疑问?欢迎在评论区留言讨论~Happy Coding! ✨

Swift 方法全解:实例方法、mutating 方法与类型方法一本通

前言

官方文档已经把语法和规则写得足够严谨,但初学者常遇到三个卡点:

  1. 结构体/枚举居然也能定义方法?
  2. mutating 到底“变异”了什么?
  3. static 与 class 关键字在类型方法里的区别与实战意义。

方法(Method)到底是什么

一句话:方法是“挂在某个类型上的函数”。

  • 在 Swift 里,类(class)、结构体(struct)、枚举(enum)都能挂函数,有两大类,分别叫做实例方法或类型方法。

  • 与 C/Objective-C 不同,C 只有函数指针,Objective-C 只有类能定义方法;Swift 把“方法”能力下放到了值类型,带来了更灵活的建模方式。

实例方法(Instance Method)

  1. 定义与调用
class Counter1 {
    var count = 0
    
    // 实例方法:默认访问全部实例成员
    func increment() {
        count += 1
    }
    
    // 带参数的方法
    func increment(by amount: Int) {
        count += amount
    }
    
    func reset() {
        count = 0
    }
}

// 调用
let counter = Counter1()
counter.increment()          // 1
print(counter.count)
counter.increment(by: 5)     // 6
print(counter.count)
counter.reset()              // 0
print(counter.count)
  1. self 的隐式与显式
  • 不写 self:编译器默认你访问的是“当前实例”成员。
  • 必须写 self:局部变量/参数与属性重名时,用来消歧。
struct Point {
    var x = 0.0, y = 0.0
    
    func isToTheRightOf(x: Double) -> Bool {
        // 如果省略 self,x 会被当成参数 x
        return self.x > x
    }
}

值类型内部修改自身:mutating 实例方法

  1. 默认禁止修改

结构体/枚举是值类型,实例方法里不能改自己属性——除非加 mutating

  1. 加 mutating 后发生了什么
  • 方法被标记为“会改本体”,编译器会把调用处生成的 let 常量拦截掉。
  • 底层实现:方法拿到的是 inout self,可以整体替换。
struct Point: CustomStringConvertible {
    var description: String {
        "{ x: \(x), y: \(y)}"
    }
    
    var x = 0.0, y = 0.0
    
    // 移动自身
    mutating func moveBy(x deltaX: Double, y deltaY: Double) {
        x += deltaX
        y += deltaY
    }
    
    // 更激进的写法:直接给 self 赋新实例
    mutating func teleport(toX x: Double, toY y: Double) {
        self = Point(x: x, y: y)
    }
}

var p = Point(x: 1, y: 1)
print(p)
p.moveBy(x: 2, y: 3)   // 现在 (3,4)
print(p)
p.teleport(toX: 0, toY: 0) // 整体替换为 (0,0)
print(p)

// 以下会编译错误
let fixedPoint = Point(x: 3, y: 3)
print(fixedPoint)
// fixedPoint.moveBy(x: 1, y: 1) // ❌
  1. 枚举也能 mutating
enum TriStateSwitch {
    case off, low, high
    
    mutating func next() {
        switch self {
        case .off: self = .low
        case .low: self = .high
        case .high: self = .off
        }
    }
}

var oven = TriStateSwitch.low
print(oven)
oven.next() // high
print(oven)
oven.next() // off
print(oven)

类型方法(Type Method)

  1. 关键字
  • static:类、结构体、枚举都能用;子类不能重写。
  • class:仅类能用;子类可 override。
  1. 调用方式

“类型名.方法名”,无需实例。

  1. 方法体内 self 指“类型自身”
class SomeClass {
    class func helloType() {
        print("Hello from \(self)") // 打印类名
    }
}
SomeClass.helloType()

完整实战:游戏关卡管理

下面把“类型属性 + 类型方法 + 实例方法”揉到一起,演示一个常用模式:

  • 类型层保存“全局状态”(最高解锁关卡)。
  • 实例层保存“个人状态”(当前玩家在第几关)。
struct LevelTracker {
    // 1. 类型属性:所有玩家共享
    nonisolated(unsafe) static var highestUnlockedLevel = 1
    
    // 2. 类型方法:解锁
    static func unlock(_ level: Int) {
        if level > highestUnlockedLevel {
            highestUnlockedLevel = level
        }
    }
    
    // 3. 类型方法:查询是否解锁
    static func isUnlocked(_ level: Int) -> Bool {
        return level <= highestUnlockedLevel
    }
    
    // 4. 实例属性:个人当前关卡
    var currentLevel = 1
    
    // 5. 实例方法(mutating):进阶到指定关
    @discardableResult
    mutating func advance(to level: Int) -> Bool {
        if LevelTracker.isUnlocked(level) {
            currentLevel = level
            return true
        }
        return false
    }
}

// 玩家类
class Player {
    let name: String
    var tracker = LevelTracker()
    
    init(name: String) {
        self.name = name
    }
    
    // 完成某关
    func complete(level: Int) {
        LevelTracker.unlock(level + 1)          // 全局解锁下一关
        tracker.advance(to: level + 1)          // 个人进度推进
    }
}

// 场景脚本
let player = Player(name: "Argyrios")
player.complete(level: 1)
print("最高解锁关卡:\(LevelTracker.highestUnlockedLevel)") // 2

// 第二个玩家想跳关
let player2 = Player(name: "Beto")
if player2.tracker.advance(to: 6) {
    print("直接跳到 6 成功")
} else {
    print("6 关尚未解锁") // 走进这里
}

易忘细节速查表

  1. mutating 只能用于 struct/enum;class 天生可变,不需要。
  2. static 与 class 区别:
    • 结构体/枚举只能用 static。
    • 类里 static = final class,不允许子类覆盖;class 允许覆盖。
  3. 在类型方法里调用同类类型方法/属性,可直接写名字,无需前缀类型。
  4. @discardableResult 用于“调用方可以不处理返回值”的场景,消除警告。

总结与实战扩展

  1. 方法不再“是类的专利”后,优先用 struct 建模数据,再根据需要升级成 class,可大幅降低引用类型带来的共享状态问题。
  2. mutating 让“值语义 + 链式调用”成为可能,例如:
extension Array where Element: Comparable {
    mutating func removeMin() -> Element? {
        guard let min = self.min() else { return nil }
        remove(at: firstIndex(of: min)!)
        return min
    }
}
var scores = [98, 67, 84]
while let min = scores.removeMin() {
    print("从低到高处理", min)
}
  1. 类型方法是做“全局状态但作用域受限”的利器:
  • App 配置中心(static 存储 + 类型方法读写)
  • 网络请求 stub 中心(type method 注册/注销 mock)
  • 工厂方法(class func makeDefaultXxx())
  1. 与协议组合

把 mutating 写进协议,让 struct/enum 也能提供“可变更”接口,而 class 实现时自动忽略 mutating:

protocol Resettable {
    mutating func reset()
}

Swift 嵌套类型:在复杂类型内部优雅地组织枚举、结构体与协议

为什么要“嵌套”

在 Swift 中,我们经常会写一些“小工具”类型:

  • 只在某个类/结构体里用到的枚举
  • 仅服务于一条业务逻辑的辅助结构体
  • 与外部世界无关的私有协议

如果把它们全部写成顶层类型,会导致:

  1. 命名空间污染(Top-Level 名字过多)
  2. 可读性下降(“这个类型到底给谁用?”)
  3. 访问控制粒度变粗(想私有却不得不 public)

嵌套类型(Nested Types)正是为了解决这三个痛点:把“辅助类型”放进“主类型”内部,让代码的“作用域”与“视觉层次”保持一致。

语法一览:如何“套娃”

// 外层:主类型
struct BlackjackCard {
    
    // 嵌套枚举 ①
    enum Suit: Character {
        case spades   = "♠"
        case hearts   = "♥"
        case diamonds = "♦"
        case clubs    = "♣"
    }
    
    // 嵌套枚举 ②
    enum Rank: Int {
        case two = 2, three, four, five, six, seven, eight, nine, ten
        case jack, queen, king, ace
        
        // 在枚举里再嵌套一个结构体 ③
        struct Values {
            let first: Int
            let second: Int?   // Ace 才有第二值
        }
        
        // 计算属性,返回嵌套结构体
        var values: Values {
            switch self {
            case .ace:
                return Values(first: 1, second: 11)
            case .jack, .queen, .king:
                return Values(first: 10, second: nil)
            default:                // 2...10
                return Values(first: self.rawValue, second: nil)
            }
        }
    }
    
    // 主类型自己的属性
    let rank: Rank
    let suit: Suit
    
    // 计算属性,拼接描述
    var description: String {
        let valueDesc = rank.values.second == nil ?
            "\(rank.values.first)" :
            "\(rank.values.first)\(rank.values.second!)"
        return "\(suit.rawValue)\(rank.rawValue)(点数 \(valueDesc))"
    }
}

知识点逐条拆解

  1. 嵌套深度不限

    上面 Values 结构体嵌套在 Rank 枚举里,Rank 又嵌套在 BlackjackCard 中,形成三级嵌套。只要你愿意,可以继续往下套。

  2. 名字自动带上“前缀”

    在外部使用时,编译器强制你加“外层名字.”前缀,天然起到命名空间隔离:

let color = BlackjackCard.Suit.hearts   // 不会和 Poker.Suit.hearts 冲突
  1. 访问控制可逐层细化

    如果 BlackjackCardpublic,而 Values 声明为 private,那么模块外部无法感知 Values 存在,实现细节被彻底隐藏。

  2. 成员构造器依旧生效

    因为 BlackjackCard 是结构体且未自定义 init,编译器仍会生成逐成员构造器:

let card = BlackjackCard(rank: .ace, suit: .spades)
print(card.description)   // ♠ace(点数 1 或 11)

注意:.ace.spades 可以省略前缀,因为 Swift 能根据形参类型推断出 RankSuit

再举三个日常开发场景

  1. UITableView 嵌套数据源
class SettingsViewController: UITableViewController {
    
    // 仅在本控制器里使用的模型
    private enum Section: Int, CaseIterable {
        case account, privacy, about
        
        var title: String {
            switch self {
            case .account: return "账号"
            case .privacy: return "隐私"
            case .about:   return "关于"
            }
        }
    }
    
    private typealias Row = (icon: UIImage?, text: String, action: () -> Void)
    
    private var data: [Section: [Row]] = [:]
}
  1. Network 嵌套错误
struct API {
    enum Error: Swift.Error {
        case invalidURL
        case httpStatus(code: Int)
        case decodeFailure(underlying: Swift.Error)
    }
    
    func request() async throws -> Model {
        guard let url = URL(string: "https://example.com") else {
            throw Error.invalidURL
        }
        ...
    }
}
  1. SwiftUI 嵌套模型
struct EmojiMemoryGame: View {
    
    // 仅在本 View 文件里使用
    private struct Card: Identifiable {
        let id = UUID()
        let emoji: String
        var isFaceUp = false
    }
    
    @State private var cards: [Card] = []
}

总结与最佳实践

  1. 命名空间 > 前缀

    与其写 BlackjackSuitBlackjackRank,不如直接嵌套,用 BlackjackCard.Suit 既简洁又清晰。

  2. 能 private 就 private

    把嵌套类型默认写成 private,直到外部真的需要再放宽权限,避免“泄露实现”。

  3. 不要“为了嵌套而嵌套”

    如果某个类型在多个业务模块出现,继续嵌套反而会增加引用成本,此时应提升为顶层 internalpublic

  4. typealias 搭配食用更佳

    当嵌套路径过长时,可在当前文件顶部 typealias CardSuit = BlackjackCard.Suit,既保留命名空间,又减少手指负担。

  5. 在 Swift Package 中作为“实现细节”

    公开接口只暴露最外层 public struct BlackjackCard,所有辅助枚举/结构体保持 internalprivate,后续迭代可随意重构而不破坏 SemVer。

Swift 类型转换实用指北:从 is / as 到 Any/AnyObject 的完整路线

为什么要“类型转换”

Swift 是强类型语言,编译期就必须知道每个变量的真实类型。

但在面向对象、协议、泛型甚至混用 OC 的场景里,变量“静态类型”与“实际类型”常常不一致。

类型转换(Type Casting)就是用来:

  1. 检查“实际类型”到底是谁(is)
  2. 把“静态类型”当成别的类型来用(as / as? / as!)
  3. 处理“任意类型”这种黑盒(Any / AnyObject)

核心运算符速查表

运算符 返回类型 可能失败 用途示例
is Bool 不会崩溃 判断“是不是”某类型
as? 可选值 会返回nil 安全向下转型(失败不炸)
as! 非可选值 可能崩溃 强制向下转型(失败运行时错误)
as 原类型 不会失败 向上转型或桥接(OC↔Swift)

建立实验田:先搭一个类层级

// ① 根类:媒体条目
class MediaItem {
    let name: String
    init(name: String) { self.name = name }
}

// ② 子类:电影
class Movie: MediaItem {
    let director: String
    init(name: String, director: String) {
        self.director = director
        super.init(name: name)
    }
}

// ③ 子类:歌曲
class Song: MediaItem {
    let artist: String
    init(name: String, artist: String) {
        self.artist = artist
        super.init(name: name)
    }
}

// ④ 仓库:存放所有媒体
let library: [MediaItem] = [
    Movie(name: "卧虎藏龙", director: "李安"),
    Song(name: "青花瓷", artist: "周杰伦"),
    Movie(name: "星际穿越", director: "诺兰"),
    Song(name: "晴天", artist: "周杰伦"),
    Song(name: "夜曲", artist: "周杰伦")
]

虽然数组静态类型是[MediaItem],但运行时每个元素仍然是原来的MovieSong

想访问director/artist?先检查、再转换——这就是本文主题。

检查类型:is 的用法

需求:统计library里电影、歌曲各多少。

var movieCount = 0
var songCount  = 0

for item in library {
    if item is Movie { movieCount += 1 }
    if item is Song  { songCount  += 1 }
}

print("电影\(movieCount)部,歌曲\(songCount)首")
// 打印:电影2部,歌曲3首

要点

  1. is只回答“是/否”,不改动类型。
  2. 对协议也适用,例如item is CustomStringConvertible

向下转型:as? 与 as! 的抉择

需求:把每个元素的详细信息打印出来,需要访问子类独有属性。

for item in library {
    // 1. 先尝试当成电影
    if let movie = item as? Movie {
        print("电影:《\(movie.name)》——导演:\(movie.director)")
        continue
    }
    
    // 2. 再尝试当成歌曲
    if let song = item as? Song {
        print("歌曲:《\(song.name)》——歌手:\(song.artist)")
    }
}

输出: 电影:《卧虎藏龙》——导演:李安 歌曲:《青花瓷》——歌手:周杰伦 电影:《星际穿越》——导演:诺兰 歌曲:《晴天》——歌手:周杰伦 歌曲:《夜曲》——歌手:周杰伦

经验

  1. 不确定成功用as?+可选绑定,几乎不会错。
  2. 只有100%确定时才写as!,否则崩溃现场见:
let first = library[0] as! Song  // 运行时错误:Could not cast value of type 'Movie' to 'Song'

向上转型:as 的“隐形”场景

向上转是最安全的,因为子类一定能当父类用,Swift甚至允许省略as

let m: Movie = Movie(name: "哪吒", director: "饺子")
let item: MediaItem = m   // 编译器自动向上转

但在某些桥接场景必须显式写as

// NSArray 只能装 NSObject,Swift String 需要桥接
let ocArray: NSArray = ["A", "B", "C"] as NSArray

Any 与 AnyObject:万金油盒子

类型 能装什么 常见场景
Any 任何类型(含struct/enum/closure) JSON、脚本语言交互
AnyObject 任何class(含@objc协议) OC SDK、UITableView datasource

示例:把“完全不相干”的值塞进一个数组

var things = [Any]()
things.append(42)                       // Int
things.append(3.14)                     // Double
things.append("Hello")                  // String
things.append((2.0, 5.0))               // 元组
things.append(Movie(name: "哪吒", director: "饺子"))
things.append({ (name: String) -> String in "Hi, \(name)" }) // 闭包

怎么把这些值取出来?switch + 模式匹配最清晰:

for thing in things {
    switch thing {
    case let int as Int:
        print("整数值:\(int)")
    case let double as Double:
        print("小数值:\(double)")
    case let str as String:
        print("字符串:\(str)")
    case let (x, y) as (Double, Double):
        print("坐标:(\(x), \(y))")
    case let movie as Movie:
        print("任意盒里的电影:\(movie.name)")
    case let closure as (String) -> String:
        print("闭包返回:\(closure("Swift"))")
    default:
        print("未匹配到的类型")
    }
}

要点

  1. as模式可以一次性完成“类型检查+绑定”。
  2. 不要滥用Any/AnyObject,你会失去编译期检查,代码维护成本陡增。

常见踩坑与调试技巧

  1. “is”对协议要求苛刻
protocol Playable { }
extension Song: Playable { }

let s: MediaItem = Song(name: "x", artist: "y")
print(s is Playable) // true

之前的某个Swift版本,会将s推断为MediaItem,而MediaItem没实现Playable协议,所以返回false

从4.x之后s的实际类型是Sone,返回true

  1. JSON转字典后全成Any
let json: [String: Any] = ["age": 18]
let age = json["age"] as! Int + 1  // 万一服务器返回String就崩

as?+guard提前返回,或Codable一步到位。

  1. as!链式写法
let label = (view.subviews[0] as! UILabel).text as! String  // 两层强转,一层失败就崩

建议分步+可选绑定,或使用if let label = view.subviews.first as? UILabel

实战延伸:类型转换在架构中的身影

  1. MVVM差异加载

tableView同一cellForRow里根据item is HeaderItem / DetailItem画不同UI。
2. 路由/插件

URL路由把参数打包成[String: Any],各插件再as?取出自己关心的类型。
3. 单元测试

XCTAssertTrue(mock is MockNetworkClient)确保测试替身注入正确。
4. OC老SDK混编

UIViewController→自定义子类,as!前先用isKind(of:)(OC习惯)或is检查。

总结与私货

  1. 类型转换不是“黑科技”,它只是把运行时类型信息暴露给开发者。
  2. 优先用as?+可选绑定,让错误止步于nil;as!留给自己能写单元测试担保的场景。
  3. Any/AnyObject是“逃生舱”,一旦打开就等于对编译器说“相信我”。能不用就不用,实在要用就封装成明确的枚举或struct,把转换工作限制在最小作用域。
  4. 在团队Code Review里,见到as!可以强制要求写注释说明为什么不会崩;这是用制度换安全感。
  5. 如果业务里大量is/as泛滥,多半协议/泛型抽象得不够,可以考虑重构:
    • 用协议扩展把“差异化行为”做成多态,而不是if/else判断类型。
    • 用泛型把“运行时类型”提前到“编译期类型”,减少转换。

《Flutter全栈开发实战指南:从零到高级》- 05 - 基础组件实战:构建登录界面

手把手教你实现一个Flutter登录页面

嗨,各位Flutter爱好者!今天我要和大家分享一个超级实用的功能——用Flutter构建一个功能完整的登录界面。说实话,第一次接触Flutter时,看着那些组件列表也是一头雾水,但当真正动手做出第一个登录页面后,才发现原来一切都这么有趣!

登录界面就像餐厅的门面,直接影响用户的第一印象。今天,我们就一起来打造一个既美观又实用的"门面"!

我们要实现什么?

先来看看我们的目标——一个支持多种登录方式的登录界面:

含以下功能点:

  • 双登录方式:账号密码 + 手机验证码
  • 实时表单验证
  • 记住密码和自动登录
  • 验证码倒计时
  • 第三方登录(微信&QQ&微博)
  • 交互动画

是不是已经迫不及待了?别急,工欲善其事,必先利其器!!! 在开始搭建之前,我们先来熟悉一下Flutter的基础组件,这些组件就像乐高积木,每个都有独特的用途,组合起来就能创造奇迹!

一、Flutter基础组件

1.1 Text组件:不只是显示文字

Text组件就像聊天时的文字消息,不同的样式能传达不同的情感。让我给你展示几个实用的例子:

// 基础文本 - 就像普通的聊天消息
Text('你好,Flutter!')

// 带样式的文本 - 像加了特效的消息
Text(
  '欢迎回来!',
  style: TextStyle(
    fontSize: 24.0,              // 字体大小
    fontWeight: FontWeight.bold, // 字体粗细
    color: Colors.blue[800],     // 字体颜色
    letterSpacing: 1.2,          // 字母间距
  ),
)

// 富文本 - 像一条消息中有不同样式的部分
Text.rich(
  TextSpan(
    children: [
      TextSpan(
        text: '已有账号?',
        style: TextStyle(color: Colors.grey[600]),
      ),
      TextSpan(
        text: '立即登录',
        style: TextStyle(
          color: Colors.blue,
          fontWeight: FontWeight.bold,
        ),
      ),
    ],
  ),
)

实用技巧:

  • 文字超出时显示省略号:overflow: TextOverflow.ellipsis
  • 限制最多显示行数:maxLines: 2
  • 文字居中显示:textAlign: TextAlign.center

1.2 TextField组件:用户输入

TextField就像餐厅的点菜单,用户在上面写下需求,我们负责处理。来看看如何打造一个贴心的输入体验:

// 基础输入框
TextField(
  decoration: InputDecoration(
    labelText: '用户名',             // 标签文字
    hintText: '请输入用户名',        // 提示文字
    prefixIcon: Icon(Icons.person), // 前缀图标
  ),
)

// 密码输入框 - 带显示/隐藏切换
TextField(
  obscureText: true,  // 隐藏输入内容
  decoration: InputDecoration(
    labelText: '密码',
    prefixIcon: Icon(Icons.lock),
    suffixIcon: IconButton(    // 后缀图标按钮
      icon: Icon(Icons.visibility),
      onPressed: () {
        // 切换密码显示/隐藏
      },
    ),
  ),
)

// 带验证的输入框
TextFormField(
  validator: (value) {
    if (value == null || value.isEmpty) {
      return '请输入内容';  // 验证失败时的提示
    }
    return null;  // 验证成功
  },
)

TextField的核心技能:

  • controller:管理输入内容
  • focusNode:跟踪输入焦点
  • keyboardType:为不同场景准备合适的键盘
  • onChanged:实时监听用户的每个输入

1.3 按钮组件:触发事件的开关

按钮就像电梯的按键,按下它就会带你到达想去的楼层。Flutter提供了多种类型按钮,每种都有其独有的特性:

// 1. ElevatedButton - 主要操作按钮(有立体感)
ElevatedButton(
  onPressed: () {
    print('按钮被点击了!');
  },
  style: ElevatedButton.styleFrom(
    backgroundColor: Colors.blue,      // 背景色
    foregroundColor: Colors.white,     // 文字颜色
    padding: EdgeInsets.all(16),       // 内边距
    shape: RoundedRectangleBorder(     // 形状
      borderRadius: BorderRadius.circular(12),
    ),
  ),
  child: Text('登录'),
)

// 2. TextButton - 次要操作按钮
TextButton(
  onPressed: () {
    print('忘记密码');
  },
  child: Text('忘记密码?'),
)

// 3. OutlinedButton - 边框按钮
OutlinedButton(
  onPressed: () {},
  child: Text('取消'),
  style: OutlinedButton.styleFrom(
    side: BorderSide(color: Colors.grey),
  ),
)

// 4. IconButton - 图标按钮
IconButton(
  onPressed: () {},
  icon: Icon(Icons.close),
  color: Colors.grey,
)

按钮状态管理很重要:

  • 加载时禁用按钮,防止重复提交
  • 根据表单验证结果控制按钮可用性
  • 提供视觉反馈,让用户知道操作已被接收

1.4 布局组件

布局组件就像房子的承重墙,它们决定了界面元素的排列方式。掌握它们,你就能轻松构建各种复杂布局:

// Container - 万能的容器
Container(
  width: 200,
  height: 100,
  margin: EdgeInsets.all(16),    // 外边距
  padding: EdgeInsets.all(20),   // 内边距
  decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.circular(16),
    boxShadow: [                 // 阴影效果
      BoxShadow(
        color: Colors.black12,
        blurRadius: 10,
      ),
    ],
  ),
  child: Text('内容'),
)

// Row - 水平排列
Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  children: [
    Text('左边'),
    Text('右边'),
  ],
)

// Column - 垂直排列
Column(
  children: [
    Text('第一行'),
    SizedBox(height: 16),  // 间距组件
    Text('第二行'),
  ],
)

现在我们已经熟悉了基础组件,是时候开始真正的功能实战了!

二、功能实战:构建多功能登录页面

2.1 项目目录结构

在开始编码前,我们先规划好项目结构,就像建房子前先画好房体图纸一样:

lib/
├── main.dart                    # 应用入口
├── models/                      # 数据模型
│   ├── user_model.dart          # 用户模型
│   └── login_type.dart          # 登录类型
├── pages/                       # 页面文件
│   ├── login_page.dart          # 登录页面
│   ├── home_page.dart           # 首页
│   └── register_page.dart       # 注册页面
├── widgets/                     # 自定义组件
│   ├── login_tab_bar.dart       # 登录选项卡
│   ├── auth_text_field.dart     # 认证输入框
│   └── third_party_login.dart   # 第三方登录
├── services/                    # 服务层
│   └── auth_service.dart        # 认证服务
├── utils/                       # 工具类
│   └── validators.dart          # 表单验证
└── theme/                       # 主题配置
    └── app_theme.dart           # 应用主题

2.2 数据模型定义

我们先定义需要用到的数据模型:

// 登录类型枚举
enum LoginType {
  account,  // 账号密码登录
  phone,    // 手机验证码登录
}

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

2.3 实现登录页面

下面我将会带你一步步构建登录页面。

第一步:状态管理

首先,我们需要管理页面的各种状态,就像我们平时开车时要关注各项指标:

class _LoginPageState extends State<LoginPage> {
  // 登录方式状态
  LoginType _loginType = LoginType.account;
  
  // 文本控制器
  final TextEditingController _accountController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();
  final TextEditingController _phoneController = TextEditingController();
  final TextEditingController _smsController = TextEditingController();
  
  // 焦点管理
  final FocusNode _accountFocus = FocusNode();
  final FocusNode _passwordFocus = FocusNode();
  final FocusNode _phoneFocus = FocusNode();
  final FocusNode _smsFocus = FocusNode();
  
  // 状态变量
  bool _isLoading = false;
  bool _rememberPassword = true;
  bool _autoLogin = false;
  bool _isPasswordVisible = false;
  bool _isSmsLoading = false;
  int _smsCountdown = 0;
  
  // 错误信息
  String? _accountError;
  String? _passwordError;
  String? _phoneError;
  String? _smsError;
  
  // 表单Key
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
  
  @override
  void initState() {
    super.initState();
    _loadSavedData();
  }
  
  void _loadSavedData() {
    // 从本地存储加载保存的账号
    if (_rememberPassword) {
      _accountController.text = 'user@example.com';
    }
  }
}
第二步:构建页面

接下来,我们构建页面的整体结构:

@override
Widget build(BuildContext context) {
  return Scaffold(
    backgroundColor: Colors.grey[50],
    body: SafeArea(
      child: SingleChildScrollView(
        physics: BouncingScrollPhysics(),
        child: Container(
          padding: EdgeInsets.all(24),
          child: Form(
            key: _formKey,
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                _buildBackButton(),        // 返回按钮
                SizedBox(height: 20),
                _buildHeader(),            // 页面标题
                SizedBox(height: 40),
                _buildLoginTypeTab(),      // 登录方式切换
                SizedBox(height: 32),
                _buildDynamicForm(),       // 动态表单
                SizedBox(height: 24),
                _buildRememberSection(),   // 记住密码选项
                SizedBox(height: 32),
                _buildLoginButton(),       // 登录按钮
                SizedBox(height: 40),
                _buildThirdPartyLogin(),   // 第三方登录
                SizedBox(height: 24),
                _buildRegisterPrompt(),    // 注册提示
              ],
            ),
          ),
        ),
      ),
    ),
  );
}
第三步:构建各个组件

现在我们来逐一实现每个功能组件:

登录方式切换选项卡:

Widget _buildLoginTypeTab() {
  return Container(
    height: 48,
    decoration: BoxDecoration(
      color: Colors.grey[100],
      borderRadius: BorderRadius.circular(12),
    ),
    child: Row(
      children: [
        // 账号登录选项卡
        _buildTabItem(
          title: '账号登录',
          isSelected: _loginType == LoginType.account,
          onTap: () {
            setState(() {
              _loginType = LoginType.account;
            });
          },
        ),
        // 手机登录选项卡
        _buildTabItem(
          title: '手机登录',
          isSelected: _loginType == LoginType.phone,
          onTap: () {
            setState(() {
              _loginType = LoginType.phone;
            });
          },
        ),
      ],
    ),
  );
}

动态表单区域:

Widget _buildDynamicForm() {
  return AnimatedSwitcher(
    duration: Duration(milliseconds: 300),
    child: _loginType == LoginType.account
        ? _buildAccountForm()   // 账号登录表单
        : _buildPhoneForm(),    // 手机登录表单
  );
}

账号输入框组件:

Widget _buildAccountField() {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text('邮箱/用户名'),
      SizedBox(height: 8),
      TextFormField(
        controller: _accountController,
        focusNode: _accountFocus,
        decoration: InputDecoration(
          hintText: '请输入邮箱或用户名',
          prefixIcon: Icon(Icons.person_outline),
          border: OutlineInputBorder(
            borderRadius: BorderRadius.circular(12),
          ),
          errorText: _accountError,
        ),
        onChanged: (value) {
          setState(() {
            _accountError = _validateAccount(value);
          });
        },
      ),
    ],
  );
}

登录按钮组件:

Widget _buildLoginButton() {
  bool isFormValid = _loginType == LoginType.account
      ? _accountError == null && _passwordError == null
      : _phoneError == null && _smsError == null;

  return SizedBox(
    width: double.infinity,
    height: 52,
    child: ElevatedButton(
      onPressed: isFormValid && !_isLoading ? _handleLogin : null,
      child: _isLoading
          ? CircularProgressIndicator()
          : Text('立即登录'),
    ),
  );
}
第四步:实现业务逻辑

表单验证:

String? _validateAccount(String? value) {
  if (value == null || value.isEmpty) {
    return '请输入账号';
  }
  final emailRegex = RegExp(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$');
  if (!emailRegex.hasMatch(value)) {
    return '请输入有效的邮箱';
  }
  return null;
}

登录逻辑:

Future<void> _handleLogin() async {
  if (_isLoading) return;
  
  if (_formKey.currentState!.validate()) {
    setState(() {
      _isLoading = true;
    });
    
    try {
      User user;
      if (_loginType == LoginType.account) {
        user = await AuthService.loginWithAccount(
          account: _accountController.text,
          password: _passwordController.text,
        );
      } else {
        user = await AuthService.loginWithPhone(
          phone: _phoneController.text,
          smsCode: _smsController.text,
        );
      }
      await _handleLoginSuccess(user);
    } catch (error) {
      _handleLoginError(error);
    } finally {
      setState(() {
        _isLoading = false;
      });
    }
  }
}

效果展示与总结

f1.png

f2.png 至此我们终于完成了一个功能完整的登录页面!让我们总结一下实现的功能:

实现功能点

  1. 双登录方式:用户可以在账号密码和手机验证码之间无缝切换
  2. 智能验证:实时表单验证,即时错误提示
  3. 用户体验:加载状态、错误提示、流畅动画
  4. 第三方登录:支持微信、QQ、微博登录
  5. 状态记忆:记住密码和自动登录选项

学到了什么?

通过这个项目,我们掌握了:

  • 组件使用:Text、TextField、Button等基础组件的深度使用
  • 状态管理:使用setState管理复杂的页面状态
  • 表单处理:实时验证和用户交互
  • 布局技巧:创建响应式和美观的界面布局
  • 业务逻辑:处理用户输入和API调用

最后的话

看到这里,你已经成功构建了一个完整的登录界面!这个登录页面只是开始,期待你能创造出更多更好的应用!

有什么问题或想法?欢迎在评论区留言讨论~, Happy Coding!✨

Swift 枚举完全指南——从基础语法到递归枚举的渐进式学习笔记

前言

在 C/Objective-C 里,枚举只是一组别名整型;在 Swift 里,枚举被提升为“一等类型”(first-class type),可以拥有

  • 计算属性
  • 实例方法
  • 初始化器
  • 扩展、协议、泛型
  • 递归结构

因此,它不再只是“常量集合”,而是一种强大的建模工具。

基础语法:enum、case、点语法

// 1. 最简形式:不附带任何值
enum CompassPoint {
    case north
    case south
    case east
    case west
}

// 2. 单行多 case 写法
enum Planet {
    case mercury, venus, earth, mars, jupiter, saturn, uranus, neptune
}

// 3. 类型推断下的点语法
var direction = CompassPoint.north
direction = .west          // 类型已知,可省略前缀

与 switch 联用: 穷举检查

Swift 的 switch 必须覆盖所有 case,否则编译失败——这是“安全第一”的体现。

var direction = CompassPoint.north
switch direction {
case .north:
    print("Lots of planets have a north")
case .south:
    print("Watch out for penguins")
case .east, .west:          // 多 case 合并
    print("Where the sun rises or sets")
}
// 如果注释掉任意 case,编译器立即报错

遍历所有 case:CaseIterable 协议

只需加一句 : CaseIterable,编译器自动合成 allCases 数组。

enum Beverage: CaseIterable {
    case coffee, tea, juice
}
print("总共 \(Beverage.allCases.count) 种饮品")
for drink in Beverage.allCases {
    print("今天喝\(drink)")
}

关联值(Associated Values)

区别于原始值,关联值是把额外信息绑定到具体实例,而不是枚举定义本身。

enum Barcode {
    // UPC 一维码:四段数字
    case upc(Int, Int, Int, Int)
    // QR 二维码:任意长度字符串
    case qrCode(String)
}

// 创建实例时才真正携带值
var product = Barcode.upc(8, 85909, 51226, 3)
product = .qrCode("https://swift.org")

// switch 提取关联值
switch product {
    //case .upc(let numSystem, let manufacturer, let product, let check):
    // 简写:如果全是 let 或 var,可移到前面
case let .upc(numSystem, manufacturer, product, check):
    print("UPC: \(numSystem)-\(manufacturer)-\(product)-\(check)")
case .qrCode(let url):
    print("QR 内容: \(url)")
}

原始值(Raw Values)——“编译期就确定”

原始值与关联值互斥:

  • 原始值在定义时就写死,所有实例共用;
  • 关联值在创建时才给出,每个实例可以不同。
  1. 手动指定
enum ASCIIControl: Character {
    case tab = "\t"
    case lineFeed = "\n"
    case carriageReturn = "\r"
}
  1. 隐式自动递增 / 隐式字符串
enum PlanetInt: Int {
    case mercury = 1      // 显式从 1 开始
    case venus            // 隐式 2
    case earth            // 隐式 3
}

enum CompassString: String {
    case north            // 隐式 rawValue = "north"
    case south
}
  1. 通过 rawValue 初始化?返回的是可选值
enum PlanetInt: Int {
    case mercury = 1      // 显式从 1 开始
    case venus            // 隐式 2
    case earth            // 隐式 3
}

let possiblePlanet = PlanetInt(rawValue: 7)   // nil,因为没有第 7 颗行星
print(possiblePlanet) // nil
if let planet = PlanetInt(rawValue: 3) {
    print("第 3 颗行星是 \(planet)")   // earth
}

自定义构造器 / 计算属性 / 方法

枚举也能“长得像类”。

enum LightBulb {
    case on(brightness: Double)   // 关联值
    case off

    // 计算属性
    var isOn: Bool {
        switch self {
        case .on: return true
        case .off: return false
        }
    }

    // 实例方法
    mutating func toggle() {
        switch self {
        case .on(let b):
            self = .off
            print("从亮度 \(b) 关灯")
        case .off:
            self = .on(brightness: 1.0)
            print("开灯到默认亮度")
        }
    }
}

var bulb = LightBulb.on(brightness: 0.8)
bulb.toggle()   // 关灯
bulb.toggle()   // 开灯

递归枚举(Indirect Enumeration)

当枚举的关联值再次包含自身时,需要显式标记 indirect,让编译器插入间接层,避免无限嵌套导致内存无法布局。

// 方式 A:单个 case 递归
enum ArithmeticExpr {
    case number(Int)
    indirect case addition(ArithmeticExpr, ArithmeticExpr)
    indirect case multiplication(ArithmeticExpr, ArithmeticExpr)
}

// 方式 B:整个枚举全部 case 都递归
indirect enum Tree<T> {
    case leaf(T)
    case node(Tree<T>, Tree<T>)
}

构建与求值:把“(5 + 4) * 2”装进枚举

let five = ArithmeticExpr.number(5)
let four = ArithmeticExpr.number(4)
let two = ArithmeticExpr.number(2)

let sum = ArithmeticExpr.addition(five, four)
let product = ArithmeticExpr.multiplication(sum, two)

// 递归求值
func evaluate(_ expr: ArithmeticExpr) -> Int {
    switch expr {
    case .number(let value):
        return value
    case .addition(let left, let right):
        return evaluate(left) + evaluate(right)
    case .multiplication(let left, let right):
        return evaluate(left) * evaluate(right)
    }
}

print(evaluate(product))   // 18

实战 1:用枚举建模“JSON”

enum JSON {
    case string(String)
    case number(Double)
    case bool(Bool)
    case null
    case array([JSON])
    case dictionary([String: JSON])
}

let json: JSON = .dictionary([
    "name": .string("Swift"),
    "year": .number(2014),
    "awesome": .bool(true),
    "tags": .array([.string("iOS"), .string("macOS")])
])

优势:

  • 编译期保证类型组合合法;
  • 写解析/生成器时,switch 覆盖所有 case 即可,无需 if-else 层层判断。

实战 2:消除“字符串驱动”——网络请求路由

enum API {
    case login(user: String, pass: String)
    case userInfo(id: Int)
    case articleList(page: Int, pageSize: Int)
}

extension API {
    var host: String { "https://api.example.com" }
    
    var path: String {
        switch self {
        case .login: return "/login"
        case .userInfo(let id): return "/users/\(id)"
        case .articleList: return "/articles"
        }
    }
    
    var parameters: [String: Any] {
        switch self {
        case .login(let u, let p):
            return ["username": u, "password": p]
        case .userInfo:
            return [:]
        case .articleList(let page, let size):
            return ["page": page, "pageSize": size]
        }
    }
}

// 使用
let request = API.login(user: "alice", pass: "123456")
print("请求地址:\(request.host + request.path)")

好处:

  • 路由与参数封装在一起,外部无需硬编码字符串;
  • 新增接口只需再加一个 case,编译器会强制你补全 path & parameters。

性能与内存Tips

  1. 不带关联值的枚举 = 一个整型大小,最省内存。
  2. 关联值会占用更多空间,编译器会按最大 case 对齐;如果内存敏感,可用 indirect 将大数据挂到堆上。
  3. 原始值并不会额外占用存储,它只是编译期常量;运行时通过 rawValue 访问即可。
  4. 枚举是值类型,跨线程传递无需担心引用计数,但大体积关联值复制时要注意写时复制(CoW)开销。

给枚举加“泛型”——一个类型参数打通所有关联值

// 1. 泛型枚举:Success 与 Failure 的具体类型由使用方决定
enum Result<Success, Failure: Error> {
    case success(Success)
    case failure(Failure)
}

// 2. 网络层统一返回
enum APIError: Error { case timeout, invalidJSON }

func fetchUser(id: Int) -> Result<User, APIError> {
    ...
    return .success(user)
}

// 3. 调用方用 switch 就能拿到强类型的 User 或 APIError
let r = fetchUser(id: 1)
switch r {
case .success(let user):
    print(user.name)
case .failure(let error):
    print(error)
}

要点

  • 枚举可以带泛型参数,且每个 case 可使用不同参数。
  • Swift 标准库已内置 Result<Success, Failure>,无需自己写。

枚举也遵守协议——让“一组无关类型”共享行为

protocol Describable { var desc: String { get } }

enum IOAction: Describable {
    case read(path: String)
    case write(path: String, data: Data)
    
    var desc: String {
        switch self {
        case .read(let p):  return "读取 \(p)"
        case .write(let p, _): return "写入 \(p)"
        }
    }
}

let action = IOAction.write(path: "/tmp/a.txt", data: Data())
print(action.desc)

进阶:把枚举当成“小而美”的命名空间,里面再套结构体、类,一并遵守协议,可组合出非常灵活的对象图。

@unknown default —— 面向库作者的“向后兼容”保险

当模块使用 library evolutionBUILD_LIBRARY_FOR_DISTRIBUTION = YES)打开 resilient 构建时,公开枚举默认是“非冻结”的,未来可能新增 case。

客户端必须用 @unknown default: 兜底,否则升级库后会得到编译警告:

// 在 App 代码里
switch frameworkEnum {
case .oldCaseA: ...
case .oldCaseB: ...
@unknown default:        // 少了就会警告
    assertionFailure("请适配新版本 SDK")
}

冻结枚举(@frozen)则告诉编译器“以后绝对不会再加 case”,可以省略 @unknown default

System 框架里大量使用了该技巧,保证 Apple 加新枚举值时老 App 不会直接崩溃。

SwiftUI 视图工厂——用枚举消灭“字符串驱动”的 Navigation

enum Route: Hashable {
    case home
    case article(id: Int)
    case settings(debug: Bool)
}

@main
struct App: SwiftUI.App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .home:         HomeView()
                    case .article(let id): ArticleView(id: id)
                    case .settings(let debug): SettingsView(debug: debug)
                    }
                }
        }
    }
}

优点

  • 路由表即枚举,强类型;
  • 新增 case 编译器会强制你补全对应视图;
  • 支持 NavigationStackpath 参数,可持久化/还原整棵导航树。

把枚举当“位掩码”——OptionSet 的本质仍是枚举

struct FilePermission: OptionSet {
    let rawValue: Int
    
    // 内部用静态枚举常量,对外却是结构体
    static let read   = FilePermission(rawValue: 1 << 0)
    static let write  = FilePermission(rawValue: 1 << 1)
    static let execute = FilePermission(rawValue: 1 << 2)
}

let rw: FilePermission = [.read, .write]
print(rw.rawValue)   // 3

为什么不用“纯枚举”?

  • 枚举无法表达“组合”语义;
  • OptionSet 协议要求 struct 以便支持按位或/与运算。

结论:需要位运算时,用结构体包一层 rawValue,而不是直接上枚举。

性能压测:100 万个关联值到底占多少内存?

测试模型

enum Node {
    case leaf(Int)
    indirect case node(Node, Node)
}

在 64 位下

  • leaf:实际 9 字节(1 字节区分 case + 8 字节 Int),但按 16 字节对齐。
  • node:额外存储两个指针(16 字节)+ 1 字节 tag → 24 字节对齐。

结论

  • 不带 indirect 的枚举=最省内存;
  • 大数据字段务必 indirect 挂到堆上,避免栈爆炸;
  • 如果 case 差异巨大,考虑“枚举 + 类”混合:枚举负责分派,类负责存数据。

什么时候该把“枚举”改回“结构体/类”

  1. case 数量会动态膨胀(如用户标签、城市字典)→ 用字典或数据库。
  2. 需要存储大量同质数据 → 结构体数组更合适。
  3. 需要继承/多态扩展 → 用协议 + 类/结构体。
  4. 需要弱引用、循环引用 → class + delegate 模式。

口诀:“有限状态用枚举,无限集合用集合;行为多态用协议,生命周期用类。”

一条龙完整示例:用枚举写个“小型正则表达式”引擎

indirect enum Regex {
    case literal(Character)
    case concatenation(Regex, Regex)
    case alternation(Regex, Regex)   // “或”
    case repetition(Regex)           // 闭包 *
}

// 匹配函数
extension Regex {
    func match(_ str: String) -> Bool {
        var idx = str.startIndex
        return matchHelper(str, &idx) && idx == str.endIndex
    }
    
    private func matchHelper(_ str: String, _ idx: inout String.Index) -> Bool {
        switch self {
        case .literal(let ch):
            guard idx < str.endIndex, str[idx] == ch else { return false }
            str.formIndex(after: &idx)
            return true
            
        case .concatenation(let left, let right):
            let tmp = idx
            return left.matchHelper(str, &idx) && right.matchHelper(str, &idx) || ({ idx = tmp; return false })()
            
        case .alternation(let left, let right):
            let tmp = idx
            return left.matchHelper(str, &idx) || ({ idx = tmp; return right.matchHelper(str, &idx) })()
            
        case .repetition(let r):
            let tmp = idx
            while r.matchHelper(str, &idx) { }
            return true
        }
    }
}

// 测试
let pattern = Regex.repetition(.alternation(.literal("a"), .literal("b")))
print(pattern.match("abba"))   // true

亮点

  • 纯值类型,线程安全;
  • 用枚举递归描述语法树,代码即文档;
  • 若需性能,可再包一层 JIT 或转成 NFA/DFA。

总结与扩展场景

  1. 枚举是值类型,但拥有近似类的能力。
  2. 关联值 = 运行期动态绑定;原始值 = 编译期静态绑定。
  3. switch 必须 exhaustive,借助 CaseIterable 可遍历。
  4. 可以写构造器、计算属性、方法、扩展、协议等
  5. 建模“有限状态 + 上下文”时,优先用枚举:
    • 播放器状态:.idle / .loading(url) / .playing(item, currentTime) / .paused(item, currentTime)
    • 订单状态:.unpaid(amount) / .paid(date) / .shipped(tracking) / .refunded(reason)
  6. 把“字符串魔法”改成枚举,可让编译器帮你检查漏掉的 case,减少运行时崩溃。
  7. 递归枚举天生适合表达树/表达式这类“自相似”结构,配合模式匹配写解释器极其清爽。
  8. 如果 case 太多(>100),可读性下降,可考虑:
    • 拆成多级枚举(namespace)
    • 用静态工厂方法隐藏细节
    • 改用结构体 + 协议,让“类型”退化为“数据”

checklist:如何写“优雅”的 Swift 枚举

☑ 名字首字母大写,case 小写。

☑ 先问自己“状态是否有限”,再决定用枚举还是字符串。

☑ 关联值 > 3 个字段就封装成结构体,保持 switch 整洁。

☑ 公开库一定要想好“未来会不会加 case”,决定 @frozen 与否。

☑ 超过 20 个 case 考虑分层:外层命名空间枚举,内层再拆。

☑ 需要 Codable 时,关联值枚举要自定义 init(from:) & encode(to:),否则编译器会报错。

☑ 最后写单元测试:把每个 case 都 switch 一遍,防止后续改挂。

Swift -- 第三方登录之微信登录 源码分享

Swift -- 第三方登录之微信登录 源码分享

第一步: 微信开放平台注册,获取APPID和秘钥

不管微信登录,微信支付,微信分享都需要到微信开放平台注册账号后并注册应用,拿到应用唯一标识AppID和应用密钥 AppSecret 然后集成SDK,具体如何集成查看官方文档,文档有详细介绍微信开放平台–iOS接入指南

第二步:程序内设置

微信SDK初始化,注意universal_link必须添加

didFinishLaunchingWithOptions 中对微信SDK进行初始化,方法如下:

 let universal_link = "https://wx.universal_link.url" //此处填写微信后台写的universal_link 地址
 WXApi.registerApp(WX_APPID, universalLink: universal_link)

⚠️ 不要忘了在Signing & Capabilities 中添加Associated Domains中添加applinks, 格式如下图所示: 设置universalLink

第三步 添加代码

微信配置代理,接收微信请求后的返回信息(用户登录/微信支付订单信息)

 // 9.0之后  打开第三方应用之后返回程序内  设置系统回调  --------待完善---------
    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
        if url.absoluteString.hasPrefix(WX_APPID){
            //微-信
            WXApi.handleOpen(url, delegate: NK_WXUtils.sharedManager)
            return true
        }
     }
    func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
        
        if userActivity.activityType == NSUserActivityTypeBrowsingWeb {
            if let url = userActivity.webpageURL{
                    return WXApi.handleOpenUniversalLink(userActivity, delegate: NK_WXUtils.sharedManager)
            }
            
        }
        return true
    }

第四部:微信调用方法

下面就是完整的微信登录及获取用户信息的调用方法

class NK_WXUtils: NSObject , WXApiDelegate{

//微信登录
    static func wxLogin(){
        
        if WXApi.isWXAppInstalled() {
            let req = SendAuthReq()
            req.state = "wx_oauth_authorization_state";//用于保持请求和回调的状态,授权请求或原样带回
            req.scope = "snsapi_userinfo";//授权作用域:获取用户个人信息
            // req.scope = "snsapi_userinfo,snsapi_base";//授权作用域:获取用户个人信息
            
            DispatchQueue.main.async {
                WXApi.send(req)
            }
        }else{
            MBProgressHUD.showJustText(msg: "您尚未安装微信客户端,请安装后重试!")
        }
        
    }

//微信发送请求,这里不用处理
    func onReq(_ req: BaseReq) {
        
        MYLog( "\n\n----openID:"+req.openID)
    }
//微信请求返回结果,这里处理返回的结果
    func onResp(_ resp: BaseResp) {
        if resp.isKind(of: SendAuthResp.self) {
            let res = resp as? SendAuthResp
            if res?.state == "wx_oauth_authorization_state" {
                NK_WXUtils.getWechatAccessToken(code: res!.code!)
            }
        }
    }

    static func getWechatAccessToken(code:String)  {
        
        let url = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=\(WX_APPID)&secret=\(WX_SECRET)&code=\(code)&grant_type=authorization_code"
        MBProgressHUD.showLoadingHUD(msg: nil, view: nil)
        NK_HttpManager().requestData(with: url, cache: false, method: .get, params: nil).success { (code, res) in
            MYLog(res)
            guard let dic = res as? [String: Any] else{
                return
            }
            
            guard let access_token = dic["access_token"] as? String,  let openid = dic["openid"] as? String else{
                MBProgressHUD.hideToastHUD(view: nil)
                return
            }
            getWechatUserInfo(with: access_token, openId: openid )
           
            }.fail { (error, msg) in
//                MYLog(msg)
                MBProgressHUD.showJustText(msg: msg)
        }
        
    }
    
    
    static func getWechatUserInfo(with access_token:String, openId:String)  {

        let url = "https://api.weixin.qq.com/sns/userinfo?access_token=\(access_token)&openid=\(openId)"
        NK_HttpManager().requestData(with: url, cache: false, method: .get, params: nil).success { (code, res) in
            MBProgressHUD.hideToastHUD(view: nil)
//            MYLog(res)
            if let dic = res as? [String: Any]{
                // 获取到的用户信息json格式,可以拿来给服务端绑定用户信息
                `在这里绑定获取到的用户信息`
            }
            
            }.fail { (error, msg) in
                MBProgressHUD.showJustText(msg: msg)
        }
    }
    
}

记录我适配iOS26遇到的一些问题

这是适配iOS 26的笔记,并非介绍新功能和API。我只是把项目中遇到的适配问题记录起来。后续如果遇到新的问题会更新这个笔记。

1. 暂时关闭Liquid Glass 液态玻璃

在iOS26中,系统默认开启了Liquid Glass 液态玻璃效果。例如UINavigationBar和UITabBar等,并且是强制性的。但是在项目紧急上线,适配没有做好的情况可以暂时关闭这个效果。

当然苹果也给了最终限制,最多一年时间,下个主要版本就没这个属性了。不推荐长期使用,应尽快完成适配

只要在info.plist加上这一项即可:


<key>UIDesignRequiresCompatibility</key>

<true/>

image.png

2. 导航栏相关

2.1 导航栏按钮玻璃背景问题

在iOS 26中,导航栏按钮会出现大的圆角矩形玻璃背景,无法隐藏或关闭。这可能导致:

  • 按钮文字被遮挡

  • 图标偏移显示异常

  • 之前设置的偏移量不再适用

解决方案:


// 调整偏移量或更换居中的图标资源

// 之前可能设置了负值偏移让按钮靠前,现在需要重新调整

  


// 方法1: 重新设计图标,使用居中对齐的图标

// 方法2: 调整customView的布局约束

2.2 自定义View添加到NavigationBar的问题

将自定义view添加到navigationBar后,在iOS 26中会出现异常:


// 页面出现时添加

[self.navigationController.navigationBar addSubview:_naviView];

  


// 页面消失时移除

[_naviView removeFromSuperview];

现象: 开始正常显示,但从二级页面返回后view消失(图层中存在但不可见)

原因:

这时候由于Apple 对 UINavigationBar 做了多次底层改造:

| iOS版本 | 导航栏变化 | 影响 |

| ------- | ------------------------------------------------ | ------------------------------------ |

| iOS 15 | 引入 UINavigationBarAppearance | 改变背景和阴影绘制机制 |

| iOS 17+ | 导航栏层级变动,_UINavigationBarModernContentView 延迟加载 | 手动添加的子视图可能被系统布局或动画移除 |

| iOS 26 | NavigationBar使用新的 compositing layer 结构 | 非官方子视图在 appear/disappear 动画时被“吞掉”或遮盖 |

导致了在iOS26中,可能出现下面的问题:

会被系统的内部 layer 覆盖;

或者生命周期中 navigationBar 被重新创建;

导致 view 不再显示、被替换或无法响应。

这时候有三种解决方案:


// 解决方法1:一般不使用

// 把view添加到titleView上

// 优点:跟随导航栏生命周期自动管理,不会丢失或被覆盖。

// 缺点:只能放在标题区域,布局受限。

  


[self.navigationController.navigationItem.titleView addSubview:_naviView];

  


// 解决方法2:

// 放到 NavigationBar 的 superview 层(而非导航栏内部)

// 优点:可以放在任何位置,布局灵活。

// 缺点:需要手动管理生命周期,容易出错。

  


// ✅ 这样即使导航栏内部结构变动,你的 view 也不会丢。

// ⚠️ 记得在二级页面 viewWillAppear 时隐藏它。

[self.navigationController.view addSubview:_naviView];

  


// 临时解决方案:

// 延迟加载 view 到 navigationBar 上

// 确保在 navigationBar 完成布局后再添加 或者在viewDidAppear 中添加

dispatch_async(dispatch_get_main_queue(), ^{

[self.navigationController.view addSubview:_naviView];

});

  


3. TabBar相关

在最新版本中,TabBar的变动很大,

3.1 私有属性设置TabBar问题

❌ 不推荐的做法:

因为在iOS26中Apple 给 tabBar 属性加了 runtime 保护;这时候或者运行闪退或者是新增一个单独的tab


// 通过私有属性设置TabBar

[self setValue:self.customTabbar forKey:@"tabBar"];

问题: 在iOS 26中,Apple给tabBar属性加了runtime保护,会导致:

  • 运行时闪退

  • 新增一个单独的tab

  • 自定义TabBar失效

3.2 直接添加SubView的方式

如果是通过直接添加到tabbar上,这种显示基本没大问题,可能有中间大按钮的问题,且有玻璃效果。 但是可能造成点击失效的问题(被系统拦截)。 我项目中是使用的系统TabBar,没有自定义TabBar。 所以没有遇到这个问题。


// 直接添加到tabbar上

self.customTabbar.frame = self.tabBar.bounds;

[self.tabBar addSubview:self.customTabbar];

问题:

  • 显示基本正常,有玻璃效果

  • 中间大按钮可能有问题

  • 点击可能失效(被系统拦截)

3.3 自定义TabBar适配建议

如果你是自定义仿咸鱼的那种发布tabbar,可能出现只有四个tabarItem和中间一个发布图标的的情况。 这时候点击也会出问题。

这种情况就需要重新设计UI了,紧急修复,或者等三方库更新,或者再找找别的方法。

3.4 TabBar透明度问题

如果内容没有延伸到TabBar下方,检查是否设置了isTranslucent属性:


// iOS 26需要移除或条件编译

if #unavailable(iOS 26) {

tabBar.isTranslucent = false

}

我是没有遇到这个问题,因为我们应用首页是个collectionview,我还怕它延伸到tabbar下方,造成不好点击的问题。

4. 布局约束问题

在修改中我发现之前获取的kTopHeight(NaviBarHeight+StatusBarHeight) 会有问题。

原因如下:

  1. windowScene.statusBarManager.statusBarFrame 在某些时机是 0 或未更新(特别是多 Scene、导航过渡、或 navigationBar 异步布局时)。

  2. safeAreaInsets 由系统在 view 布局完成后才会精确计算,早用会得到旧值或 0。

  3. UINavigationBar 在 iOS 26 里可能异步构建(或使用新 compositing),导致你在 viewDidLoad/viewWillAppear 读取到不正确的高度。

  4. 如果你把子视图约束到 self.view.top 而不是 safeAreaLayoutGuide.top,内容会延伸到状态栏/导航栏下方(被遮盖)。

所以布局时使用 Safe Area(safeAreaLayoutGuide 或 view.safeAreaInsets)而不是 statusBarFrame。


//建议使用这个来获取高度

make.top.equalTo(self.view.mas_safeAreaLayoutGuideTop).offset(0);

5. 图片导航栏按钮设置original仍显示蓝色

即使设置了UIImageRenderingModeAlwaysOriginal,在iOS 26中图片按钮仍显示为蓝色(系统tintColor)。


// ❌ 在iOS 26中无效

- (void)setNavigationBarBtn {

UIImage *addImg = [[UIImage imageNamed:@"规范_新增+"]

imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal];

UIBarButtonItem *add = [[UIBarButtonItem alloc] initWithImage:addImg

style:UIBarButtonItemStyleDone

target:self

action:@selector(addClient)];

self.navigationItem.rightBarButtonItems = @[add];

}

解决方案

方案1:设置tintColor为clearColor(推荐)


- (void)setNavigationBarBtn {

UIImage *addImg = [[UIImage imageNamed:@"规范_新增+"]

imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal];

UIImage *searchImg = [[UIImage imageNamed:@"放大镜"]

imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal];

  


UIBarButtonItem *add = [[UIBarButtonItem alloc] initWithImage:addImg

style:UIBarButtonItemStylePlain

target:self

action:@selector(addClient)];

  


UIBarButtonItem *search = [[UIBarButtonItem alloc] initWithImage:searchImg

style:UIBarButtonItemStylePlain

target:self

action:@selector(searchClient)];

  


// ✅ iOS 26修复方案

for (UIBarButtonItem *item in @[add, search]) {

item.tintColor = UIColor.clearColor; // 确保使用原图色

}

// ⚠️ 注意:iOS 26中左右顺序和之前版本相反

if (@available(iOS 26.0, *)) {

self.navigationItem.rightBarButtonItems = @[search, add];

} else {

self.navigationItem.rightBarButtonItems = @[add, search];

}

}

方案2:使用自定义UIButton


- (void)setNavigationBarBtn {

UIButton *addButton = [UIButton buttonWithType:UIButtonTypeCustom];

[addButton setImage:[UIImage imageNamed:@"规范_新增+"] forState:UIControlStateNormal];

addButton.frame = CGRectMake(0, 0, 30, 30);

[addButton addTarget:self action:@selector(addClient) forControlEvents:UIControlEventTouchUpInside];

UIBarButtonItem *add = [[UIBarButtonItem alloc] initWithCustomView:addButton];

self.navigationItem.rightBarButtonItems = @[add];

}

重要提醒

⚠️ iOS 26中导航栏按钮顺序变化:

在设置rightBarButtonItemsleftBarButtonItems时,iOS 26的显示顺序与之前版本相反,需要条件编译处理:


if (@available(iOS 26.0, *)) {

// iOS 26: 数组第一个元素显示在最右边

self.navigationItem.rightBarButtonItems = @[最右边的按钮, 中间按钮, 最左边的按钮];

} else {

// iOS 25及以下: 数组第一个元素显示在最左边

self.navigationItem.rightBarButtonItems = @[最左边的按钮, 中间按钮, 最右边的按钮];

}

补充 iPad相关

可以看这个大佬的iPad适配文章

正确的 .gitignore 配置

# Xcode 用户数据
**/xcuserdata/
*.xcodeproj/xcuserdata/
*.xcworkspace/xcuserdata/

# Xcode 构建文件
build/
DerivedData/

# CocoaPods - 只忽略 Pods 目录,不忽略 Podfile 和 Podfile.lock
Pods/

# macOS
.DS_Store

# 其他
*.swp
*~

提交代码时

git add Podfile Podfile.lock .gitignore
git commit -m "Update dependencies"
git push

执行 pod install 后,.xcodeproj 文件被修改了,产生了待提交的内容。

原因分析

当你运行 pod install 时,CocoaPods 会:

  1. ✅ 在 Pods/ 目录下载依赖库(已被 .gitignore 忽略)
  2. ⚠️ 修改 .xcodeproj/project.pbxproj 文件,添加对 Pods 的引用

首次克隆项目后

# 1. 克隆项目
git clone <your-repo-url>
cd 项目目录

# 2. 安装依赖
pod install

# 3. 提交 .xcodeproj 的修改(如果有)
git add eWordMedical.xcodeproj/project.pbxproj
git commit -m "Update project configuration after pod install"
git push

为什么会有这些修改? 可能的原因:

  1. 路径差异:不同电脑上的绝对路径不同
  2. CocoaPods 版本:不同版本的 CocoaPods 生成的配置略有差异
  3. 首次安装:如果项目之前没有正确提交 .xcodeproj

这样做的好处:

  • 保持项目文件与实际配置一致
  • 团队其他成员拉取后可以直接编译

预防措施 为了减少这种情况,团队应该 统一 CocoaPods 版本

# 查看当前版本
pod --version

# 在 Gemfile 中锁定版本(可选)
gem 'cocoapods', '~> 1.15'

确保 .xcworkspace 也被提交

# .xcworkspace 应该提交(包含工作区配置)
git add xxxx.xcworkspace

在 .gitignore 中只忽略用户数据

# 只忽略用户数据,不忽略项目文件
**/xcuserdata/
*.xcworkspace/xcuserdata/

⚠️ 可能冲突的情况

只有在以下情况会冲突:

  1. 同时修改项目结构

    • 你:添加了新文件 A
    • 同事:添加了新文件 B
    • 两个人都修改了 .xcodeproj
    • 结果:Git 合并冲突 ❌
  2. 同时更新依赖

    • 你:更新了 Alamofire 版本
    • 同事:更新了 SnapKit 版本
    • 两个人都修改了 Podfile.lock 和 .xcodeproj
    • 结果:需要手动合并 ⚠️

CocoaPods 库中的代码有报错问题每次我都需要手动修改为了防止每次都修改以下修改 使用 Podfile 的 post_install 钩子自动修复

post_install do |installer|
  installer.pods_project.targets.each do |target|
    if target.name == 'CountdownLabel'  # 替换为你的 Pod 名称
      target.build_configurations.each do |config|
        # 自动修复感叹号问题
        Dir.glob("Pods/CountdownLabel/**/*.swift").each do |file|
          contents = File.read(file)
          # 将 as !TimeZone 替换为 as? TimeZone
          new_contents = contents.gsub(/as !TimeZone/, 'as? TimeZone')
          File.write(file, new_contents) if contents != new_contents
        end
      end
    end
  end
end

【Swift 筑基记】把“结构体”与“类”掰开揉碎——从值类型与引用类型说起

Swift 里的“结构体”和“类”长什么样?

  1. 定义语法
// 结构体:用 struct 关键字
struct Resolution {
    var width = 0          // 存储属性
    var height = 0
}

// 类:用 class 关键字
class VideoMode {
    var resolution = Resolution() // 复杂类型属性
    var interlaced = false        // 逐行/隔行扫描
    var frameRate = 0.0
    var name: String?             // 可选类型,默认 nil
}

注意:Swift 不需要 .h/.m 分离,一个文件搞定接口与实现。

  1. 创建实例——“()” 就是最简单的初始化器
let someResolution = Resolution() // 结构体实例
let someVideoMode = VideoMode()   // 类实例
  1. 访问属性——点语法(dot syntax)
print("默认宽:\(someResolution.width)")           // 0
print("视频模式宽:\(someVideoMode.resolution.width)") // 0

// 也能层层赋值
someVideoMode.resolution.width = 1280
print("修改后宽:\(someVideoMode.resolution.width)")   // 1280

结构体自带“逐成员初始化器”

编译器会自动给 struct 生成一个 memberwise initializer,class 没有!

// 结构体可直接写全参构造器
let vga = Resolution(width: 640, height: 480)

// 类必须自己写
class VideoMode {
    ...
    init(resolution: Resolution, interlaced: Bool, frameRate: Double, name: String?) {
        self.resolution = resolution
        self.interlaced = interlaced
        self.frameRate = frameRate
        self.name = name
    }
}

值类型 vs 引用类型

  1. 结构体是值类型:赋值 = 全量复制
let hd = Resolution(width: 1920, height: 1080)
var cinema = hd          // 内存里出现两份独立数据
cinema.width = 2048

print("cinema.width = \(cinema.width)") // 2048
print("hd.width     = \(hd.width)")     // 1920,原数据纹丝不动
  1. 枚举也是值类型
enum CompassPoint {
    case north, south, east, west
    mutating func turnNorth() { self = .north }
}
var current = CompassPoint.west
let old = current          // 复制一份
current.turnNorth()

print("当前:\(current)")  // north
print("旧值:\(old)")      // west,不受影响
  1. 类是引用类型:赋值 = 多一根指针指向同一块堆内存
let tenEighty = VideoMode()
tenEighty.resolution = hd
tenEighty.interlaced = true
tenEighty.name = "1080i"
tenEighty.frameRate = 25.0

let alsoTenEighty = tenEighty   // 只复制指针,未复制对象
alsoTenEighty.frameRate = 30.0

print("tenEighty.frameRate = \(tenEighty.frameRate)") // 30.0,一起变

如何判断“指向同一实例”?——身份运算符

if tenEighty === alsoTenEighty {
    print("两根指针指向同一块堆内存 ✅")
}
// 输出:两根指针指向同一块堆内存 ✅

注意:=== 与 == 完全不同

  • === 比较“身份”(是否同一实例)
  • == 比较“值相等”,需要开发者自己实现 Equatable 协议

4 个易错点

  1. 数组/字典/集合是 struct,但内部有“写时复制”(COW) 优化,大块数据不会立刻复制。
  2. let 修饰 class 实例:只能锁定“指针”不能变,但实例内部属性可变!
let vm = VideoMode()
vm.frameRate = 60 // ✅ 合法,指针没变
  1. struct 里包含 class 属性时,复制的是“引用”。嵌套情况要画对象图。
  2. 多线程下,值类型天然线程安全;引用类型需要额外同步(如锁、actor)。

10 秒选型决策表

场景 首选
模型小而简单,主要存数据 struct
需要继承、多态、抽象基类 class
需要 @objc 动态派发、KVO class
SwiftUI 视图状态(@State) struct
共享可变状态(缓存、注册表) class + 单例
网络 JSON 转模型(Codable) struct(Codable)
需要 deinit 释放资源 class

实战扩展:SwiftUI + Combine 中的 struct/class 协奏

  1. 视图层——全是 struct
struct TweetRow: View {
    let tweet: Tweet        // 值类型,一行数据
    var body: some View { ... }
}
  1. 数据源——class 托管生命周期
final class TimelineVM: ObservableObject {
    @Published private(set) var tweets: [Tweet] = []
    
    func fetch() async {
        ...
    }
}
  1. 共享状态——@StateObject 只接受 class
struct TimelineView: View {
    @StateObject private var vm = TimelineVM() // 必须是 class
    var body: some View {
        List(vm.tweets) { TweetRow(tweet: $0) }
    }
}

struct 真的比 class 快吗?

官方文档只说“struct 是值类型,会复制”,却不说:

  • 复制一次到底多大开销?
  • Array 的“写时复制”(COW) 对自定义类型是否同样生效?
  • 在 10 万元素级别,struct 与 class 差距是 1 % 还是 10 倍?

热身:先写一个“无脑”版本

// 1. 纯值类型,每次赋值都全量复制
struct MyArrayStruct {
    var storage: [Int] = Array(0..<100_000)
}

// 2. 引用类型,永远共享
final class MyArrayClass {
    var storage: [Int] = Array(0..<100_000)
}

跑分结果(M1 Mac,Release 模式,100 万次读):

类型 随机读 拷贝 + 写一次 内存峰值
struct 18 ms 6.2 ms 3.2 MB × 2
class 17 ms 0.3 ms 3.2 MB

结论:读一样快;但凡有一次写入,struct 的复制成本肉眼可见;

但官方 Array 为什么没这么慢?→ 因为 Apple 给标准库做了 COW。

自己动手:给 struct 加上“写时复制”

思路:把实际数据放到引用类型的“盒子”里,再用 isUniquelyReferenced 判断是否需要复制。

final class BufferBox {          // 1. 真实数据放堆里
    var storage: [Int]
    init(_ storage: [Int]) { self.storage = storage }
}

struct COWArray {
    private var box: BufferBox   // 2. 结构体里只保存指针
    
    init() {
        box = BufferBox(Array(0..<100_000))
    }
    
    // 3. 读操作,直接透传
    subscript(index: Int) -> Int {
        box.storage[index]
    }
    
    // 4. 写操作,先检查唯一性
    mutating func set(_ index: Int, _ value: Int) {
        if !isKnownUniquelyReferenced(&box) {
            box = BufferBox(box.storage) // 复制
        }
        box.storage[index] = value
    }
}

关键点:

  • isKnownUniquelyReferenced 是 Swift 标准库函数,编译器帮你优化成“指针比较 + ARC 判断”。
  • 结构体本身仍是值语义,但只有写入时才真复制,读操作零额外开销。

什么时候该自己写 COW

场景 建议
自定义大集合(ImageData、顶点缓冲) 给 struct 加 COW,保值语义
小 Pod 模型 (< 64 Byte) 无脑 struct,复制成本低于 ARC 计数
需要线程安全 struct + COW 天然不可变,写时加锁即可
需要 NSCoding / @objc 用 class,省去桥接

线程安全番外:let class 可变隐患

final class Counter { var value = 0 }

let counter = Counter()   // let 只能锁定“指针”
DispatchQueue.concurrentPerform(iterations: 1000) { _ in
    counter.value += 1    // 未加锁 → 数据竞争
}
print(counter.value)      // 结果 < 1000

值类型就不会出现该问题——因为每个线程拿到的是独立副本。

在多核场景下,“值类型 + COW” 比 “class + 锁” 更容易写出无锁代码。

小结

  1. struct 是“复印机”,class 是“共享云文档”。
  2. Swift 官方推荐“默认 struct,不得不 class 才用 class”。
  3. 值类型/引用类型的区别不仅在于“复制”,更影响“线程安全”“生命周期”“性能”。
  4. 实际开发中两者经常嵌套:struct 保 immutable 语义,class 管共享状态与副作用。

Swift 字符串与字符完全导读(三):比较、正则、性能与跨平台实战

字符串比较的 3 个层次

比较方式 API 等价准则 复杂度 备注
字符相等 “==” 扩展字形簇 canonically equivalent O(n) 最常用
前缀 hasPrefix(:) UTF-8 字节逐段比较 O(m) m=前缀长度
后缀 hasSuffix(:) 同上,从后往前 O(m) 注意字形簇边界

示例

let precomposed = "café"                    // U+00E9
let decomposed  = "cafe\u{301}"             // e + ́
print(precomposed == decomposed)            // true ✅ 字形簇等价

let aEnglish = "A"  // U+0041
let aRussian = "А"  // U+0410 Cyrillic
print(aEnglish == aRussian)                 // false ❌ 视觉欺骗

Unicode 正规化(Normalization)

有时需要把“视觉上一样”的字符串统一到同一二进制形式,再做哈希或数据库唯一索引。

Swift 借助 Foundation 的 decomposedStringWithCanonicalMapping / precomposedStringWithCanonicalMapping

import Foundation

func normalized(_ s: String) -> String {
    s.decomposedStringWithCanonicalMapping
}

let set: Set<String> = [
    normalized("café"),
    normalized("cafe\u{301}")
]
print(set.count) // 1 ✅ 去重成功

Swift 5.7+ Regex 一站式入门

字面量构建

import RegexBuilder

let mdLink = Regex {
    "["                               // 字面左括号
    Capture { OneOrMore(.any) }       // 链接文字
    "]("
    Capture { OneOrMore(.any) }       // URL
    ")"
}

let text = "见 [官方文档](https://swift.org)。"
if let match = text.firstMatch(of: mdLink) {
    let (whole, title, url) = match.output
    print("文字:\(title)  地址:\(url)")
}

性能提示

  • 字面量 Regex 在编译期构建,零运行时解析成本;
  • 捕获组数量 < 5 时,使用静态 Output 类型,无堆分配。

切片 + 区间:一次遍历提取所有信息

需求:把 “/api/v1/users/9527” 拆成版本号与 ID

let path = "/api/v1/users/9527"
// 1. 找到两个数字区间
let versionRange = path.firstRange(of: /v\d+/)!          // Swift 5.7  Regex 作为区间
let idRange      = path.firstRange(of: /\d+$/)!

// 2. 切片
let version = path[versionRange]  // "v1"
let userID  = path[idRange]       // "9527"

关键点:

  • path[range] 返回 Substring,长期存需 String(...)
  • 正则区间可链式调用,避免多次扫描。

性能 Benchmark(M4 MacBook Pro, Release 构建)

测试 1:100 万次 “==” 比较

import QuartzCore
func measure(action: () -> Void) {
    let startTimeinterval = CACurrentMediaTime()
    action()
    let endTimeinterval = CACurrentMediaTime()
    print((endTimeinterval - startTimeinterval) * 1_000)
}

let s1 = "Swift字符串性能测试"
let s2 = "Swift字符串性能测试"

measure { for _ in 0..<1_000_000 { _ = s1 == s2 } }
//  耗时  0.0025 ms

测试 2:100 万次 hasPrefix

measure { for _ in 0..<1_000_000 { _ = s1.hasPrefix("Swift") } }
//  耗时  76 ms

测试 3:提取 Markdown 链接 10 万次

let blog = String(repeating: "见 [官方文档](https://swift.org)。\n", count: 10_000)
measure { _ = blog.matches(of: mdLink).map { $0.output } }
//  median  12 ms

结论:

  • 比较操作已高度优化,可放心用于字典 Key;
  • 正则采用静态构建后,与手写 Scanner 差距 < 5%。

常见“坑”与诊断工具

场景 现象 工具/修复
Substring 泄漏 百万行日志内存暴涨 Instruments → Allocations → 查看 “Swift String” 的 CoW 备份
整数下标越界 运行时 crash 使用 index(_, offsetBy:, limitedBy:) 安全版
正则回溯爆炸 卡住 100% CPU Regex 内使用 Possessive 量词或 OneOrMore(..., .eager)
比较失败 “é” != “é” 检查是否混入 Cyrillic / Greek 等视觉同形字符;打印 unicodeScalars 调试

终极最佳实践清单

  1. 比较:优先用 “==”,必要时先正规化再哈希。
  2. 前缀/后缀:用 hasPrefix / hasSuffix,别手写 prefix() 再比较。
  3. 索引:永远通过 String.Index 计算,禁止 str[Int]
  4. 子串:函数返回前立即 String(substring),防止隐式内存泄漏。
  5. 拼接:大量小字符串先用 [String] 收集,最后 joined();或 reserveCapacity 预分配。
  6. 正则:静态字面量 Regex 性能最佳;捕获组能少就少。
  7. 遍历:
    • 看“人眼字符”→ for ch in string
    • 看“UTF-8 字节”→ string.utf8
    • 看“Unicode 标量”→ string.unicodeScalars
  8. 多线程:String 是值类型,跨线程传递无数据竞争,但共享大字符串时 Substring 会拖住原内存,及时转存。
  9. 日志 / 模板:多行字面量 + 插值最清晰;需要原始反斜杠用扩展分隔符 #"..."#
  10. 性能测量:用 swift test -c release + measure 块, Instruments 只看 “Swift String” 的 CoW 备份次数。

Swift 字符串与字符完全导读(二):Unicode 视图、索引系统与内存陷阱

Unicode 的三种编码视图

Swift 把同一个 String 暴露成 4 种迭代方式:

视图 元素类型 单位长度 典型用途
String Character 人眼“一个字符” 业务逻辑
utf8 UInt8 1~4 字节 网络/文件 UTF-8 流
utf16 UInt16 2 或 4 字节 与 Foundation / Objective-C 交互
unicodeScalars UnicodeScalar 21-bit 精确到标量,做编码分析

代码一览

let dog = "Dog‼🐶"          
// 4 个 Character,5 个标量,10 个 UTF-8 字节,6 个 UTF-16 码元

// 1. Character 视图
for ch in dog {
    print(ch, terminator: "|")
}   // D|o|g|‼|🐶|
print()

// 2. UTF-8
dog.utf8.forEach {
    print($0, terminator: " ")
}// 68 111 103 226 128 188 240 159 144 182
print()

// 3. UTF-16
dog.utf16.forEach {
    print($0, terminator: " ")
}// 68 111 103 8252 55357 56374
print()

// 4. Unicode Scalars
dog.unicodeScalars.forEach {
    print($0.value, terminator: " ")
}// 68 111 103 8252 128054
print()

扩展字形簇 vs 字符计数

var cafe = "cafe"
print(cafe.count)           // 4
cafe += "\u{301}"           // 附加组合重音
print(cafe, cafe.count)     // café 4  (仍然是 4 个 Character)

结论:

  • count 走的是“字形簇”边界,必须从头扫描,复杂度 O(n)。
  • 不要在大循环里频繁读取 str.count;缓存到局部变量。

String.Index 体系

基础 API

let str = "Swift🚀"
let start = str.startIndex
let end   = str.endIndex        // 指向最后一个字符之后
// let bad = str[7]             // ❌ Compile-time error:Index 不是 Int

let fifth = str.index(start, offsetBy: 5)
print(str[fifth])             // 🚀

往前/后偏移

let prev = str.index(before: fifth)
let next = str.index(after: start)
let far  = str.index(start, offsetBy: 4, limitedBy: end) // 安全版,返回可选值

区间与切片

let range = start...fifth
let sub = str[range]            // Substring

子串 Substring 的“零拷贝”双刃剑

let article = "Swift String 深度指南"
let intro   = article.prefix(9) // Substring
// 此时整份 article 的缓冲区仍被 intro 强引用,内存不会释放

// 正确姿势:尽快转成 String
let introString = String(intro)

内存图简述

article ┌-------------------------┐
        │ Swift String 深度指南     │
        └-------------------------┘
          ▲
          │零拷贝
       intro (Substring)

只要 Substring 活着,原 String 的缓冲区就不得释放。

最佳实践:函数返回时立刻 String(substring),避免“隐形内存泄漏”。

插入、删除、Range 替换全 API 速查

var s = "Hello Swift"

// 插入字符
s.insert("!", at: s.endIndex)
// Hello Swift!
print(s)

// 插入字符串
s.insert(contentsOf: " 2025", at: s.index(before: s.endIndex))
// Hello Swift 2025!
print(s)

// 删除单个字符
s.remove(at: s.firstIndex(of: " ")!)        // 删掉第一个空格
// HelloSwift 2025!
print(s)

// 删除子范围
let range = s.range(of: "Swift")!
s.removeSubrange(range)
// Hello 2025!
print(s)

// 直接替换
s.replaceSubrange(s.range(of: "2025")!, with: "2026")
// Hello 2026!
print(s)

实战:写一个“安全截断”函数

需求

  • 按“字符数”截断,但不能把 Emoji/组合音标劈成两半;
  • 尾部加“...”且总长度不超过 maxCount;
  • 返回 String,而非 Substring。

代码

func safeTruncate(_ text: String, maxCount: Int, suffix: String = "...") -> String {
    guard maxCount > suffix.count else { return suffix }
    let maxTextCount = maxCount - suffix.count
    var count = 0
    var idx = text.startIndex
    while idx < text.endIndex && count < maxTextCount {
        idx = text.index(after: idx)
        count += 1
    }
    // 如果原文很短,无需截断
    if idx == text.endIndex { return text }
    return String(text[..<idx]) + suffix
}

// 测试
let long = "Swift 字符串深度指南🚀🚀🚀"
print(safeTruncate(long, maxCount: 12)) // "Swift 字符..."

复杂度 O(n),只扫描一次;不依赖 count 的重复计算。

性能与内存最佳实践清单

  1. 大量拼接用 String.reserveCapacity(_:) 预分配。
  2. 遍历+修改时先复制到 var,再批量改,减少中间临时对象。
  3. 网络/文件 IO 用 utf8 视图直接写入 Data,避免先转 String
  4. 正则提取到的 [Substring] 尽快 map 成 [String] 再长期持有。
  5. 不要缓存 str.count 在多次循环外,如果字符串本身在变。

扩展场景:今天就能落地的 3 段代码

日志脱敏(掩码手机号)

func maskMobile(_ s: String) -> String {
    guard s.count == 11 else { return s }
    let start = s.index(s.startIndex, offsetBy: 3)
    let end   = s.index(s.startIndex, offsetBy: 7)
    return s.replacingCharacters(in: start..<end, with: "****")
}

语法高亮(简易关键词着色)

let keywords = ["let", "var", "func"]
var code = "let foo = 1"
for kw in keywords {
    if let range = code.range(of: kw) {
        code.replaceSubrange(range, with: "[KW]\(kw)[KW]")
    }
}

大文件分块读(UTF-8 视图直接操作)

import Foundation
func chunk(path: String, chunkSize: Int = 1<<14) -> [String] {
    guard let data = FileManager.default.contents(atPath: path) else { return [] }
    return data.split(separator: UInt8(ascii: "\n"),
                      maxSplits: .max,
                      omittingEmptySubsequences: false)
               .map { String(decoding: $0, as: UTF8.self) }
}

利用 UInt8 切片,避免先整体转成 String 的额外内存峰值。

Swift 字符串与字符完全导读(一):从字面量到 Unicode 的实战之旅

前言

Swift 的 String 看起来“像 NSString 的弟弟”,但骨子里是一套全新的 Unicode 抽象模型。

String 与 Character 的本质

  • String:由“扩展字形簇”(extended grapheme cluster)构成的有序集合。
  • Character:一个扩展字形簇,人类眼中的“一个字符”,占用的字节数可变。
// 1 个 Character,由 2 个 Unicode 标量合成
let eAcute: Character = "é"                 // “é”
let eCombining: Character = "\u{65}\u{301}" // “e” + “́” 组合
print(eAcute == eCombining) // true,两者字形簇等价

字符串字面量:单行、多行、转义、扩展分隔符

单行字面量

let msg = "Hello, Swift" // 类型自动推断为 String

多行字面量

let html = """
           <div>
               <p>Hello</p>
           </div>
           """ // 缩进 4 空格会被自动去掉,因为闭合 """ 在最左侧第 12 列
           // 闭合"""左侧的空格会被删除,每一行左侧的同等长度的空格都会被删除

换行控制技巧

let sql = """
          SELECT * FROM user \
          WHERE age > 18
          """ // 反斜杠让源码换行,但字符串里无换行

转义序列

let special = "双引号:\",制表:\t,换行:\n,Unicode:\u{1F496}"

扩展分隔符(#)

场景:正则、JSON 模板里想保留原始反斜杠。

let raw = #"Raw \n still two characters"#
let needEscape = #"Use \#n to enable line break"#
print(raw)       // 输出:Raw \n still two characters
print(needEscape)// 输出:Use
                 //       to enable line break

多行 + 扩展分隔符

let mlRaw = #"""
            Line 1
            Line 2
            """#

空字符串的 3 种创建方式

let a = ""
let b = String()
let c = String("") // 与前两种等价
print(a.isEmpty) // true

可变性:let 与 var 的抉择

let immutable = "can't change"
// immutable += "!" // ❌ Compile-time error

var mutable = "hello"
mutable += ", world" // ✅

值类型:写时复制(COW)到底发生了什么

func foo(_ s: String) {
    var local = s   // 此时未复制,共享同一块缓冲区
    local += "!"    // 突变触发复制,O(n) 成本
    print(local)
}

底层优化:仅当本地突变或跨线程时才真正拷贝,因此作为入参传递时无需担心性能。

字符集合:遍历、提取、构造

let word = "Swift"
for ch in word {
    print(ch, terminator: "-") // S-w-i-f-t-
}

let single: Character = "A"
let fromChars = String([single, "B", "C"]) // "ABC"

字符串拼接的 5 种姿势

let left  = "Hello"
let right = "World"

// 1. 加法
let s1 = left + ", " + right

// 2. 加法赋值
var s2 = left
s2 += ", " + right

// 3. append(Character)
var s3 = left
s3.append(",")
s3.append(" ")
s3.append(Character(right)) // 仅当 right 长度=1 时安全

// 4. append(contentsOf: String)
var s4 = left
s4.append(contentsOf: ", \(right)")

// 5. 多行拼接注意最后一行换行
let goodStart = """
                Line 1
                Line 2
                """
let end       = """
                Line 3
                """
let merged = goodStart + end // 3 行,无意外合并

字符串插值:最灵活的“模板引擎”

let name = "Swift"
let year = 2025
let msg = "Hello, \(name)! In \(year + 1) we will rock."
print(msg) // Hello, Swift! In 2026 we will rock.

// 在扩展分隔符中使用插值
let rawLog = #"Level \#(name) recorded at \#(Date())"#

扩展场景:今天就能用上的 3 个小工具

彩色命令行日志

func log(_ info: String) {
    print(#"\u{1B}[32m[INFO]\#(info)\u{1B}[0m"#)
}
log("Server started") // 终端绿色输出

快速 Mock JSON

let userId = 9527
let json = #"""
           {"id": \#(userId), "name": "Alice"}
           """#
print(json) // 直接贴进 Postman 即可

多行 SQL 模板

let table = "user"
let sql = """
          SELECT *
          FROM \(table)
          WHERE status = 'active'
            AND created_at > ?
          """

你的错误处理一团糟-是时候修复它了-🛠️

GitHub 主页

你的错误处理一团糟,是时候修复它了!🛠️

我还记得那个让我彻夜难眠的 bug。一个支付回调接口,在处理一个罕见的、来自第三方支付网关的异常状态码时,一个Promise链中的.catch()被无意中遗漏了。结果呢?没有日志,没有警报,服务本身也没有崩溃。它只是“沉默地”失败了。那个用户的订单状态永远停留在了“处理中”,而我们,对此一无所知。直到一周后,在对账时我们才发现,有数百个这样的“沉默订单”,造成了数万美元的损失。💸

这个教训是惨痛的。它让我明白,在软件工程中,我们花在处理成功路径上的时间,可能还不到 10%。剩下 90%的复杂性,都来自于如何优雅、健壮地处理各种预料之中和意料之外的错误。 而一个框架的优劣,很大程度上就体现在它如何引导我们去面对这个“错误的世界”。

很多框架,尤其是那些动态语言的“灵活”框架,它们在错误处理上的哲学,几乎可以说是“放任自流”。它们给了你一万种犯错的可能,却只给了你一种需要极度自律才能做对的方式。

回调地狱与被吞噬的Promise:JavaScript 的错误处理之殇

在 Node.js 的世界里,我们经历了一场漫长的、与错误作斗争的进化史。

阶段一:回调地狱 (Callback Hell)

老一辈的 Node.js 开发者都还记得被“金字塔”支配的恐惧。

function processOrder(orderId, callback) {
  db.findOrder(orderId, (err, order) => {
    if (err) {
      // 错误处理点 1
      return callback(err);
    }
    payment.process(order, (err, result) => {
      if (err) {
        // 错误处理点 2
        return callback(err);
      }
      inventory.update(order.items, (err, status) => {
        if (err) {
          // 错误处理点 3
          return callback(err);
        }
        callback(null, status); // 成功!
      });
    });
  });
}

这种“错误优先”的回调风格,在理论上是可行的。但随着业务逻辑的复杂化,代码会向右无限延伸,形成一个难以维护的“死亡金字塔”。你必须在每一个回调里,都记得去检查那个err对象。只要有一次疏忽,错误就会被“吞掉”。

阶段二:Promise的救赎与新的陷阱

Promise的出现,把我们从回调地狱中解救了出来。我们可以用.then().catch()来构建一个更扁平、更易读的异步链。

function processOrder(orderId) {
  return db
    .findOrder(orderId)
    .then((order) => payment.process(order))
    .then((result) => inventory.update(result.items))
    .catch((err) => {
      // 统一的错误处理点
      console.error('Order processing failed:', err);
      // 但这里,你必须记得向上抛出错误,否则调用者会认为成功了
      throw err;
    });
}

这好多了!但新的问题又来了。如果你在一个.then()里忘记了return下一个Promise,或者在一个.catch()里忘记了重新throw错误,这个链条就会以一种你意想不到的方式继续执行下去。错误,再一次被“沉默地”吞噬了。

阶段三:async/await的优雅与最后的伪装

async/await让我们能用看似同步的方式来编写异步代码,这简直是天赐的礼物。

async function processOrder(orderId) {
  try {
    const order = await db.findOrder(orderId);
    const result = await payment.process(order);
    const status = await inventory.update(result.items);
    return status;
  } catch (err) {
    console.error('Order processing failed:', err);
    throw err;
  }
}

这看起来已经很完美了,不是吗?但它依然依赖于程序员的“自觉”。你必须记得把所有可能出错的异步调用都包在一个try...catch块里。如果你忘了await一个返回Promise的函数呢?那个函数里的错误将永远不会被这个try...catch捕获。

JavaScript 的问题在于,错误是一个可以被轻易忽略的值nullundefined可以像幽灵一样在你的代码里游荡。你需要依靠严格的规范、Linter 工具和个人纪律,才能确保每一个错误都被正确处理。而这,恰恰是不可靠的。

Result枚举:当编译器成为你最可靠的错误处理伙伴

现在,让我们进入 Rust 和 hyperlane 的世界。在这里,错误处理的哲学是完全不同的。Rust 语言的核心,有一个叫做Result<T, E>的枚举类型。

enum Result<T, E> {
   Ok(T),  // 代表成功,并包含一个值
   Err(E), // 代表失败,并包含一个错误
}

这个设计,简单而又深刻。它意味着一个可能失败的函数,它的返回值必然是这两种状态之一。它不再是一个可能为null的值,或者一个需要你在别处.catch()Promise。它是一个完整的、包含了所有可能性的类型。

最关键的是,编译器会强制你处理Err的情况。如果你调用一个返回Result的函数,却不处理它的Err分支,编译器会直接给你一个警告甚至错误。你不可能“不小心”忽略一个错误。

让我们看看在 hyperlaneservice 层,代码会是什么样子:

// 在一个 service 文件中
pub fn process_order(order_id: &str) -> Result<Status, OrderError> {
    let order = db::find_order(order_id)?; // `?` 操作符:如果失败,立即返回Err
    let result = payment::process(order)?;
    let status = inventory::update(result.items)?;
    Ok(status) // 明确返回成功
}

看到那个?操作符了吗?它是 Rust 错误处理的精髓。它相当于在说:“调用这个函数,如果它返回Ok(value),就把value取出来继续执行;如果它返回Err(error),就立刻从当前函数返回这个Err(error)。”

这种模式,把之前 JavaScript 中需要try...catch才能实现的逻辑,变成了一种极其简洁、清晰、且由编译器保证安全的链式调用。错误,不再是需要被“捕获”的异常,而是数据流中一个可预期的、被优雅处理的分支。

panic_hook:最后的防线

当然,总有一些错误是我们无法预料的,也就是panic(恐慌)。比如数组越界、整数溢出等。在很多框架中,一个未被捕获的panic会导致整个线程甚至进程崩溃。

hyperlane 提供了一个优雅的“最后防线”——panic_hook。我们在之前的文章中已经见过它的身影:

async fn panic_hook(ctx: Context) {
    let error: Panic = ctx.try_get_panic().await.unwrap_or_default();
    let response_body: String = error.to_string();
    eprintln!("{}", response_body); // 记录详细的错误日志

    // 向客户端返回一个标准的、安全的 500 错误响应
    let _ = ctx
        .set_response_status_code(500)
        .await
        .set_response_body("Internal Server Error")
        .await
        .send()
        .await;
}

// 在 main 函数中注册它
server.panic_hook(panic_hook).await;

这个钩子能捕获任何在请求处理过程中发生的panic。它能防止服务器直接崩溃,并允许你记录下详细的错误信息用于事后分析,同时给客户端返回一个友好的错误页面,而不是一个断开的连接。这是一种极其负责任和健壮的设计。

别再祈祷代码不出错了,从一开始就拥抱错误

好的错误处理,不是在代码的各个角落里都塞满try...catch。它是从语言和框架层面,就为你提供一套机制,让“失败”成为程序流程中一个可预期的、一等公民。

Rust 的Result枚举强迫你直面每一个可能的失败,而hyperlane的架构和钩子系统则为你提供了处理这些失败的优雅模式。它把错误处理从一种“开发者纪律”,变成了一种“编译器保证”。

所以,如果你还在为那混乱的错误处理逻辑而头痛,为那些“沉默”的失败而恐惧,那么问题可能真的不在于你不够努力,而在于你选择的工具,从一开始就没有把“健壮性”放在最重要的位置。是时候,选择一个能和你并肩作战,共同面对这个不完美世界的伙伴了。

GitHub 主页

《Flutter全栈开发实战指南:从零到高级》- 04 - Widget核心概念与生命周期

Flutter Widget核心概念与生命周期

掌握Flutter UI构建的基石,告别"面向谷歌编程"

前言:为什么Widget如此重要?

还记得我刚开始学Flutter的时候,最让我困惑的就是那句"Everything is a Widget"。当时我想,这怎么可能呢?按钮是Widget,文字是Widget,连整个页面都是Widget,这也太抽象了吧!

经过几个实际项目的打磨,我才真正明白Widget设计的精妙之处。今天我就用最通俗易懂的方式,把我踩过的坑和总结的经验都分享给大家。

1. StatelessWidget vs StatefulWidget:静态与动态的艺术

1.1 StatelessWidget:一次成型的雕塑

通俗理解:就像一张照片,拍好之后内容就固定不变了。

// 用户信息卡片 - 典型的StatelessWidget
class UserCard extends StatelessWidget {
  // 这些final字段就像雕塑的原材料,一旦设定就不能改变
  final String name;
  final String email;
  final String avatarUrl;
  
  // const构造函数让Widget可以被Flutter优化
  const UserCard({
    required this.name,
    required this.email,
    required this.avatarUrl,
  });
  
  @override
  Widget build(BuildContext context) {
    // build方法描述这个Widget长什么样
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Row(
          children: [
            CircleAvatar(backgroundImage: NetworkImage(avatarUrl)),
            SizedBox(width: 16),
            Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(name, style: TextStyle(fontWeight: FontWeight.bold)),
                Text(email, style: TextStyle(color: Colors.grey)),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

使用场景总结

  • ✅ 显示静态内容(文字、图片)
  • ✅ 布局容器(Row、Column、Container)
  • ✅ 数据完全来自父组件的展示型组件
  • ✅ 不需要内部状态的纯UI组件

1.2 StatefulWidget:有记忆的智能助手

举个例子:就像一个智能闹钟,它能记住你设置的时间,响应用户操作。

// 计数器组件 - 典型的StatefulWidget
class Counter extends StatefulWidget {
  @override
  _CounterState createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _count = 0; // 状态数据,可以变化
  
  void _increment() {
    // setState告诉Flutter:状态变了,请重新构建UI
    setState(() {
      _count++;
    });
  }
  
  void _decrement() {
    setState(() {
      _count--;
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('当前计数: $_count', style: TextStyle(fontSize: 24)),
        SizedBox(height: 20),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(onPressed: _decrement, child: Text('减少')),
            SizedBox(width: 20),
            ElevatedButton(onPressed: _increment, child: Text('增加')),
          ],
        ),
      ],
    );
  }
}

使用场景总结

  • ✅ 需要用户交互(按钮、输入框)
  • ✅ 有内部状态需要管理
  • ✅ 需要执行初始化或清理操作
  • ✅ 需要响应数据变化

1.3 选择指南:我的实用判断方法

刚开始我经常纠结该用哪种,后来总结了一个简单的方法:

问自己三个问题

  1. 这个组件需要记住用户的操作吗?
  2. 组件的数据会自己变化吗?
  3. 需要执行初始化或清理操作吗?

如果答案都是"否",用StatelessWidget;如果有一个"是",就用StatefulWidget。

2. Widget生命周期:从出生到退休的完整旅程

2.1 生命周期全景图

我把Widget的生命周期比作人的职业生涯,这样更容易理解:

class LifecycleExample extends StatefulWidget {
  @override
  _LifecycleExampleState createState() => _LifecycleExampleState();
}

class _LifecycleExampleState extends State<LifecycleExample> {
  // 1. 构造函数 - 准备简历
  _LifecycleExampleState() {
    print('📝 构造函数:创建State对象');
  }
  
  // 2. initState - 办理入职
  @override
  void initState() {
    super.initState();
    print('🎯 initState:组件初始化完成');
    // 在这里初始化数据、注册监听器
  }
  
  // 3. didChangeDependencies - 熟悉环境
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    print('🔄 didChangeDependencies:依赖发生变化');
    // 当父组件或全局数据变化时调用
  }
  
  // 4. build - 开始工作
  @override
  Widget build(BuildContext context) {
    print('🎨 build:构建UI界面');
    return Container(child: Text('生命周期演示'));
  }
  
  // 5. didUpdateWidget - 岗位调整
  @override
  void didUpdateWidget(LifecycleExample oldWidget) {
    super.didUpdateWidget(oldWidget);
    print('📝 didUpdateWidget:组件配置更新');
    // 比较新旧配置,决定是否需要更新状态
  }
  
  // 6. deactivate - 办理离职
  @override
  void deactivate() {
    print('👋 deactivate:组件从树中移除');
    super.deactivate();
  }
  
  // 7. dispose - 彻底退休
  @override
  void dispose() {
    print('💀 dispose:组件永久销毁');
    // 清理资源:取消订阅、关闭控制器等
    super.dispose();
  }
}

2.2 生命周期流程图

创建阶段:
createState() → initState() → didChangeDependencies() → build()

更新阶段:
setState() → build()  或  didUpdateWidget() → build()

销毁阶段:
deactivate() → dispose()

2.3 实战经验:我踩过的那些坑

坑1:在initState中访问Context

// ❌ 错误做法
@override
void initState() {
  super.initState();
  Theme.of(context); // Context可能还没准备好!
}

// ✅ 正确做法  
@override
void didChangeDependencies() {
  super.didChangeDependencies();
  Theme.of(context); // 这里才是安全的
}

坑2:忘记清理资源

@override
void initState() {
  super.initState();
  _timer = Timer.periodic(Duration(seconds: 1), _onTick);
}

// ❌ 忘记在dispose中取消定时器
// ✅ 一定要在dispose中清理
@override
void dispose() {
  _timer?.cancel(); // 重要!
  super.dispose();
}

坑3:异步操作中的setState

Future<void> fetchData() async {
  final data = await api.getData();
  
  // ❌ 直接调用setState
  // setState(() { _data = data; });
  
  // ✅ 先检查组件是否还在
  if (mounted) {
    setState(() {
      _data = data;
    });
  }
}

当然还有很多其他的坑,这里就不一一介绍了,感兴趣的朋友可以留言,看到一定会回复~

3. BuildContext:组件的身份证和通信证

3.1 BuildContext的本质

简单来说,BuildContext就是组件在组件树中的"身份证"。它告诉我们:

  • 这个组件在树中的位置
  • 能访问哪些祖先组件提供的数据
  • 如何与其他组件通信
class ContextExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 使用Context获取主题信息
    final theme = Theme.of(context);
    
    // 使用Context获取设备信息  
    final media = MediaQuery.of(context);
    
    // 使用Context进行导航
    void navigateToDetail() {
      Navigator.of(context).push(MaterialPageRoute(
        builder: (context) => DetailPage(),
      ));
    }
    
    return Container(
      color: theme.primaryColor,
      width: media.size.width * 0.8,
      child: ElevatedButton(
        onPressed: navigateToDetail,
        child: Text('跳转到详情'),
      ),
    );
  }
}

3.2 Context的层次结构

想象一下组件树就像公司组织架构:

  • 每个组件都有自己的Context
  • Context知道自己的"上级"(父组件)
  • 可以通过Context找到"领导"(祖先组件)
// 查找特定类型的祖先组件
final scaffold = context.findAncestorWidgetOfExactType<Scaffold>();

// 获取渲染对象
final renderObject = context.findRenderObject();

// 遍历子组件
context.visitChildElements((element) {
  print('子组件: ${element.widget}');
});

3.3 常见问题解决方案

问题:Scaffold.of()找不到Scaffold

// ❌ 可能失败
Widget build(BuildContext context) {
  return ElevatedButton(
    onPressed: () {
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(
        content: Text('Hello'),
      ));
    },
    child: Text('显示提示'),
  );
}

// ✅ 使用Builder确保正确的Context
Widget build(BuildContext context) {
  return Builder(
    builder: (context) {
      return ElevatedButton(
        onPressed: () {
          ScaffoldMessenger.of(context).showSnackBar(SnackBar(
            content: Text('Hello'),
          ));
        },
        child: Text('显示提示'),
      );
    },
  );
}

4. 组件树与渲染原理:Flutter的三大支柱

4.1 三棵树架构:设计图、施工队和建筑物

我用建筑行业来比喻Flutter的三棵树,这样特别容易理解:

Widget树 = 建筑设计图

  • 描述UI应该长什么样
  • 配置信息(颜色、尺寸、文字等)
  • 不可变的(immutable)

Element树 = 施工队

  • 负责按照图纸施工
  • 管理组件生命周期
  • 可复用的

RenderObject树 = 建筑物本身

  • 实际可见的UI
  • 负责布局和绘制
  • 性能关键

4.2 渲染流程详解

阶段1:构建(Build)

// Flutter执行build方法,创建Widget树
Widget build(BuildContext context) {
  return Container(
    color: Colors.blue,
    child: Row(
      children: [
        Text('Hello'),
        Icon(Icons.star),
      ],
    ),
  );
}

阶段2:布局(Layout)

  • 计算每个组件的大小和位置
  • 父组件向子组件传递约束条件
  • 子组件返回自己的尺寸

阶段3:绘制(Paint)

  • 将组件绘制到屏幕上
  • 只绘制需要更新的部分
  • 高效的重绘机制

4.3 setState的工作原理

很多人对setState有误解,以为它直接更新UI。其实过程是这样的:

  1. 标记脏状态:setState标记当前Element为"脏"
  2. 重新构建Widget:调用build方法生成新的Widget
  3. 对比更新:比较新旧Widget的差异
  4. 更新RenderObject:只更新发生变化的部分
  5. 重绘:在屏幕上显示更新
void _updateCounter() {
  setState(() {
    // 1. 这里的代码同步执行
    _counter++;
  });
  // 2. setState完成后,Flutter会安排一帧来更新UI
  // 3. 不是立即更新,而是在下一帧时更新
}

5. 性能优化实战技巧

5.1 减少不必要的重建

// ❌ 不好的做法:在build中创建新对象
Widget build(BuildContext context) {
  return ListView(
    children: [
      ItemWidget(), // 每次build都创建新实例
      ItemWidget(),
    ],
  );
}

// ✅ 好的做法:使用const或成员变量
class MyWidget extends StatelessWidget {
  // 这些Widget只创建一次
  static const _itemWidgets = [
    ItemWidget(),
    ItemWidget(),
  ];
  
  @override
  Widget build(BuildContext context) {
    return ListView(children: _itemWidgets);
  }
}

5.2 合理使用const

// ✅ 尽可能使用const
const Text('Hello World');
const SizedBox(height: 16);
const Icon(Icons.star);

// 对于自定义Widget,也可以使用const构造函数
class MyWidget extends StatelessWidget {
  const MyWidget({required this.title});
  final String title;
  
  @override
  Widget build(BuildContext context) {
    return Text(title);
  }
}

5.3 使用Key优化列表

ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ListItem(
      key: ValueKey(items[index].id), // 帮助Flutter识别项的身份
      item: items[index],
    );
  },
)

5.4 避免在build中执行耗时操作

// ❌ 不要在build中做这些
Widget build(BuildContext context) {
  // 网络请求
  // 复杂计算
  // 文件读写
  
  return Container();
}

// ✅ 在initState或专门的方法中执行
@override
void initState() {
  super.initState();
  _loadData();
}

Future<void> _loadData() async {
  final data = await api.fetchData();
  if (mounted) {
    setState(() {
      _data = data;
    });
  }
}

6. 实战案例:构建高性能列表

让我分享一个实际项目中的优化案例:

class ProductList extends StatefulWidget {
  @override
  _ProductListState createState() => _ProductListState();
}

class _ProductListState extends State<ProductList> {
  final List<Product> _products = [];
  bool _isLoading = false;
  
  @override
  void initState() {
    super.initState();
    _loadProducts();
  }
  
  Future<void> _loadProducts() async {
    if (_isLoading) return;
    
    setState(() => _isLoading = true);
    try {
      final products = await ProductApi.getProducts();
      setState(() => _products.addAll(products));
    } finally {
      if (mounted) {
        setState(() => _isLoading = false);
      }
    }
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('商品列表')),
      body: _buildContent(),
    );
  }
  
  Widget _buildContent() {
    if (_products.isEmpty && _isLoading) {
      return Center(child: CircularProgressIndicator());
    }
    
    return ListView.builder(
      itemCount: _products.length + (_isLoading ? 1 : 0),
      itemBuilder: (context, index) {
        if (index == _products.length) {
          return _buildLoadingIndicator();
        }
        
        final product = _products[index];
        return ProductItem(
          key: ValueKey(product.id), // 重要:使用Key
          product: product,
          onTap: () => _showProductDetail(product),
        );
      },
    );
  }
  
  Widget _buildLoadingIndicator() {
    return Padding(
      padding: EdgeInsets.all(16),
      child: Center(child: CircularProgressIndicator()),
    );
  }
  
  void _showProductDetail(Product product) {
    Navigator.of(context).push(MaterialPageRoute(
      builder: (context) => ProductDetailPage(product: product),
    ));
  }
  
  @override
  void dispose() {
    // 清理工作
    super.dispose();
  }
}

7. 调试技巧:快速定位问题

7.1 使用Flutter Inspector

  • 在Android Studio或VS Code中打开Flutter Inspector
  • 查看Widget树结构
  • 检查渲染性能
  • 调试布局问题

7.2 打印生命周期日志

@override
void initState() {
  super.initState();
  debugPrint('$runtimeType initState');
}

@override
void dispose() {
  debugPrint('$runtimeType dispose');  
  super.dispose();
}

7.3 性能分析工具

  • 使用Flutter Performance面板
  • 检查帧率(目标是60fps)
  • 识别渲染瓶颈
  • 分析内存使用情况

最后的话

学习Flutter Widget就像学骑自行车,开始可能会摔倒几次,但一旦掌握了平衡,就能自由驰骋。记住几个关键点:

  1. 多动手实践 - 光看理论是不够的
  2. 理解原理 - 知道为什么比知道怎么做更重要
  3. 循序渐进 - 不要想一口吃成胖子
  4. 善用工具 - Flutter提供了很好的调试工具

我在学习过程中最大的体会是:每个Flutter高手都是从不断的踩坑和总结中成长起来的。希望我的经验能帮你少走一些弯路。


🎯 写这篇文章花了我很多时间,如果对你有帮助,动动发财的小手来个一键三连!

你的支持真的对我很重要!有什么问题欢迎在评论区留言,我会尽力解答。 我们下篇文章见! 🚀

去 Apple Store 修手机 | 肘子的 Swift 周报 #0107

issue107.webp

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

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

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

去 Apple Store 修手机

父亲的 iPhone 16 突然无法充电。预约后,我前往 Apple Store 送修。工作人员确认问题后,为我提供了一部 iPhone 14 作为备用机,并协助完成数据转移。十二天后(期间正好赶上一个长假),设备维修完成——更换了 Type-C 接口,同时还免费更换了一块新电池。体验一如既往地令人满意。

这些年来,我修过不少苹果设备。印象较深的几次包括:因“显卡门”事件,MacBook Pro 免费更换主板;2011 款 iMac 27 英寸因屏幕进灰,免费更换显示屏。其他一些小问题,如果时机合适,有时会直接换新设备。

网络上确实不乏关于 Apple Store 或授权维修商的不愉快经历。但就我个人而言,多次维修体验都算顺利——或许得益于对设备问题的充分了解,以及始终保持友好的沟通态度。

一个有趣的观察是:Apple Store 里有相当多的老年用户。与天才吧工作人员闲聊时得知,许多年轻人会把淘汰的 iPhone 或 iPad 送给长辈,或直接购买新设备作为礼物,但往往没有时间教他们使用。于是,天才吧相当一部分工作量,变成了帮助老年人注册账户、安装应用、指导基本操作。

天才吧的存在,让全年龄段用户都能享受科技的便利——既服务了消费者,也增强了品牌的用户粘性。如今许多其他品牌也开设了规模不小的线下门店,但大多仍停留在展示与销售层面。在网购已成主流的时代,实体店的价值早已超越“卖产品”,更在于那种人与人面对面交流的温度——这,正是 Apple Store 的重要魅力所在。

前一期内容全部周报列表

近期推荐

SwiftUI 应用热重载方案 (Hot Reloading SwiftUI Apps)

虽然 Xcode Preview 为开发者带来了极大便利,可以即时查看 UI 的变化,但它仍存在不少限制。Daniel Hooper 在本文中展示了一种巧妙的热重载方案:将 UI 与应用逻辑编译为动态库(dynamic library),并由宿主 App 在运行时加载。当代码变更时,重新加载新库并替换旧库。这一方案的亮点在于:支持完整应用运行、可保留状态,不依赖 Xcode。整个实现仅需约 120 行代码,充分体现了“理解原理后,复杂功能也能以简洁方式实现”的魅力。


精通 UITableViewDiffableDataSource

尽管 SwiftUI 的列表能力持续进步,但在大数据量、复杂交互或需要精细控制的场景中,UITableView 依然不可替代。相较于 SwiftUI 的声明式简洁,UITableView 的数据源管理更易出错,批量更新也常因数据与 UI 不一致而崩溃。Kingnight (Jinkai) 通过一个功能完备的音乐播放列表示例,系统讲解了 UITableViewDiffableDataSource 的现代用法。

文章不仅覆盖基础,还深入对比 reconfigureItemsreloadItems 的性能取舍,拆解拖拽重排与滑动删除的实现,并通过 BaseReorderableDiffableDataSource 与 DiffableTableAdapter 给出可复用的架构实践。无论是在 SwiftUI 项目中集成 UIKit 列表,还是维护现有 UIKit 项目,这都是一份扎实的现代化指南。


iPhone 17 屏幕尺寸 (iPhone 17 Screen Sizes)

iPhone 17 系列的屏幕配置迎来重大调整:Plus 型号被取消,取而代之的是全新的 iPhone Air(6.5 英寸)。基础版 iPhone 17 与 Pro 版共享同一块 6.3 英寸显示屏,这也意味着基础款首次获得 ProMotion 与 Always-On Display 等 Pro 级特性。值得注意的是,所有新机型在横屏模式下新增了 20 pt 的顶部安全区内边距。

Keith Harrison 整理了所有 iPhone(及 iPod touch)自 iOS 15 起的完整屏幕尺寸与安全区(Safe Area Insets),并更新了 App Store 截图要求:开发者可继续使用 6.9 英寸(1320 × 2868)或 6.5 英寸(1242 × 2688)规格上传主截图。


超越 QA:移动测试策略 (Beyond QA: Mobile Testing Strategies)

移动应用无法“热修复”,一旦上线崩溃,就要经历审核与分阶段发布,因此预防远比补救重要。Tjeerd in ’t Veen 通过“组合爆炸”问题深入分析了为何仅依赖手动测试远远不够,并探讨了如何构建更全面的移动测试体系。

文章对多种测试策略进行了权衡:手动测试擅长发现 UI 与交互问题,但难以扩展;UI 测试可并行运行大规模流程,但维护成本高;快照测试能捕获视觉回归,却需维护参考图像库。作者建议采用混合策略——以单元测试和 UI 测试覆盖 90% 的功能,用手动测试验证真实网络环境和视觉细节,并将 UI 测试设为可选或定期运行,以避免阻塞开发流程。


用 Swift Subprocess 实现自动化 (Automate All the Things with Swift Subprocess)

在本文中,Jacob Bartlett 用多个示例探讨了如何借助 swift-subprocess——一个旨在以 Swift 的现代特性取代老旧 Process(NSTask)API、简化进程管理的新库——改善 Swift 的脚本化编程体验。对于简单脚本而言,swift-subprocess 仍显笨重:必须创建完整的 SPM 项目,首次运行的依赖解析与编译开销让人怀疑这是否还称得上“脚本”。但在更复杂的自动化工作流(如 CI/CD 流程)中,Swift 的类型安全、模块化与可维护性则展现出优势,虽不如 Bash 精简,却更利于组织与复用。

Jacob 指出,在 LLM 辅助编程时代,Bash 脚本的边际成本几乎为零,且模型对 Bash 的掌握远超 Swift。是否采用 swift-subprocess,应基于实际需求,而非追求“用 Swift 统一所有工具链”的理想。


Swift 并发:那些早该知道的事 (Swift Concurrency: What I Wish Someone Had Told Me)

在过去的六个月中,Bruno Valente Pimentel 完成了三个应用的 Swift 6 并发迁移,他在本文中分享了那些文档里不会提及的“血泪教训”。Bruno 揭示了几个关键陷阱:@MainActor 只保护同步访问,await 之后的世界充满未知;Actor 重入性会引发隐蔽的竞态,需以“任务去重”的方式规避;而过度追求代码洁癖(强制 Sendable)会拖慢进度,可先用 @unchecked Sendable@preconcurrency 解锁开发,再逐步还债。

作者的感悟是:目标不是完美,而是创造比昨天更好的可用软件。


SwiftUI TextEditor 富文本编辑 (Using Rich Text in the TextEditor with SwiftUI)

在 WWDC 2025 中,苹果为 TextEditor 带来了期待已久的富文本支持,让开发者可以直接使用 AttributedString 在 SwiftUI 中编写和编辑样式化文本。Alfonso Tarallo 在文中演示了从基础到编辑交互(选区、属性变换)的完整流程,并特别指出 transformAttributes(in:) 的设计巧妙:它以 inout 方式处理选区,自动合并相邻属性片段,从而避免文本碎片化。

TextEditor 的富文本能力高度依赖于 Foundation 层面对 AttributedString 的重大增强。正如 WWDC Session 中 Jeremy 所强调的,AttributedString 的索引是“一条穿过树的路径”,这种设计虽强大,却也更复杂。要真正用好这一特性,开发者不仅需要学习新 API,更要理解一种全新的文本处理范式。


在 Swift Concurrency 中处理单例 (Singletons with Swift Concurrency)

单例作为“全局可变状态”,在 Swift Concurrency 的严格模型下成了棘手难题。Matt Massicotte 提供了一份务实的迁移指南,其核心理念是——“向编译器如实表达并发事实”(expressing truth)。

文章系统分析了多种处理方式:如果类型已具备线程安全机制,可使用 @unchecked Sendable 如实声明;若主要在主线程访问,@MainActor 是最诚实且高效的选择。而将类直接改为 actor 或使用自定义全局 actor 虽然能彻底隔离状态,却往往导致过度工程化。Matt 建议开发者与其掩盖现有并发访问模式,不如明确告诉编译器实际情况,让隐形风险显性化。

几天前的一个 Reddit 讨论 引发了热烈争论,Matt 也参与其中。这篇文章是他对该话题的系统性回应,将“如实表达”的原则贯穿始终。

工具

AsyncCombine

虽然 Apple 明确将 Swift Concurrency 作为未来方向,但许多开发者在从 Combine 迁移后都感受到代码可读性的下降——原本简洁的响应式管道,变成了冗长的 for await 循环与手动任务管理。为此, William Lumley 开发了 AsyncCombine,一个基于 AsyncSequence 和 Swift Observation 框架的轻量库。它在保留 async/await 原生特性的同时,重新带回了 Combine 风格的操作符,使开发者能够继续使用熟悉的 sinkassignstore(in:) 等 API,编写出更直观、可组合的异步代码。

在处理复杂的异步数据流时,我依然偏爱 Combine 的管道式表达——它让数据变换的意图一目了然。正如 William 在 AsyncCombine: Because Async Code Shouldn't Be Ugly 中所说:强大的功能与优雅的代码并非互斥。

往期内容

THANK YOU

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

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

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

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

深入解析 iOS 与 macOS 应用程序生命周期(完整指南)

最近在开发中过程看到appDelegate中的一些方法,突然想到了之前了解但没有梳理过的app的生命周期的一些方法,因此在这里简单的记录并和大家分享一下iOS与macOS应用程序的生命周期。

在 Apple 生态系统中,无论是 iPhone 上的轻量级 App 还是 Mac 上功能完整的桌面应用,其行为都受到一套精密设计的“应用程序生命周期”机制控制。这套机制不仅决定了应用何时启动、暂停、恢复或终止,还深刻影响着内存管理、后台执行策略、多任务处理能力以及用户体验流畅度。

本文将从底层原理到实践层面,全面剖析 iOS 和 macOS 应用程序生命周期 的每一个关键环节,涵盖状态模型、代理方法调用顺序、多场景支持、后台任务调度、调试技巧,并对比两个平台的设计哲学差异。


一、为什么需要理解应用生命周期?

许多看似随机的问题——如数据丢失、定时器未停止、音频中断、定位服务异常退出——往往源于对生命周期回调的误用或忽略。

掌握生命周期意味着你可以:

  • 在正确时机初始化资源
  • 避免内存泄漏
  • 实现优雅的数据持久化
  • 提升电池效率
  • 支持多窗口与多任务交互(尤其在 iPadOS/macOS)
  • 适配 SwiftUI 和 UIKit/AppKit 混合架构

二、iOS 应用程序生命周期详解

1. 核心概念:UIApplication 与 Run Loop

iOS 应用基于 事件驱动模型,由 UIApplication 单例对象主导整个生命周期。它依赖于 Main Run Loop 来接收并分发事件(触摸、网络回调、定时器等)。

📌 Run Loop 是什么?
它是一个循环线程结构,持续监听输入源(Input Sources),并在有事件时唤醒线程执行任务。它是所有 UI 更新和用户交互的基础。

// UIApplication 主循环简化示意(非真实实现)
func run() {
    while isRunning {
        let event = nextEvent()
        if let e = event {
            processEvent(e)
        }
    }
}

2. iOS 应用五大状态详解

状态 描述 可执行操作 是否会被系统终止
Not Running 进程未启动或已被杀死
Inactive 运行但不接收事件(来电、通知中心、Control Center 弹出) 可继续运行代码,但不处理 UI 事件
Active 正在前台运行,完全响应用户输入 全功能运行
Background 进入后台,系统给予约 3 秒宽限期执行清理任务 可申请延长后台执行时间(最多 3 分钟) 是(超时后)
Suspended 被挂起,不消耗 CPU,保留在内存中 不执行任何代码 是(低内存时)

⚠️ 注意:

  • “Suspended” ≠ “Terminated”。挂起的应用仍驻留内存,可快速恢复。
  • 系统可在任何时候终止 Suspended 或 Background 中的任务,不会调用 applicationWillTerminate

3. 生命周期方法详解(AppDelegate)

didFinishLaunchingWithOptions(_:)

这是应用的第一个入口点。在此处应完成:

  • 初始化第三方 SDK(Firebase、Analytics)
  • 设置根视图控制器
  • 检查启动选项(例如通过 URL Scheme 启动)
func application(_ application: UIApplication, 
                 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    
    guard let shortcutItem = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem else {
        // 正常启动
        setupRootViewController()
        return true
    }

    // 处理 3D Touch 快捷方式启动
    handleShortcutItem(shortcutItem)
    return true
}

applicationDidBecomeActive(_:)

应用进入 Active 状态,表示可以正常交互。

建议操作:

  • 恢复动画、AVPlayer 播放
  • 重启 CADisplayLink / Timer
  • 检查登录状态是否过期

避免:

  • 执行耗时同步操作(阻塞主线程)

applicationWillResignActive(_:)

应用即将失去焦点。常见触发场景:

  • 接到来电
  • 用户打开通知中心/控制中心
  • 切换到其他应用(双击 Home 键)
  • Face ID 验证中断

建议操作:

  • 暂停游戏、视频播放
  • 暂停计时器
  • 模糊敏感界面(安全考虑)

applicationDidEnterBackground(_:)

应用已进入后台。此时只有约 3 秒 时间执行任务。

若需更长时间运行(如上传文件、同步数据),必须请求后台任务:

var backgroundTask: UIBackgroundTaskIdentifier = .invalid

func applicationDidEnterBackground(_ application: UIApplication) {
    backgroundTask = application.beginBackgroundTask { [weak self] in
        // 超时回调:必须在此结束任务
        self?.endBackgroundTask()
    }

    // 执行后台任务
    performLongRunningTask { [weak self] in
        self?.endBackgroundTask()
    }
}

private func endBackgroundTask() {
    if backgroundTask != .invalid {
        UIApplication.shared.endBackgroundTask(backgroundTask)
        backgroundTask = .invalid
    }
}

📌 Tips

  • 每个后台任务最长 ~3 分钟(具体时间由系统动态调整)
  • 使用 BGProcessingTaskRequest(iOS 13+)进行低优先级后台处理(需声明 capability)

applicationWillEnterForeground(_:)

应用即将回到前台。这是刷新 UI 的理想时机。

func applicationWillEnterForeground(_ application: UIApplication) {
    // 刷新首页内容
    NotificationCenter.default.post(name: .appWillEnterForeground, object: nil)
}

applicationWillTerminate(_:)

⚠️ 重要警告:该方法仅在非挂起状态下被调用
即:当应用处于 Inactive 或 Background 状态时被手动杀死才会触发。大多数情况下(挂起后被系统清理),此方法不会执行

因此,不要依赖它来保存关键数据

正确做法:

  • applicationDidEnterBackground 中保存
  • 使用自动保存机制(Core Data 自动保存、UserDefaults.flush())
  • 监听特定事件即时保存

4. iOS 13+ 多场景(Scene-Based Lifecycle)

随着 iPadOS 支持多窗口、Mac Catalyst 的推出,Apple 引入了 Scene 架构,将 UI 与生命周期解耦。

关键组件:

  • UIScene:代表一个独立的 UI 实例(如一个窗口)
  • UISceneSession:持久化场景信息(用于恢复)
  • UISceneDelegate:管理单个场景的生命周期
  • NSUserActivity:跨设备 Handoff 和场景恢复

Scene 生命周期方法(SceneDelegate.swift)

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, 
               willConnectTo session: UISceneSession, 
               options connectionOptions: UIScene.ConnectionOptions) {

        guard let windowScene = (scene as? UIWindowScene) else { return }

        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = createInitialViewController()
        self.window = window
        window.makeKeyAndVisible()

        // 处理通过场景启动的快捷方式或 URL
        handleConnectionOptions(connectionOptions)
    }

    func sceneDidBecomeActive(_ scene: UIScene) {
        print("Scene became active")
    }

    func sceneWillResignActive(_ scene: UIScene) {
        print("Scene will resign active")
    }

    func sceneDidEnterBackground(_ scene: UIScene) {
        // 场景进入后台(可能还有其他场景在前台)
        (scene as? UIWindowScene)?.windows.forEach { $0.resignFirstResponder() }
    }
}

多场景配置(Info.plist)

<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <true/>
    <key>UISceneConfigurations</key>
    <dict>
        <key>UIWindowSceneSessionRoleApplication</key>
        <array>
            <dict>
                <key>UISceneConfigurationName</key>
                <string>Default Configuration</string>
                <key>UISceneDelegateClassName</key>
                <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
            </dict>
        </array>
    </dict>
</dict>

💡 提示:即使你的 App 不支持多窗口,iOS 13+ 也会默认创建一个场景。


三、macOS 应用程序生命周期详解

macOS 使用 AppKit 框架,其生命周期由 NSApplicationNSApplicationDelegate 控制。

1. 状态模型(较宽松)

状态 说明
Not Running 未运行
Inactive 窗口未聚焦(其他应用在前台)
Active 当前应用拥有焦点

❗ macOS 没有“挂起”状态。应用可在后台无限期运行(除非用户主动退出或系统因内存压力终止)。

2. 生命周期方法(AppDelegate.swift)

import Cocoa

class AppDelegate: NSObject, NSApplicationDelegate {

    func applicationDidFinishLaunching(_ notification: Notification) {
        // 创建主窗口
        let mainWindow = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
            styleMask: [.titled, .closable, .miniaturizable, .resizable],
            backing: .buffered,
            defer: false
        )
        mainWindow.center()
        mainWindow.title = "My Mac App"
        mainWindow.makeKeyAndOrderFront(nil)
        
        NSApp.activate(ignoringOtherApps: true)
    }

    func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
        if !flag {
            // 如果没有可见窗口,重新打开主窗口
            showMainWindow()
        }
        return true
    }

    func applicationWillTerminate(_ notification: Notification) {
        // 清理资源、保存偏好设置
        UserDefaults.standard.synchronize()
    }

    func applicationDidResignActive(_ notification: Notification) {
        // 当前应用失去焦点
    }

    func applicationDidBecomeActive(_ notification: Notification) {
        // 当前应用获得焦点
    }
}

3. 窗口生命周期(NSWindowDelegate)

macOS 强调多窗口管理,每个窗口有自己的代理:

extension MainWindowController: NSWindowDelegate {
    func windowWillClose(_ notification: Notification) {
        // 保存窗口位置、大小
        UserDefaults.standard.set(window?.frame, forKey: "MainWindowFrame")
    }
}

4. 与 iOS 的关键区别

特性 iOS macOS
后台运行 严格限制 几乎无限制
终止机制 系统可随时终止 用户主动退出为主
多实例 通常单实例 可打开多个文档窗口
生命周期粒度 应用级 + 场景级 应用级 + 窗口级
用户期望 快速启动、节省电量 持续可用、功能完整

四、跨平台开发注意事项(SwiftUI & Catalyst)

1. SwiftUI 的统一生命周期

使用 SwiftUI 时,可通过 @UIApplicationDelegateAdaptor 或直接使用声明式生命周期:

@main
struct MyApp: App {
    
    @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .onChange(of: phase) { newPhase in
            switch newPhase {
            case .active:
                print("App is active")
            case .inactive:
                print("App is inactive")
            case .background:
                print("App in background")
            @unknown default:
                break
            }
        }
    }
}

2. Mac Catalyst 适配要点

  • 启用 Target > General > Mac > Mac Catalyst
  • 处理鼠标悬停、右键菜单、键盘快捷键
  • 调整布局以适应更大屏幕
  • 注意后台任务权限差异

五、调试生命周期问题的实用技巧

1. 使用 Xcode 生命周期断点

AppDelegate 方法中添加断点,观察调用顺序。

2. 模拟状态变化

Xcode → Debug → Simulate Background Fetch / Terminate App

3. 日志追踪

统一日志宏:

func logLifecycle(_ message: String) {
    print("[LIFECYCLE] \(Date()): \(message)")
}

并在各回调中调用。

4. Instruments 工具检测

使用 Energy LogAllocations 查看后台能耗与内存占用。


六、常见陷阱与解决方案

问题 原因 解决方案
数据未保存 依赖 willTerminate 改为 didEnterBackground 或实时保存
定时器持续运行 未在 resignActive 中暂停 使用 invalidate() 并在 becomeActive 重建
音频中断 未处理 AVAudioSession 注册中断通知并恢复播放
内存警告后崩溃 未释放缓存 实现 didReceiveMemoryWarning
多场景重复初始化 未检查 session restoration ID 使用 session.persistentIdentifier 去重

七、总结

平台 设计哲学 生命周期特点 开发建议
iOS 移动优先、资源受限、用户体验至上 严格状态管理、后台限制、自动挂起 快速响应、及时保存、合理使用后台任务
macOS 功能完整、多任务、长期运行 松散状态、自由后台、多窗口 注重窗口管理、支持文档模型、优化长期运行性能

尽管 iOS 和 macOS 在生命周期实现上存在差异,但其核心理念一致:让应用在合适的时机做合适的事

作为开发者,我们应当:

  • 理解状态转换逻辑
  • 正确使用生命周期钩子
  • 善用工具调试
  • 面向未来设计(支持多场景、Catalyst、SwiftUI)

只有这样,才能构建出既高效又可靠的跨平台应用。


延伸阅读与官方文档


📌 结语
应用生命周期不仅是技术细节,更是设计思维的体现。掌握它,你就能更好地驾驭 Apple 平台的强大能力,为用户带来无缝、流畅、可靠的体验。

如果你觉得这篇文章有价值,请分享给你的团队!也欢迎在评论区提出疑问或分享你的最佳实践。


iOS/Swift:深入理解iOS CoreText API

这篇文章是从0到1自定义富文本渲染的原理篇之一,此外你还可能感兴趣:

更多内容可订阅公众号「非专业程序员Ping」,文中所有代码可在公众号后台回复 “CoreText” 获取。

一、引言

CoreText是iOS/macOS中的文字排版引擎,提供了一系列对文本精确操作的API;UIKit中UILabel、UITextView等文本组件底层都是基于CoreText的,可以看官方提供的层级图:

在这里插入图片描述 本文的目的是结合实际使用例子,来介绍和总结CoreText中的重要概念和API。

二、重要概念

CoreText中有几个重要概念:CTTypesetter、CTFramesetter、CTFrame、CTLine、CTRun;它们之间的关系可以看官方提供的层级图:

在这里插入图片描述

一篇文档可以分为:文档 -> 段落 -> 段落中的行 -> 行中的文字,类似的,CoreText也是按这个结构来组织和管理API的,我们也可以根据诉求来选择不同层级的API。

2.1 CTFramesetter

CTFramesetter类似于文档的概念,它负责将多段文本进行排版,管理多个段落(CTFrame)。

CTFramesetter的输入是属性字符串(NSAttributedString)和路径(CGPath),负责将文本在指定路径上进行排版。

2.2 CTFrame

CTFrame类似于段落的概念,其中包含了若干行(CTLine)以及对应行的位置、方向、行间距等信息。

2.3 CTLine

CTLine类似于行的概念,其中包含了若干个字形(CTRun)以及对应字形的位置等信息。

2.4 CTRun

需要注意CTRun不是单个的字符,而是一段连续的且具有相同属性(字体、颜色等)的字形(Glyph)。

如下,每个虚线框都代表一个CTRun:

在这里插入图片描述

2.5 CTTypesetter

CTTypesetter支持对属性字符串进行换行,可以通过CTTypesetter来自定义换行(比如按word换行、按char换行等)或控制每行的内容,可以理解成更精细化的控制。

三、重要API

3.1 CTFramesetter

1)CTFramesetterCreateWithAttributedString

func CTFramesetterCreateWithAttributedString(_ attrString: CFAttributedString) -> CTFramesetter

通过属性字符串来创建CTFramesetter。

我们可以构造不同字体、颜色、大小的属性字符串,然后从属性字符串构造CTFramesetter,之后可以继续往下拆分得到段落、行、字形等信息,这样可以实现自定义排版、图文混排等复杂富文本样式。

2)CTFramesetterCreateWithTypesetter

func CTFramesetterCreateWithTypesetter(_ typesetter: CTTypesetter) -> CTFramesetter

通过CTTypesetter来创建CTFramesetter,当我们需要对文本实现更精细控制,比如自定义换行时,可以自己构造CTTypesetter。

3)CTFramesetterCreateFrame

func CTFramesetterCreateFrame(
    _ framesetter: CTFramesetter,
    _ stringRange: CFRange,
    _ path: CGPath,
    _ frameAttributes: CFDictionary?
) -> CTFrame

生成CTFrame:在指定路径(path)为属性字符串的指定范围(stringRange)生成CTFrame。

  • framesetter
  • stringRange:字符范围,注意需要以UTF-16编码格式计算;当 stringRange.length = 0 时,表示从起点(stringRange.location)到字符结束为止;比如当 CFRangeMake(0, 0) 表示全字符范围
  • path:排版路径,可以是不规则矩形,这意味着可以传入不规则图形来实现文字环绕等高级效果
  • frameAttributes:一个可选的字典,可以用于控制段落级别的布局行为,比如行间距等,一般用不到,可传 nil

4)CTFramesetterSuggestFrameSizeWithConstraints

func CTFramesetterSuggestFrameSizeWithConstraints(
    _ framesetter: CTFramesetter,
    _ stringRange: CFRange,
    _ frameAttributes: CFDictionary?,
    _ constraints: CGSize,
    _ fitRange: UnsafeMutablePointer<CFRange>?
) -> CGSize

计算文本宽高:在给定约束尺寸(constraints)下计算文本范围(stringRange)的实际宽高。

如下,我们可以计算出在宽高 100 x 100 的范围内排版,实际能放下的文本范围(fitRange)以及实际的文本尺寸:

let attr = NSAttributedString(string: "这是一段测试文本,通过调用CTFramesetterSuggestFrameSizeWithConstraints来计算文本的宽高信息,并返回实际的range", attributes: [
    .font: UIFont.systemFont(ofSize: 16),
    .foregroundColor: UIColor.black
])
let framesetter = CTFramesetterCreateWithAttributedString(attr)
var fitRange = CFRange(location: 0, length: 0)
let size = CTFramesetterSuggestFrameSizeWithConstraints(
    framesetter,
    CFRangeMake(0, 0),
    nil,
    CGSize(width: 100, height: 100),
    &fitRange
)
print(size, fitRange, attr.length)

这个API在分页时非常有用,比如微信读书的翻页效果,需要知道在哪个地方截断,PDF的分页排版等。

3.1.1 CTFramesetter使用示例

1)实现一个支持AutoLayout且高度靠内容撑开的富文本View

在这里插入图片描述

2)在圆形路径中绘制文本

在这里插入图片描述

3)文本分页:模拟微信读书的分页逻辑 在这里插入图片描述

3.2 CTFrame

1)CTFramesetterCreateFrame

func CTFramesetterCreateFrame(
    _ framesetter: CTFramesetter,
    _ stringRange: CFRange,
    _ path: CGPath,
    _ frameAttributes: CFDictionary?
) -> CTFrame

创建CTFrame,在CTFramesetter一节中有介绍过,这是创建CTFrame的唯一方式。

2)CTFrameGetStringRange

func CTFrameGetStringRange(_ frame: CTFrame) -> CFRange

获取CTFrame包含的字符范围。

我们在调用CTFramesetterCreateFrame创建CTFrame时,会传入一个 stringRange 的参数,CTFrameGetStringRange也可以理解成获取这个 stringRange,区别是处理了当 stringRange.length 为0的情况。

3)CTFrameGetVisibleStringRange

func CTFrameGetVisibleStringRange(_ frame: CTFrame) -> CFRange

获取CTFrame实际可见的字符范围。

我们在调用CTFramesetterCreateFrame创建CTFrame时,会传入path,可能会把字符截断,CTFrameGetVisibleStringRange返回的就是可见的字符范围。

需要注意和CTFrameGetStringRange进行区分,可以用如下Demo验证:

let longText = String(repeating: "这是一个分栏布局的例子。Core Text 允许我们将一个长的属性字符串(CFAttributedString)流动到多个不同的路径(CGPath)中。我们只需要创建一个 CTFramesetter,然后循环调用 CTFramesetterCreateFrame。每次调用后,我们使用 CTFrameGetStringRange 来找出有多少文本被排入了当前的框架,然后将下一个框架的起始索引设置为这个范围的末尾。 ", count: 10)
let attributedText = NSAttributedString(string: longText, attributes: [
    .font: UIFont.systemFont(ofSize: 12),
    .foregroundColor: UIColor.darkText
])
let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString)
let path = CGPath(rect: .init(x: 10, y: 100, width: 400, height: 200), transform: nil)
let frame = CTFramesetterCreateFrame(
    framesetter,
    CFRange(location: 100, length: 0),
    path,
    nil
)
// 输出:CFRange(location: 100, length: 1980)
print(CTFrameGetStringRange(frame))
// 输出:CFRange(location: 100, length: 584)
print(CTFrameGetVisibleStringRange(frame))

4)CTFrameGetPath

func CTFrameGetPath(_ frame: CTFrame) -> CGPath

获取创建CTFrame时传入的path。

5)CTFrameGetLines

func CTFrameGetLines(_ frame: CTFrame) -> CFArray

获取CTFrame中所有的行(CTLine)。

6)CTFrameGetLineOrigins

func CTFrameGetLineOrigins(
    _ frame: CTFrame,
    _ range: CFRange,
    _ origins: UnsafeMutablePointer<CGPoint>
)

获取每一行的起点坐标。

用法示例:

let lines = CTFrameGetLines(frame) as! [CTLine]
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)

7)CTFrameDraw

func CTFrameDraw(
    _ frame: CTFrame,
    _ context: CGContext
)

绘制CTFrame。

3.2.1 CTFrame使用示例

1)绘制CTFrame

在这里插入图片描述

2)高亮某一行

在这里插入图片描述

3)检测点击字符

在这里插入图片描述

3.3 CTLine

1)CTLineCreateWithAttributedString

func CTLineCreateWithAttributedString(_ attrString: CFAttributedString) -> CTLine

从属性字符串创建单行CTLine,如果字符串中有换行符(\n)的话,换行符会被转换成空格,如下:

let line = CTLineCreateWithAttributedString(
    NSAttributedString(string: "Hello CoreText\nWorld", attributes: [.font: UIFont.systemFont(ofSize: 16)])
)

2)CTLineCreateTruncatedLine

func CTLineCreateTruncatedLine(
    _ line: CTLine,
    _ width: Double,
    _ truncationType: CTLineTruncationType,
    _ truncationToken: CTLine?
) -> CTLine?

创建一个被截断的新行。

  • line:待截断的行
  • width:在多少宽度截断
  • truncationType:start/end/middle,截断类型
  • truncationToken:在截断处添加的字符,nil表示不添加,一般使用省略符(...)
let truncationToken = CTLineCreateWithAttributedString(
    NSAttributedString(string: "…", attributes: [.font: UIFont.systemFont(ofSize: 16)])
)
let truncated = CTLineCreateTruncatedLine(line, 100, .end, truncationToken)

3)CTLineCreateJustifiedLine

func CTLineCreateJustifiedLine(
    _ line: CTLine,
    _ justificationFactor: CGFloat,
    _ justificationWidth: Double
) -> CTLine?

创建一个两端对齐的新行,类似书籍或报纸中两端对齐的排版效果。

  • line:原始行
  • justificationFactorjustificationFactor <= 0表示不缩放,即与原始行相同;justificationFactor >= 1表示完全缩放到指定宽度;0 < justificationFactor < 1表示部分缩放到指定宽度,可以看示例代码
  • justificationWidth:缩放指定宽度

示例:

在这里插入图片描述

4)CTLineDraw

func CTLineDraw(
    _ line: CTLine,
    _ context: CGContext
)

绘制行。

5)CTLineGetGlyphCount

func CTLineGetGlyphCount(_ line: CTLine) -> CFIndex

获取行内字形总数。

6)CTLineGetGlyphRuns

func CTLineGetGlyphRuns(_ line: CTLine) -> CFArray

获取行内所有的CTRun。

7)CTLineGetStringRange

func CTLineGetStringRange(_ line: CTLine) -> CFRange

获取该行对应的字符范围。

8)CTLineGetPenOffsetForFlush

func CTLineGetPenOffsetForFlush(
    _ line: CTLine,
    _ flushFactor: CGFloat,
    _ flushWidth: Double
) -> Double

获取在指定宽度绘制时的水平偏移,一般配合 CGContext.textPosition 使用,可用于实现在固定宽度下文本的左对齐、右对齐、居中对齐及自定义水平偏移等。

示例:

在这里插入图片描述

9)CTLineGetImageBounds

func CTLineGetImageBounds(
    _ line: CTLine,
    _ context: CGContext?
) -> CGRect

获取行的视觉边界;注意 CTLineGetImageBounds 获取的是相对于CTLine局部坐标系的矩形,即以textPosition为原点的矩形。

视觉边界可以看下面的例子,与之相对的是布局边界;这个API在实际应用中不常见,除非有特殊诉求,比如要检测精确的内容点击范围,给行绘制紧贴背景等。

在这里插入图片描述

10)CTLineGetTypographicBounds

func CTLineGetTypographicBounds(
    _ line: CTLine,
    _ ascent: UnsafeMutablePointer<CGFloat>?,
    _ descent: UnsafeMutablePointer<CGFloat>?,
    _ leading: UnsafeMutablePointer<CGFloat>?
) -> Double

获取上行(ascent)、下行(descent)、行距(leading)。

这几个概念不熟悉的可以参考:一文读懂字符、字形、字体

想了解这几个数值最终是从哪个地方读取的可以参考:一文读懂字体文件

通过这个API我们可以手动构造布局边界(见上面的例子),一般用于点击检测、绘制行背景等。

11)CTLineGetTrailingWhitespaceWidth

func CTLineGetTrailingWhitespaceWidth(_ line: CTLine) -> Double

获取行尾空白字符的宽度(比如空格、制表符 (\t) 等),一般用于实现对齐时基于可见文本对齐等。

示例:

let line = CTLineCreateWithAttributedString(
    NSAttributedString(string: "Hello  ", attributes: [.font: UIFont.systemFont(ofSize: 16)])
)

let totalWidth = CTLineGetTypographicBounds(line, nil, nil, nil)
let trailingWidth = CTLineGetTrailingWhitespaceWidth(line)

print("总宽度: \(totalWidth)")
print("尾部空白宽度: \(trailingWidth)")
print("可见文字宽度: \(totalWidth - trailingWidth)")

12)CTLineGetStringIndexForPosition

func CTLineGetStringIndexForPosition(
    _ line: CTLine,
    _ position: CGPoint
) -> CFIndex

获取给定位置处的字符串索引。

注意:虽然官方文档说这个API一般用于点击检测,但实际测试下来这个API返回的点击索引不准确,比如虽然点击的是当前字符,但实际返回的索引是后一个字符的,如下:

在这里插入图片描述

查了下,发现这个API一般是用于计算光标位置的,比如点击「行」的左半部分,希望光标出现在「行」左侧,如果点击「行」的右半部分,希望光标出现在「行」的右侧。

如果我们想精确做字符的点击检测,推荐使用字符/行的bounds来计算,参考「CTFrame使用示例-3」例子。

13)CTLineGetOffsetForStringIndex

func CTLineGetOffsetForStringIndex(
    _ line: CTLine,
    _ charIndex: CFIndex,
    _ secondaryOffset: UnsafeMutablePointer<CGFloat>?
) -> CGFloat

获取指定字符索引相对于行的 x 轴偏移量。

  • line:待查询的行
  • charIndex:要查询的字符在原始属性字符串中的索引
  • secondaryOffset:次要偏移值,在简单的LTR文本中,可以忽略(传nil即可),但在复杂的双向文本(BiDi)中会用到

使用场景:

  • 字符点击检测:见「CTFrame使用示例-3」例子
  • 给某段字符绘制高亮和下划线
  • 定位某个字符:比如想在一段文本中的某个字符上方显示弹窗,可以用这个API先定位该字符

14)CTLineEnumerateCaretOffsets

func CTLineEnumerateCaretOffsets(
    _ line: CTLine,
    _ block: @escaping (Double, CFIndex, Bool, UnsafeMutablePointer<Bool>) -> Void
)

遍历一行中光标所有的有效位置。

  • line
  • block
    • Double:offset,相对于行的 x 轴偏移
    • CFIndex:与此光标位置相关的字符串索引
    • Bool:true 表示光标位于字符的前边(在 LTR 中即左侧),false 表示光标位于字符的后边(在 LTR 中即右侧);在 BiDi 中需要特殊同一个字符可能会回调两次(比如 BiDi 边界的地方),需要用这个值区分前后
    • UnsafeMutablePointer:stop 指针,赋值为 true 会停止遍历

使用场景:

  • 绘制光标:富文本选区或者文本编辑器中,要绘制光标时,可以先通过 CTLineGetStringIndexForPosition 获取字符索引,再通过这个函数或者 CTLineGetOffsetForStringIndex 获取光标偏移
  • 实现光标的左右键移动:可以用这个API将所有的光标位置存储到数组,并按offset排序,当用户按下右箭头 -> 时,可以找到当前光标index,将index + 1即是下一个光标位置

3.3.1 CTLine使用示例

除了上面例子,再举一个:

1)高亮特定字符

在这里插入图片描述

3.4 CTRun

CTRun相关API比较基础,这里主要介绍常用的。

1)CTLineGetGlyphRuns

func CTLineGetGlyphRuns(_ line: CTLine) -> CFArray

获取CTRun的唯一方式。

2)CTRunGetAttributes

func CTRunGetAttributes(_ run: CTRun) -> CFDictionary

获取CTRun的属性;比如想知道这个CTRun是不是粗体,是不是链接,是不是目标Run等,都可以通过这个API。

示例:

guard let attributes = CTRunGetAttributes(run) as? [NSAttributedString.Key: Any] else { continue }
// 现在你可以检查属性
if let color = attributes[.foregroundColor] as? UIColor {
    // ...
}
if let font = attributes[.font] as? UIFont {
    // ...
}
if let link = attributes[NSAttributedString.Key("my_custom_link_key")] {
    // 这就是那个可点击的 run!
}

3)CTRunGetStringRange

func CTRunGetStringRange(_ run: CTRun) -> CFRange

获取CTRun对应于原始属性字符串的哪个范围。

4)CTRunGetTypographicBounds

func CTRunGetTypographicBounds(
    _ run: CTRun,
    _ range: CFRange,
    _ ascent: UnsafeMutablePointer<CGFloat>?,
    _ descent: UnsafeMutablePointer<CGFloat>?,
    _ leading: UnsafeMutablePointer<CGFloat>?
) -> Double

获取CTRun的度量信息,同上面许多API一样,当 range.length 为0时表示直到CTRun文本末尾。

5)CTRunGetPositions

func CTRunGetPositions(
    _ run: CTRun,
    _ range: CFRange,
    _ buffer: UnsafeMutablePointer<CGPoint>
)

获取CTRun中每一个字形的位置,注意这里的位置是相对于CTLine原点的。

6)CTRunDelegate

CTRunDelegate允许为属性字符串中的一段文本提供自定义布局测量信息,一般用于在文本中插入图片、自定义View等非文本元素。

比如在文本中间插入图片:

在这里插入图片描述

3.4.1 CTRun使用示例

1)基础绘制

在这里插入图片描述

2)链接点击识别

在这里插入图片描述

3.5 CTTypesetter

CTFramesetter会自动处理换行,当我们想手动控制换行时,可以用CTTypesetter。

1)CTTypesetterSuggestLineBreak

func CTTypesetterSuggestLineBreak(
    _ typesetter: CTTypesetter,
    _ startIndex: CFIndex,
    _ width: Double
) -> CFIndex

按单词(word)换行。

如下示例,输出:Try word wrapping

let attrStringWith = NSAttributedString(string: "Try word wrapping", attributes: [.font: UIFont.systemFont(ofSize: 18)])
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
let totalLength = attributedString.length // UTF-16 长度
var startIndex = 0
var lineCount = 1

while startIndex < totalLength {
    let charCount = CTTypesetterSuggestLineBreak(typesetter, startIndex, 100)
    // 如果返回 0,意味着一个字符都放不下(或已结束)
    if charCount == 0 {
        if startIndex < totalLength {
            print("Line \(lineCount): (Error) 无法放下剩余字符。")
        }
        break
    }
    // 获取这一行的子字符串
    let range = NSRange(location: startIndex, length: charCount)
    let lineString = (attributedString.string as NSString).substring(with: range)
    print("Line \(lineCount): '\(lineString)' (UTF-16 字符数: \(charCount))")
    // 更新下一次循环的起始索引
    startIndex += charCount
    lineCount += 1
}

2)CTTypesetterSuggestClusterBreak

func CTTypesetterSuggestClusterBreak(
    _ typesetter: CTTypesetter,
    _ startIndex: CFIndex,
    _ width: Double
) -> CFIndex

按字符(char)换行。

如下示例,输出:Try word wrapping

let attrStringWith = NSAttributedString(string: "Try word wrapping", attributes: [.font: UIFont.systemFont(ofSize: 18)])
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
let totalLength = attributedString.length // UTF-16 长度
var startIndex = 0
var lineCount = 1

while startIndex < totalLength {
    let charCount = CTTypesetterSuggestClusterBreak(typesetter, startIndex, 100)
    // 如果返回 0,意味着一个字符都放不下(或已结束)
    if charCount == 0 {
        if startIndex < totalLength {
            print("Line \(lineCount): (Error) 无法放下剩余字符。")
        }
        break
    }
    // 获取这一行的子字符串
    let range = NSRange(location: startIndex, length: charCount)
    let lineString = (attributedString.string as NSString).substring(with: range)
    print("Line \(lineCount): '\(lineString)' (UTF-16 字符数: \(charCount))")
    // 更新下一次循环的起始索引
    startIndex += charCount
    lineCount += 1
}

四、总结

以上是CoreText中常用的API及其场景代码举例,完整示例代码可在公众号「非专业程序员Ping」回复 “CoreText” 获取。

iOS 基于Vision.framework从图片中提取文字

基于Vision.framework从图片中提取文字 苹果在iOS 11中引入的Vision框架为OCR提供了基础能力,其核心组件VNRecognizeTextRequest可实现高效文字检测与识别。结合VisionKit中的DocumentCameraViewController,可快速构建扫描界面,支持自动裁剪、透视校正等预处理功能。

技术优势

  • 硬件加速:利用神经网络引擎(Neural Engine)实现低功耗、高帧率识别
  • 隐私保护:所有计算在设备端完成,无需上传至云端
  • 系统级优化:与iOS相机、相册系统深度集成
#import <Foundation/Foundation.h>
#import <Vision/Vision.h>

NS_ASSUME_NONNULL_BEGIN

API_AVAILABLE(ios(11.0))
typedef void(^SBVisionTextCallBack)(NSError *error, NSArray<__kindof VNObservation*>* results);


API_AVAILABLE(ios(11.0))

@interface SBVisionText : NSObject

@property (nonatomic,copy)SBVisionTextCallBack resultBlock;

+ (void)sb_vision_text_image:(UIImage *)img result:(SBVisionTextCallBack) resultBlock;

@end


#import "SBVisionText.h"

@implementation SBVisionText

+ (void)sb_vision_text_image:(UIImage *)img result:(SBVisionTextCallBack) resultBlock{

    if (@available(iOS 13.0, *)) {

        VNRecognizeTextRequest *textRequest = [[VNRecognizeTextRequest alloc] initWithCompletionHandler:^(VNRequest * _Nonnull request, NSError * _Nullable error){

            NSArray *observations = request.results;

            //        [self textRectangles:observations image:image complete:complete];

            NSLog(@"sb_vision_text_image:%@",observations);

            if (resultBlock) {
                resultBlock(error,request.results);
            }
        }];
        
        textRequest.recognitionLevel = VNRequestTextRecognitionLevelAccurate;
        textRequest.usesLanguageCorrection = NO;
        textRequest.recognitionLanguages = @[@"zh-Hans", @"en-US"];

        // 转换CIImage
        CIImage *convertImage = [[CIImage alloc]initWithImage:img];

        // 创建处理requestHandler

        VNImageRequestHandler *detectRequestHandler = [[VNImageRequestHandler alloc]initWithCIImage:convertImage options:@{}];

        // 发送识别请求
        [detectRequestHandler performRequests:@[textRequest] error:nil];

    } else {
        // Fallback on earlier versions
        NSLog(@"Fallback on earlier versions");
    }
}

@end

方法调用

#import "SBVisionTextViewController.h"
#import "SBVisionText.h"


@implementation SBVisionTextViewController
- (void)viewDidLoad {
    [super viewDidLoad];
}

- (IBAction)getText:(UIButton *)sender {
    [self getTextFormImage:[UIImage imageNamed:@"1681888102373.jpg"]];
}

-(void)getTextFormImage:(UIImage *)img{
    if (@available(iOS 11.0, *)) {
        [SBVisionText sb_vision_text_image:img result:^(NSError * _Nonnull error, NSArray<__kindof VNObservation *> * _Nonnull results) {

            if (@available(iOS 13.0, *)) {
                for (VNRecognizedTextObservation *observation in results) {
                    NSLog(@"%@", [observation topCandidates:1].firstObject.string);
                }
            } else {
                NSLog(@"Fallback on earlier versions");
            }
        }];

    } else {
        NSLog(@"Fallback on earlier versions");
    }
    return;
}

@end
❌