普通视图

发现新文章,点击刷新页面。
今天 — 2025年7月22日iOS

F1:电影很好看,赛事很挣钱 | 肘子的 Swift 周报 #094

作者 东坡肘子
2025年7月22日 08:06

issue94.webp

weekly.fatbobman.com 订阅本周报的电子邮件版本。访问我的博客 肘子的 Swift 记事本 查看更多的文章。加入 Discord 社区,与 2000+ 中文开发者深入交流 Swift、SwiftUI 开发体验。

F1:电影很好看,赛事很挣钱

上周我去影院看了苹果出品的电影《F1》,体验真的超乎预期。虽然剧情略显简单,但影片营造出的赛车沉浸感和观赛体验已经远超票价。当布拉德·皮特驾驶着 APXGP 赛车在银石赛道上疾驰时,那引擎的咆哮通过 IMAX 音响系统传来的震撼感,让我仿佛回到了 20 多年前,在电视机前为舒马赫和阿隆索激烈对决而热血沸腾的日子。

三亿美元的制作成本听起来不少,但看完后你很难不惊叹:这真的是三亿能拍出来的吗?真车、真人(众多顶级车手客串)、真场地(银石赛道、迈凯伦车队总部、梅赛德斯风洞实验室)——这些资源若没有汉密尔顿担任制片人时的人脉、苹果的企业背书,以及 F1 官方的全力配合,恐怕花再多钱都未必能实现。

有趣的是,苹果目前也在竞争 2026 年 F1 在美国的转播权。这是受电影成功鼓舞的顺势而为,还是从拍摄之初就已纳入战略布局?无论如何,这部电影或许不会在电影史上留下浓重的印记,但作为一部顶级商业运动的“宣传片”,其效果无疑是拉满的。或许我们很快就会看到更多与高商业价值运动组织深度合作的体育题材电影。

二十多年前我热衷 F1 的时候,FIA 对各车队的限制相对宽松,轮胎供应商之间有米其林和普利司通的激烈竞争,车队可以在空气动力学、引擎开发、燃油策略等多个方面大胆创新。即便是小车队,也有机会通过独特的战术选择在某场比赛中跑进积分区,给车迷带来意想不到的惊喜。

但随着 FIA 逐步收紧规则,比赛变得越来越程式化。统一轮胎供应商、混合动力时代复杂的引擎规则,每一次改革似乎都在削弱那种“anything goes”的爽快感和刺激感。

FIA 的初衷是好的——通过增加限制来缩小车队间差距,让比赛更加激烈、不可预测。理论上,当大家都被施加相同限制时,实力差距应该会缩小。但现实却并非如此。大车队依旧凭借几十年的技术积累、顶级设施和成熟管理体系维持统治,而小车队则丧失了“剑走偏锋”的机会。爆冷和奇迹变得越来越稀少,比赛也失去了许多曾经令人热血沸腾的变化和不确定性。

这并非 F1 独有的现象。放眼整个体育产业,几乎所有高商业价值的运动组织都在采取类似的“平衡策略”:NBA 的工资帽和奢侈税、NFL 的选秀制度、英超的财政公平法案……这些规则的初衷都是限制强队、增加竞争性,从而提升联赛整体商业价值。但实际效果往往有限。长期积累的资源优势不会因为规则限制而消失,只是换了种表现形式。而在追求“平衡”的过程中,这些运动可能也在悄悄失去一些独特的魅力。

我并不反对商业与竞技结合——体育需要商业支撑才能可持续发展。但当商业考量开始主导竞技规则的制定时,就需要警惕了。变化和不可预测性,始终是竞技运动的核心吸引力。如果为了追求表面的“平衡”而扼杀了创新和变化,我们最终可能会看到这样一个局面:赛事越来越赚钱,但运动本身的魅力却在逐渐消退。

前一期内容全部周报列表

近期推荐

如何在 mac Finder Action 扩展中使用新并发 (Writing a macOS Finder "action" Extension with Swift 6 Concurrency)

对于开发者来说,一段标注了详细注释的代码,或许胜过千言万语。在这篇文章中(90% 是代码),Chris Jones 向我们展示了如何在系统回调场景中(同步调用,线程不确定),巧妙地与 Swift 现代并发模型协作。

为了方便快速抓住重点,我整理了以下几个关键技术点:

  • 通过 DispatchGroup 协调多个异步操作,确保所有准备工作完成后再返回结果
  • 使用 Mutex 保护共享状态,避免多线程环境下的数据竞争
  • 通过 Task.detached 创建独立执行上下文,解决异步/同步桥接问题(避免死锁)

在开发者的精心设计下,传统 Cocoa API 与现代 Swift 并发或许可以“愉快”地共存。


Icon Composer 见招拆招

随着 Liquid Glass 风格的推出,苹果同步发布了 Icon Composer,帮助开发者制作符合新标准的应用图标。虽然官方介绍中“拖放分层图片即可”的流程看似简单,但实际使用过程中仍有不少细节需要留意。本文中,Megabits 分享了他在使用 Icon Composer 制作 Liquid Glass 图标时的实战经验,包括如何处理 SVG 导出、单色模式适配、层数限制 以及 Xcode 的导入路径等问题,让你在设计新图标时少走弯路。


SwiftUI 2025:修了哪些坑,哪些还在 (SwiftUI 2025: What’s Fixed, What’s Not, and How I Build Apps Now)

相较于初期版本,SwiftUI 已经取得了显著进步,但它真的足以支撑复杂 App 的开发吗?哪些坑已经被填平,哪些问题依然存在?Weichao Deng 分享了他在一个 SwiftUI-first 项目 中遇到的主要挑战,以及如何通过混合 UIKit 提升性能和解决兼容性问题。他认为,尽管 SwiftUI 获得了大量改进,但出于兼容性和性能考量,仍建议用 UIKit/AppKit 管理生命周期(如 UIScene、UIWindow),将 SwiftUI 作为主要视图库;而对于个人小工具或 demo,纯 SwiftUI 已经足够。


用 TestHelpers 打造高效测试目标 (Hitting The Target with TestHelpers)

随着项目规模增长,模块化已成为管理复杂度的有效手段。但在许多项目中,模块化的收益往往集中在生产代码,而测试目标(test target)却被忽视。William Boles 提出了一种简单而实用的做法:为每个模块引入 TestHelper target,消除重复的测试桩代码,提升团队开发效率。

在我自己构建的项目中,也采用了与 William 建议非常接近的方案。实践下来,我最大的感受是:初期投入时间去打造高质量的 Mock 和测试工具确实不轻松,但这些投入会在后续开发中带来“复利效应”——让测试编写更快、维护成本更低、代码质量更高。


Mutex 与同步框架 (Modern Swift Lock: Mutex & the Synchronization Framework)

Swift 6.2 新增的 Synchronization 框架 引入了 Mutex 锁(SE-433),提供了一种更简洁、线程安全的方式来管理可变状态。Antoine van der Lee 在文章中不仅介绍了其用法,还对比了传统 Lock 的差异。Mutex(互斥锁)只能由获取它的线程/任务释放,更严格,适合避免解锁错误;同时,withLock 方法让我们能够以同步且线程安全的方式访问数据。

Mutex 给我的最大感受是优雅:它让我可以放心、自然地实现 Sendable 类型,而不必向编译器提供人格担保(@unchecked Sendable)。唯一的遗憾是,该功能仅支持 iOS 18 及以上系统。


在 SwiftUI 中检测交互式返回手势 (Detecting the Interactive Pop Gesture in SwiftUI)

虽然 NavigationStack 底层基于 UINavigationController,但 SwiftUI 仅暴露了有限的接口,导致我们在某些场景下难以精准判断用户意图。比如,感知返回手势,以便在用户滑动返回时暂停任务、取消网络请求或触发动画。Artem Mirzabekian 在本文分享了一种实用技巧:通过嵌入一个隐形 UIViewController,监听父级 UINavigationController(即 NavigationStack)的 interactivePopGestureRecognizer 状态。


iPadOS 窗口化:开发者的下一个挑战 (Windowing on iPadOS)

在 WWDC 2025,iPadOS 迎来了重大升级,带来了与 macOS 接近的窗口系统体验。这不仅丰富了用户体验,也为开发者带来了新的挑战:应用需要适配更多窗口尺寸状态,包括“微型窗口”(甚至小于 iPhone SE 的尺寸)。Danny Bolella 在本文分享了多种适配技巧与注意事项,并提出一个有趣的假设——或许可以借助 iPad 的窗口系统,提前模拟折叠屏 iPhone 的体验。

窗口化是 iPadOS 的下一步进化,拥抱它,也是在实践 Liquid Glass 设计理念。


SwiftData 性能提升实战 (High Performance SwiftData Apps)

你能接受在 SwiftData 中保存一张照片要等 20 秒吗?显然 Jacob Bartlett 不行——他觉得用户更不会买账。本文记录了他从“灾难性设计”到彻底重构优化的全过程:包括如何将数据读写移出主线程、重构数据模型以避免存储庞大的 PNG 数据、将图片迁移到文件系统并引用路径,以及借助第三方库(如 Nuke)实现高效加载。

随着采用 SwiftData 的开发者增多,性能问题开始困扰更多人。至少在目前阶段,SwiftData 的易用性优势正在被这些性能隐患逐渐削弱。

工具

XLKit - 不仅仅导出数据

一个迫切的需求,往往能催生出有特色的第三方库。The Acharya 团队在电影制作工作流程中,需要处理 Final Cut Pro 的 FCPXML 并导出 Excel 文件,尤为关键的是导出数据中的图片既要保持“完美长宽比”,又要支持单元格自动适配其尺寸。为此,Vigneswaran Rajkumar 开发了 XLKit——一个支持完整 .xlsx 文件输出的 Swift 库,内置 17 种专业视频/影院长宽比,并特别为 Final Cut Pro 工作流优化。


Apple Docs MCP

苹果开发者文档向来被诟病更新慢、检索难,在 AI 编程时代这一问题愈发凸显。为解决这一痛点,Sungwhee Kim 开发了 Apple Docs MCP:一个基于 Model Context Protocol 的苹果文档服务器,让 Claude、Cursor 等 AI 助手能够直接访问苹果官方文档、API 参考和 WWDC 视频资料。配置该 MCP 后,你可以直接向 AI 提问:“Show me withAnimation API with related APIs” 或 “Find UIViewController delegate methods”,让大模型真正成为苹果开发者的智能助手。

往期内容

THANK YOU

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

weekly.fatbobman.com 订阅本周报的电子邮件版本。访问我的博客 肘子的 Swift 记事本 查看更多的文章。加入 Discord 社区,与 2000+ 中文开发者深入交流 Swift、SwiftUI 开发体验。

为什么要学习Flutter编译过程

作者 亿刀
2025年7月21日 16:41

1. 为什么要了解编译过程

  • 因为只有了解了flutter的编译过程,才能更好的做flutter CI CD工作。尤其是解决“如何让flutter开发适配目前已有的完备的iOS/Android的开发工作流”问题, 尤为重要。(如果是个人开发者可以忽略)。
  • 其次你会发现一个现象:“debug包 在离开xcode环境,脱机运行的时候,在初始化Flutter engine会失败。但是在xcode环境下运行debug包一切正常”。这就带来一个问题:有些业务需要再debug模式下提测,但提交给测试工程师的debug包flutter初始化会失败。为了解决这个问题,你需要了解flutter的编译过程来解决这个问题。

2. 编译过程

编译过程可以分为:

  • 编译前:为编译做准备,工程参数设定,脚本注入等
  • 编译:将flutter module编译为framework,并打包资源文件
  • 编译后:将打包后的framework和资源复制到相应的文件夹

我们会举例说明三个过程

2.1. Demo环境:目录结构和Podfile

2.1.1. 目录结构

/path/to/MyApp
├── my_flutter/
│   └── .ios/
│       └── Flutter/
│         └── podhelper.rb
└── MyApp/
    └── Podfile

2.1.2. Podfile

flutter_application_path = '../my_flutter'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')

MyApp/Podfile
target 'MyApp' do
  install_all_flutter_pods(flutter_application_path)
end

post_install do |installer|
  flutter_post_install(installer) if defined?(flutter_post_install)
end

重点看podhelper.rb文件内的install_all_flutter_pods和flutter_post_install两个函数做了什么

2.2. 编译器前

编译前可以理解为pod install所做的事情,主要由install_all_flutter_pods和flutter_post_install两个函数完成

2.2.1. install_all_flutter_pods

def install_all_flutter_pods(flutter_application_path = nil)
  # defined_in_file is a Pathname to the Podfile set by CocoaPods.
  pod_contents = File.read(defined_in_file)
  unless pod_contents.include? 'flutter_post_install'
    puts  <<~POSTINSTALL
Add `flutter_post_install(installer)` to your Podfile `post_install` block to build Flutter plugins:

post_install do |installer|
  flutter_post_install(installer)
end
POSTINSTALL
    raise 'Missing `flutter_post_install(installer)` in Podfile `post_install` block'
  end

  flutter_application_path ||= File.join('..', '..')
  install_flutter_engine_pod(flutter_application_path)
  install_flutter_plugin_pods(flutter_application_path)
  install_flutter_application_pod(flutter_application_path)
end

这里主要调用了三个函数:

  • install_flutter_engine_pod
  • install_flutter_plugin_pods
  • install_flutter_application_pod

这里重点看:install_flutter_application_pod。它为MyApp工程文件导入两个脚本:

  • Run Flutter Build my_flutter Script:根据环境编译flutter 和 App framework
  • Embed Flutter Build my_flutter Script:把编译后的产出复制到相应的目录
def install_flutter_application_pod(flutter_application_path)
  flutter_application_path ||= File.join('..', '..')

  export_script_directory = File.join(flutter_application_path, '.ios', 'Flutter')

  # Keep script phase paths relative so they can be checked into source control.
  relative = flutter_relative_path_from_podfile(export_script_directory)

  flutter_export_environment_path = File.join('${SRCROOT}', relative, 'flutter_export_environment.sh')

  # Compile App.framework and move it and Flutter.framework to "BUILT_PRODUCTS_DIR"
  script_phase name: 'Run Flutter Build my_flutter Script',
    script: "set -e\nset -u\nsource "#{flutter_export_environment_path}"\nexport VERBOSE_SCRIPT_LOGGING=1 && "$FLUTTER_ROOT"/packages/flutter_tools/bin/xcode_backend.sh build",
    execution_position: :before_compile

  # Embed App.framework AND Flutter.framework.
  script_phase name: 'Embed Flutter Build my_flutter Script',
    script: "set -e\nset -u\nsource "#{flutter_export_environment_path}"\nexport VERBOSE_SCRIPT_LOGGING=1 && "$FLUTTER_ROOT"/packages/flutter_tools/bin/xcode_backend.sh embed_and_thin",
    execution_position: :after_compile
end

2.2.2. flutter_post_install

def flutter_post_install(installer, skip: false)
  return if skip

  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |_build_configuration|
      # flutter_additional_ios_build_settings is in Flutter root podhelper.rb
      flutter_additional_ios_build_settings(target)
    end
  end
end

进一步调用了flutter_additional_ios_build_settings,它的主要做用是根据当前环境设置MyApp的编译配置

为每个编译配置。比如framework搜索路径等

  • flutter_additional_ios_build_settings所在的文件:flutter_root/package/flutter_tools/bin/podhelper.rb
def flutter_additional_ios_build_settings(target)
  return unless target.platform_name == :ios

  # [target.deployment_target] is a [String] formatted as "8.0".
  inherit_deployment_target = target.deployment_target[/\d+/].to_i < 12

  # ARC code targeting iOS 8 does not build on Xcode 14.3.
  force_to_arc_supported_min = target.deployment_target[/\d+/].to_i < 9

  # This podhelper script is at $FLUTTER_ROOT/packages/flutter_tools/bin.
  # Add search paths from $FLUTTER_ROOT/bin/cache/artifacts/engine.
  artifacts_dir = File.join('..', '..', '..', '..', 'bin', 'cache', 'artifacts', 'engine')
  debug_framework_dir = File.expand_path(File.join(artifacts_dir, 'ios', 'Flutter.xcframework'), __FILE__)

  unless Dir.exist?(debug_framework_dir)
    # iOS artifacts have not been downloaded.
    raise "#{debug_framework_dir} must exist. If you're running pod install manually, make sure "flutter precache --ios" is executed first"
  end

  release_framework_dir = File.expand_path(File.join(artifacts_dir, 'ios-release', 'Flutter.xcframework'), __FILE__)
  # Bundles are com.apple.product-type.bundle, frameworks are com.apple.product-type.framework.
  target_is_resource_bundle = target.respond_to?(:product_type) && target.product_type == 'com.apple.product-type.bundle'

  target.build_configurations.each do |build_configuration|
    # Build both x86_64 and arm64 simulator archs for all dependencies. If a single plugin does not support arm64 simulators,
    # the app and all frameworks will fall back to x86_64. Unfortunately that case is not detectable in this script.
    # Therefore all pods must have a x86_64 slice available, or linking a x86_64 app will fail.
    build_configuration.build_settings['ONLY_ACTIVE_ARCH'] = 'NO' if build_configuration.type == :debug

    # Workaround https://github.com/CocoaPods/CocoaPods/issues/11402, do not sign resource bundles.
    if target_is_resource_bundle
      build_configuration.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
      build_configuration.build_settings['CODE_SIGNING_REQUIRED'] = 'NO'
      build_configuration.build_settings['CODE_SIGNING_IDENTITY'] = '-'
      build_configuration.build_settings['EXPANDED_CODE_SIGN_IDENTITY'] = '-'
    end

    # ARC code targeting iOS 8 does not build on Xcode 14.3. Force to at least iOS 9.
    build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '9.0' if force_to_arc_supported_min

    # Skip other updates if it does not depend on Flutter (including transitive dependency)
    next unless depends_on_flutter(target, 'Flutter')

    # Bitcode is deprecated, Flutter.framework bitcode blob will have been stripped.
    build_configuration.build_settings['ENABLE_BITCODE'] = 'NO'

    # Profile can't be derived from the CocoaPods build configuration. Use release framework (for linking only).
    # TODO(stuartmorgan): Handle local engines here; see https://github.com/flutter/flutter/issues/132228
    configuration_engine_dir = build_configuration.type == :debug ? debug_framework_dir : release_framework_dir
    Dir.new(configuration_engine_dir).each_child do |xcframework_file|
      next if xcframework_file.start_with?('.') # Hidden file, possibly on external disk.
      if xcframework_file.end_with?('-simulator') # ios-arm64_x86_64-simulator
        build_configuration.build_settings['FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]'] = ""#{configuration_engine_dir}/#{xcframework_file}" $(inherited)"
      elsif xcframework_file.start_with?('ios-') # ios-arm64
        build_configuration.build_settings['FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]'] = ""#{configuration_engine_dir}/#{xcframework_file}" $(inherited)"
       # else Info.plist or another platform.
      end
    end
    build_configuration.build_settings['OTHER_LDFLAGS'] = '$(inherited) -framework Flutter'

    build_configuration.build_settings['CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER'] = 'NO'
    # Suppress warning when pod supports a version lower than the minimum supported by Xcode (Xcode 12 - iOS 9).
    # This warning is harmless but confusing--it's not a bad thing for dependencies to support a lower version.
    # When deleted, the deployment version will inherit from the higher version derived from the 'Runner' target.
    # If the pod only supports a higher version, do not delete to correctly produce an error.
    build_configuration.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' if inherit_deployment_target

    # Override legacy Xcode 11 style VALID_ARCHS[sdk=iphonesimulator*]=x86_64 and prefer Xcode 12 EXCLUDED_ARCHS.
    build_configuration.build_settings['VALID_ARCHS[sdk=iphonesimulator*]'] = '$(ARCHS_STANDARD)'
    build_configuration.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = '$(inherited) i386'
    build_configuration.build_settings['EXCLUDED_ARCHS[sdk=iphoneos*]'] = '$(inherited) armv7'
  end
end

2.3. 编译

当MyApp工程开始编译,就胡已调用'Run Flutter Build my_flutter Script'脚本编译flutter module。

set -e
set -u
source "${SRCROOT}/../my_flutter/.ios/Flutter/flutter_export_environment.sh"
export VERBOSE_SCRIPT_LOGGING=1 && "$FLUTTER_ROOT"/packages/flutter_tools/bin/xcode_backend.sh build

这里还加载了一个文件flutter_export_environment.sh。添加了一些环境变量,这些环境变量可以控制编译flutter module的参数。

#!/bin/sh
# This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=/Users/yidao/Documents/env/1/flutter"
export "FLUTTER_APPLICATION_PATH=/Users/yidao/Documents/code/flutter/my_flutter"
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib/main.dart"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0"
export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false"
export "PACKAGE_CONFIG=.dart_tool/package_config.json"

# debug profile release
#export "FLUTTER_BUILD_MODE=release"

xcode_backend.sh其实是一个转发脚本到xcode_backend.sh build

#!/usr/bin/env bash
# Copyright 2014 The Flutter Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

# exit on error, or usage of unset var
set -euo pipefail

# Needed because if it is set, cd may print the path it changed to.
unset CDPATH

function follow_links() (
  cd -P "$(dirname -- "$1")"
  file="$PWD/$(basename -- "$1")"
  while [[ -h "$file" ]]; do
    cd -P "$(dirname -- "$file")"
    file="$(readlink -- "$file")"
    cd -P "$(dirname -- "$file")"
    file="$PWD/$(basename -- "$file")"
  done
  echo "$file"
)

PROG_NAME="$(follow_links "${BASH_SOURCE[0]}")"
BIN_DIR="$(cd "${PROG_NAME%/*}" ; pwd -P)"
FLUTTER_ROOT="$BIN_DIR/../../.."
DART="$FLUTTER_ROOT/bin/dart"

"$DART" "$BIN_DIR/xcode_backend.dart" "$@"

xcode_backend.sh进一步调用了同文件夹下的xcode_backend.dart build。看xcode_backend关键代码:

void main(List<String> arguments) {
  File? scriptOutputStreamFile;
  final String? scriptOutputStreamFileEnv = Platform.environment['SCRIPT_OUTPUT_STREAM_FILE'];
  if (scriptOutputStreamFileEnv != null && scriptOutputStreamFileEnv.isNotEmpty) {
    scriptOutputStreamFile = File(scriptOutputStreamFileEnv);
  }
  Context(
    arguments: arguments,
    environment: Platform.environment,
    scriptOutputStreamFile: scriptOutputStreamFile,
  ).run();
}

class Context {
  Context({required this.arguments, required this.environment, File? scriptOutputStreamFile}) {
    if (scriptOutputStreamFile != null) {
      scriptOutputStream = scriptOutputStreamFile.openSync(mode: FileMode.write);
    }
  }

  final Map<String, String> environment;
  final List<String> arguments;
  RandomAccessFile? scriptOutputStream;

  void run() {
    if (arguments.isEmpty) {
      // Named entry points were introduced in Flutter v0.0.7.
      stderr.write(
        'error: Your Xcode project is incompatible with this version of Flutter. '
        'Run "rm -rf ios/Runner.xcodeproj" and "flutter create ." to regenerate.\n',
      );
      exit(-1);
    }

    final String subCommand = arguments.first;
    switch (subCommand) {
      case 'build':
        buildApp(); //>>>>>>>>>>>>>>>看这里<<<<<<<<<<<
      case 'prepare':
        prepare();
      case 'thin':
        // No-op, thinning is handled during the bundle asset assemble build target.
        break;
      case 'embed':
        embedFlutterFrameworks();
      case 'embed_and_thin':
        // Thinning is handled during the bundle asset assemble build target, so just embed.
        embedFlutterFrameworks();
      case 'test_vm_service_bonjour_service':
        // Exposed for integration testing only.
        addVmServiceBonjourService();
    }
  }
}
  • main函数调用Context.run
  • run通过判断传参为build,然后调用buildApp()方法
  void buildApp() {
    final bool verbose = (environment['VERBOSE_SCRIPT_LOGGING'] ?? '').isNotEmpty;
    final String sourceRoot = environment['SOURCE_ROOT'] ?? '';
    final String projectPath = environment['FLUTTER_APPLICATION_PATH'] ?? '$sourceRoot/..';

    final String buildMode = parseFlutterBuildMode();

    final List<String> flutterArgs = _generateFlutterArgsForAssemble('build', buildMode, verbose);

    flutterArgs.add('${buildMode}_ios_bundle_flutter_assets');

    final ProcessResult result = runSync(
      '${environmentEnsure('FLUTTER_ROOT')}/bin/flutter',
      flutterArgs,
      verbose: verbose,
      allowFail: true,
      workingDirectory: projectPath, // equivalent of RunCommand pushd "${project_path}"
    );

    if (result.exitCode != 0) {
      echoError('Failed to package $projectPath.');
      exitApp(-1);
    }

    streamOutput('done');
    streamOutput(' └─Compiling, linking and signing...');

    echo('Project $projectPath built and packaged successfully.');
  }

buildApp完成:

  • 基于环境准备编译参数
  • 通过runSync函数编译flutter module

重点看一下对parseFlutterBuildMode();的调用。

  • 它会返回当前的编译模式:debug、profile、release

编译模式从两个环境变量获取:

  • environment['FLUTTER_BUILD_MODE']:优先使用此变量。可以在上面提到的flutter_export_environment.sh中设置,也可以通过其他途径设置。
  • environment['CONFIGURATION']:如果上面变量没有设置,则使用此变量。此变量是Xcode从工程文件设置中读取并设置此变量。
  • 总结:如果没有设置FLUTTER_BUILD_MODE,则按照xcode当前编译设置选择编译模式。这样就说xcode编译debug包,则flutter module也编译debug包,如果是.......
  String parseFlutterBuildMode() {
    // Use FLUTTER_BUILD_MODE if it's set, otherwise use the Xcode build configuration name
    // This means that if someone wants to use an Xcode build config other than Debug/Profile/Release,
    // they _must_ set FLUTTER_BUILD_MODE so we know what type of artifact to build.
    final String? buildMode =
        (environment['FLUTTER_BUILD_MODE'] ?? environment['CONFIGURATION'])?.toLowerCase();

    if (buildMode != null) {
      if (buildMode.contains('release')) {
        return 'release';
      }
      if (buildMode.contains('profile')) {
        return 'profile';
      }
      if (buildMode.contains('debug')) {
        return 'debug';
      }
    }
  }

2.4. 编译后

通过编译前注入的“Embed Flutter Build my_flutter Script”脚本,完成Embed Flutter Build

set -e
set -u
source "${SRCROOT}/../my_flutter/.ios/Flutter/flutter_export_environment.sh"
export VERBOSE_SCRIPT_LOGGING=1 && "$FLUTTER_ROOT"/packages/flutter_tools/bin/xcode_backend.sh embed_and_thin

这个阶段的职责:

操作 说明
嵌入 Flutter.framework 和 App.framework 确保它们包含在最终 App 中
瘦身处理(thin) 移除不必要的架构,提高打包效率
复制资源 包括 Dart AOT、Asset、VM Snapshot 等运行时资源

和编译脚本同样也会调用Context的run函数:

void run() {
  if (arguments.isEmpty) {
    // Named entry points were introduced in Flutter v0.0.7.
    stderr.write(
      'error: Your Xcode project is incompatible with this version of Flutter. '
      'Run "rm -rf ios/Runner.xcodeproj" and "flutter create ." to regenerate.\n',
    );
    exit(-1);
  }

  final String subCommand = arguments.first;
  switch (subCommand) {
    case 'build':
      buildApp();
    case 'prepare':
      prepare();
    case 'thin':
      // No-op, thinning is handled during the bundle asset assemble build target.
      break;
    case 'embed':
      embedFlutterFrameworks();
    case 'embed_and_thin':
      // Thinning is handled during the bundle asset assemble build target, so just embed.
      embedFlutterFrameworks();
    case 'test_vm_service_bonjour_service':
      // Exposed for integration testing only.
      addVmServiceBonjourService();
  }
}

这里调用了embedFlutterFrameworks函数:

  void embedFlutterFrameworks() {
    // Embed App.framework from Flutter into the app (after creating the Frameworks directory
    // if it doesn't already exist).
    final String xcodeFrameworksDir =
        '${environment['TARGET_BUILD_DIR']}/${environment['FRAMEWORKS_FOLDER_PATH']}';
    runSync('mkdir', <String>['-p', '--', xcodeFrameworksDir]);
    runRsync(
      delete: true,
      '${environment['BUILT_PRODUCTS_DIR']}/App.framework',
      xcodeFrameworksDir,
    );

    // Embed the actual Flutter.framework that the Flutter app expects to run against,
    // which could be a local build or an arch/type specific build.
    runRsync(
      delete: true,
      '${environment['BUILT_PRODUCTS_DIR']}/Flutter.framework',
      '$xcodeFrameworksDir/',
    );

    // Copy the native assets. These do not have to be codesigned here because,
    // they are already codesigned in buildNativeAssetsMacOS.
    final String sourceRoot = environment['SOURCE_ROOT'] ?? '';
    String projectPath = '$sourceRoot/..';
    if (environment['FLUTTER_APPLICATION_PATH'] != null) {
      projectPath = environment['FLUTTER_APPLICATION_PATH']!;
    }
    final String flutterBuildDir = environment['FLUTTER_BUILD_DIR']!;
    final String nativeAssetsPath = '$projectPath/$flutterBuildDir/native_assets/ios/';
    final bool verbose = (environment['VERBOSE_SCRIPT_LOGGING'] ?? '').isNotEmpty;
    if (Directory(nativeAssetsPath).existsSync()) {
      if (verbose) {
        print('♦ Copying native assets from $nativeAssetsPath.');
      }
      runRsync(
        extraArgs: <String>['--filter', '- native_assets.yaml', '--filter', '- native_assets.json'],
        nativeAssetsPath,
        xcodeFrameworksDir,
      );
    } else if (verbose) {
      print("♦ No native assets to bundle. $nativeAssetsPath doesn't exist.");
    }

    addVmServiceBonjourService();
  }

至此flutter module的编译完成

3. 解决debug包提测问题

3.1. 问题:

  • 有些业务需要再debug模式下提测,但提交给测试工程师的debug包flutter初始化会失败。

3.2. 原因:

3.2.1. 分析

  • Debug 模式的 Flutter Add-to-App 项目中,flutter module 会被编译为debug包。FlutterEngine 启动时会尝试 连接开发主机上的 flutter attach 服务,用于:
    • 调试功能(热重载、日志打印、DevTools)
    • 加载 Dart VM snapshot 等临时调试资源
  • 而这些功能只有在 Xcode 启动时会设置好相关参数路径(通过环境变量或启动参数传入)

3.2.2. 所以:

  • ✅ Xcode 启动时,会自动设置 --observatory-port 等参数,FlutterEngine 知道去哪找 Dart runtime
  • ❌ 图标点击启动时,这些参数没有传入,FlutterEngine 找不到调试环境、资源路径,导致 run 返回 NO(Dart isolate 无法启动)

3.3. 如何解决

  • 思路:在debug App包中中使用flutter module的profile或release包。profile或release包不依赖xcode提供的环境。但也不能hot reload dart代码。
  • 实现:可以通过设置上面提到的“environment['FLUTTER_BUILD_MODE']”环境变量,控制flutter module的编译模式
#!/bin/sh
# This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=/Users/yidao/Documents/env/1/flutter"
export "FLUTTER_APPLICATION_PATH=/Users/yidao/Documents/code/flutter/my_flutter"
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib/main.dart"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0"
export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false"
export "PACKAGE_CONFIG=.dart_tool/package_config.json"

# debug profile release
export "FLUTTER_BUILD_MODE=release"

swiftui使用String Catalog文件实现语言本地化

作者 逆向APP
2025年7月21日 13:57

自定义view中的字符串不能自动加入Localizable.xcstrings文件中,自定义代码:

import SwiftUI

struct FeatureCard: View {
    var title: String
    var systemImage: String
    var color: Color
    
    var body: some View {
        VStack {
            Image(systemName: systemImage)
                .font(.system(size: 30))
                .foregroundColor(color)
                .padding(.bottom, 8)
            
            Text(title)
                .font(.system(size: 14, weight: .medium))
                .multilineTextAlignment(.center)
        }
        .frame(width: 100, height: 100)
        .background(.ultraThinMaterial)
        .cornerRadius(15)
        .shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2)
    }
}

你当前定义中: 

 var title: String 这正是导致 Xcode 无法自动将这些字符串提取到 Localizable.xcstrings 的核心原因。

 🧠 原理再强调一下: 

 Xcode 的 String Catalog 只能自动收集 SwiftUI 中使用 Text("字符串字面量") 或 LocalizedStringKey 的字符串。 

 你现在传进去的是 String 类型(虽然你传的是字面量),但因为类型是 String,SwiftUI 会自动调用 Text(verbatim:) 初始化器,而这个是不参与本地化提取的。 

 ✅ 正确写法:

改成 LocalizedStringKey 请将 title: String 改成: 

 var title: LocalizedStringKey 

 这是 SwiftUI 专门为本地化设计的类型。 

 改完后你的 FeatureCard 组件如下:

struct FeatureCard: View {
    var title: LocalizedStringKey  // ✅ 改这里
    var systemImage: String
    var color: Color

    var body: some View {
        VStack {
            Image(systemName: systemImage)
                .font(.system(size: 30))
                .foregroundColor(color)
                .padding(.bottom, 8)

            Text(title)  // ✅ 这样才会被 Xcode 编译器识别为可本地化的 key
                .font(.system(size: 14, weight: .medium))
                .multilineTextAlignment(.center)
        }
        .frame(width: 100, height: 100)
        .background(.ultraThinMaterial)
        .cornerRadius(15)
        .shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2)
    }
}

调用的时候什么都不需要改,你依然可以这样写:

 FeatureCard(title: "提取资源", systemImage: "square.and.arrow.down", color: .blue) 

🧪 检查是否生效的方法:

 1. ✅ 清理工程(Product → Clean Build Folder)

 2. ✅ 重新构建项目(Cmd + B)

 3. ✅ 在 Xcode 的左侧导航栏中找到 Localizable.xcstrings,检查是否已经自动添加了 "提取资源"、"海外素材" 等字符串。

 4. ❗注意不要使用字符串拼接、格式化等方式传参(这会打破静态分析)。 

✨ 额外建议(增强维护性): 

 为了更好地维护和集中管理你这些本地化 Key,建议你可以额外定义一个 enum 或 struct: 

struct LocalizedKeys {
    static let extractResources = LocalizedStringKey("提取资源")
    static let overseasMaterial = LocalizedStringKey("海外素材")
    static let xhsLivePhoto = LocalizedStringKey("红薯实况图")
    // ...其他key
}

然后调用时使用: 

 FeatureCard(title: LocalizedKeys.extractResources, systemImage: "square.and.arrow.down", color: .blue) 

 更易于管理和本地化多语言版本。

iOS26适配指南之动画

作者 YungFan
2025年7月21日 06:00

介绍

新增了一种新的动画选项flushUpdates,它可以自动根据 @Observable Object 或者 AutoLayout 约束的更改添加动画效果,而无需手动调用layoutIfNeeded()方法。

使用

  • 代码。
import UIKit

@Observable class Model {
    var backgroundColor: UIColor = .systemGray
}

class ViewController: UIViewController {
    lazy var redView: UIView = {
        let view = UIView(frame: CGRect(x: 0, y: 50, width: 100, height: 100))
        view.backgroundColor = .systemRed
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    var widthConstraint: NSLayoutConstraint!
    var heightConstraint: NSLayoutConstraint!
    let model = Model()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(redView)

        widthConstraint = redView.widthAnchor.constraint(equalToConstant: 100)
        heightConstraint = redView.heightAnchor.constraint(equalToConstant: 100)
        widthConstraint.isActive = true
        heightConstraint.isActive = true
        redView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        redView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
    }

    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        view.backgroundColor = model.backgroundColor
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        // UIView动画
        UIView.animate(withDuration: 2.0, delay: 0, options: .flushUpdates) {
            self.model.backgroundColor = .systemBlue
        } completion: { _ in
            // UIViewPropertyAnimator动画
            _ = UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 2.0,
                                                               delay: 0,
                                                               options: .flushUpdates) {
                self.widthConstraint.constant = 300
                self.heightConstraint.constant = 300
            } completion: { _ in
                print("动画完成")
            }
        }
    }
}
  • 效果。

动画.gif

昨天 — 2025年7月21日iOS

F1:电影很好看,赛事很挣钱 - 肘子的 Swift 周报 #94

作者 Fatbobman
2025年7月21日 22:00

上周我去影院看了苹果出品的电影《F1》,体验真的超乎预期。虽然剧情略显简单,但影片营造出的赛车沉浸感和观赛体验已经远超票价。当布拉德·皮特驾驶着 APXGP 赛车在银石赛道上疾驰时,那引擎的咆哮通过 IMAX 音响系统传来的震撼感,让我仿佛回到了 20 多年前,在电视机前为舒马赫和阿隆索激烈对决而热血沸腾的日子。

昨天以前iOS

老司机 iOS 周报 #343 | 2025-07-21

作者 ChengzhiHuang
2025年7月20日 22:23

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

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

新手推荐

🐎 Meet the Inspector view in SwiftUI

@阿权:文章介绍了 SwiftUI 中检查器视图(Inspector View)的用法:

  1. 基本概念与使用场景:
    1. 检查器视图用于展示主内容的附加信息,默认在右侧展示(macOS/iPadOS),在紧凑尺寸设备(如竖屏 iPhone)上以 sheet 形式呈现。
    2. 需要 macOS 14+ 和 iOS 17+。
  2. 基本用法:通过 inspector(isPresented:content:) 控制检查视图的显隐。
  3. 自定义宽度:支持固定宽度 inspectorColumnWidth(_:),和用户拖拽控制宽度 inspectorColumnWidth(min:ideal:max:)
  4. 检查器入口按钮与导航工具栏的适配,问题提供了三种适配方案。
  5. 在紧凑尺寸中的展示效果及其适配方案。

在苹果提供中的 App 中,检查视图是用户早已习惯的交互形式,使用检查视图可能提升 App 的专业性并提升用户体验。

文章

🌟 🐢 An open source tool to speed up iOS app launch

@ChengzhiHuang:Emerge Tools 提供了一个轻量级的收集 App 启动阶段内执行到的函数的收集工具,而不用修改编译选项(开启插桩),以此可以用来投喂给 Xcode 进行二进制重排,进而通过减少启动过程中的 Page In 数量来加快启动速度。技术方案是通过 Linkmap 或者 trick 的方式 收集函数起始的地址。然后对所有函数起始地址替换汇编指令为触发 ARM64_BREAK_INSTRUCTION 。在捕获断点后,记录下此方法,然后跳转原始逻辑继续执行。其中有非常多的细节,推荐对此感兴趣的同学阅读。

同时可以对此工具稍加改造,以此适配收集 App 任意阶段内的执行函数(如某个巨大模块),然后执行预热,以加快某个模块的冷启动首次进入耗时;同时其中涉及到与 SimpleDebugger 的联动,可以进行修改优化,目前是每个函数都执行一次 StopTheWorld + 替换单个函数汇编方便断点记录,可以修改为 批量执行一次 StopTheWorld + 替换所有需要函数。

🐕 Everything You Should Know About Spacer

@AidenRao:SwiftUI 提供了四种 Spacer 类型实现灵活布局:通用 Spacer 在可用空间内沿主轴扩展;_TextBaselineRelativeSpacer 支持文本基线对齐;_HSpacer/_VSpacer 严格限定方向。它们通过 PrimitiveSpacer 协议共享核心属性(minLengthaxisrequireTextBaselineSpacing

🐕 Swift 6.2 Java 互操作性实践

@含笑饮砒霜:这篇文章展示了如何通过 Swift 6.2 的 swift-java 包实现 Swift 与 Java 的高效互操作。整个过程通过标准工具链(SwiftPM + Makefile)自动化,从 Java 编译到 Swift 调用无缝衔接,大大简化了跨语言开发的复杂度。最终实现了 Swift 程序可以优雅地调用 Java 方法的目标。

🐕 What you need to know before migrating to Swift Testing

@JonyFang: 一篇 Swift Testing 迁移指南摘要,介绍了从传统 XCTest 框架迁移到 Swift Testing 框架前需要了解的关键信息。如:

  • Swift Testing 框架的主要优势和新特性
  • 迁移过程中可能遇到的常见兼容性问题
  • Swift Testing 与 XCTest 的语法和结构差异
  • 一些迁移策略建议和最佳实践

对于正在考虑升级测试框架的 iOS 开发者来说,这是一篇实用的参考指南。

代码

部分使用了 Data.bytes 的代码无法正常编译

@Smallfly:iOS 26 SDK 中 Foundation.Data 扩展了新的属性 var bytes: RawSpan { get },如果之前的实现里面使用了 CryptoSwift 对 Data 添加的 extension 中的 var bytes: Array<UInt8> 且未明确 import CryptoSwift 就会遇到编译器无法正确匹配实现的问题。

使用到的位置主动 import CryptoSwift 明确实现来源。

CryptoSwift 已将 extension 重命名为 byteArray ,PR

CrazyFanFan 提供信息

内推

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

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

关注我们

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

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

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

说明

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

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

Flutter PlatformView实战:嵌入原生 iOS 视图

作者 MaoJiu
2025年7月19日 20:36

flutter.webp


Flutter 的跨平台能力非常出色,但有时你需要使用一个平台特有的、在 Flutter 中没有对应实现的 UI 组件。你可能需要集成一个复杂的、经过实战检验的原生 SDK,或者你只是想复用一个已有的原生视图。这时候,PlatformView 就派上用场了。

PlatformView 允许你将原生的 UIView (在 iOS 上) 和 View (在 Android 上) 直接嵌入到你的 Flutter widget 树中。它是一个强大的功能,充当了你的 Flutter UI 和原生平台之间的桥梁。

在本文我们将通过一个完整的示例,演示如何使用 SwiftUI 将一个原生的 iOS MapKit 地图视图嵌入到 Flutter 应用中。

1. 在 Flutter 中显示原生视图

在 Flutter 中使用一个特殊的 widget 来承载原生视图。对于 iOS,这个 widget 是 UiKitView

lib/map_view.dart 文件中,定义了一个 MapView widget。这个 widget 的核心就是 UiKitView

// ... existing code ...
class _MapViewState extends State<MapView> {
  // ... existing code ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: UiKitView(
        // 一个唯一的标识符,用于将此 widget 连接到原生工厂。
        viewType: "map_view",
        layoutDirection: TextDirection.ltr,
        // 在创建时传递给原生视图的数据。
        creationParams: <String, dynamic>{
          "latitude": 23.12911,
          "longitude": 113.264385,
        },
        // 用于对 creationParams 进行编解码的编解码器。
        creationParamsCodec: const StandardMessageCodec(),
        onPlatformViewCreated: (id) {
          print("MapView created with id: $id");
        },
      ),
    );
  }
}

让我们分解一下 UiKitView 的关键属性:

  • viewType: 这是一个至关重要的 String 标识符。Flutter 使用它来查找相应的原生“工厂”,这个工厂知道如何创建我们想要的原生视图。
  • creationParams: 一个 Map 类型的动态数据,你希望在初始化时从 Flutter 发送到原生端。在我们的例子中,我们传递了地图的初始坐标。
  • creationParamsCodec: 这指定了 creationParams 应该如何在 Dart 和原生平台之间进行编码和解码。StandardMessageCodec 是一个通用的选择,支持常见的数据类型。

2. 注册原生视图工厂 (iOS)

在原生 iOS 端,我们需要告诉我们的 Flutter 应用如何构建与 viewType ("map_view") 关联的视图。这是通过注册一个 FlutterPlatformViewFactory 来完成的。

注册过程发生在 ios/Runner/AppDelegate.swift 中。

// ... existing code ...
@available(iOS 17.0, *)
@main
@objc class AppDelegate: FlutterAppDelegate {
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        GeneratedPluginRegistrant.register(with: self)

        // ... MethodChannel 注册 ...

        /// 注册平台视图
        guard let registrar = self.registrar(forPlugin: "map_view") else {
            fatalError("Failed to get registrar")
        }
        // 实例化我们的工厂。
        let factory = MapViewFactory(messenger: registrar.messenger())
        // 使用唯一ID "map_view" 注册工厂。
        // 这必须与 UiKitView 中的 `viewType` 匹配。
        registrar.register(factory, withId: "map_view", gestureRecognizersBlockingPolicy: FlutterPlatformViewGestureRecognizersBlockingPolicyWaitUntilTouchesEnded)

        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

在这里,我们创建了一个 MapViewFactory 的实例,并用 ID "map_view" 注册了它。现在,每当 Flutter 中构建一个带有此 viewTypeUiKitView 时,Flutter 就会请求我们的 MapViewFactory 来创建相应的原生视图。

3. 实现工厂和平台视图 (iOS)

现在我们来看看工厂本身以及它所创建的平台视图。

3.1. 工厂 (MapViewFactory.swift)

工厂的工作很简单:创建我们的平台视图的一个实例。

// ... existing code ...
@available(iOS 17.0, *)
class MapViewFactory: NSObject, FlutterPlatformViewFactory {
    private var messenger: FlutterBinaryMessenger?

    init(messenger: FlutterBinaryMessenger) {
        self.messenger = messenger
        super.init()
    }

    // ...

    // Flutter 调用此方法来创建原生视图。
    func create(
        withFrame frame: CGRect,
        viewIdentifier viewId: Int64,
        arguments args: Any?
    ) -> any FlutterPlatformView {

        // 它返回我们的平台视图类的一个实例。
        return MapView(
            frame: frame,
            viewIdentifier: viewId,
            arguments: args,
            binaryMessenger: messenger
        )
    }
}

3.2. 平台视图 (MapView.swift)

这是主要的桥接类。它遵守 FlutterPlatformView 协议,并负责创建和管理实际 UIView 的生命周期。

// ... existing code ...
@available(iOS 17.0, *)
class MapView: NSObject, FlutterPlatformView {
    // 这持有将要被嵌入的实际 UIView。
    private var _mapView: UIView

    private var hostingController: UIHostingController<MapContentView>?

    init(
        frame: CGRect,
        viewIdentifier viewId: Int64,
        arguments args: Any?, // 这些是来自 Flutter 的 creationParams。
        binaryMessenger messenger: FlutterBinaryMessenger?
    ) {
        self._mapView = UIView()
        super.init()
        // 我们调用一个辅助方法来设置 SwiftUI 视图。
        createMapView(view: _mapView, args: args)
    }

    // 这个方法必须返回 Flutter 将要显示的 UIView。
    func view() -> UIView {
        return _mapView
    }

    func createMapView(view: UIView, args: Any?) {
        // 1. 解码来自 Flutter 的参数。
        guard let args = args as? [String: Any] else { return }
        guard let latitude = args["latitude"] as? Double,
        let longitude = args["longitude"] as? Double else { return }

        // 2. 用数据创建我们的 SwiftUI 视图。
        let mapContentView = MapContentView(latitude: latitude, longitude: longitude)

        // 3. 将 SwiftUI 视图托管在 UIHostingController 中。
        hostingController = UIHostingController(rootView: mapContentView)
        guard let hostingController = hostingController else { return }

        // 4. 将托管控制器的视图添加为子视图并设置约束。
        _mapView.addSubview(hostingController.view)
        // ... AutoLayout 约束 ...
    }
}

在这个类中:

  1. init 方法接收我们从 Dart 作为 creationParams 传递过来的 arguments
  2. 我们解析这些参数以获取纬度和经度。
  3. 我们初始化我们的 MapContentView (一个 SwiftUI 视图),并将其包装在一个 UIHostingController 中,以便它可以作为标准的 UIView 使用。
  4. view() 方法返回这个 UIView,然后 Flutter 将其渲染出来。

4. 使用 MethodChannel 进行双向通信

显示视图很棒,但是交互呢?我们需要一种方法让原生视图能够回过头来与 Flutter 通信。为此,我们使用 MethodChannel

4.1. 设置通道

通道必须在 Flutter 和原生两端用相同的名称进行初始化。

  1. Flutter ( lib/map_view.dart ):
    我们创建通道并设置一个处理器来监听来自原生的方法调用。
class _MapViewState extends State<MapView> {
  // 1. 使用与原生代码中相同的名称创建通道。
  final MethodChannel _channel = MethodChannel("map_view");

  @override
  void initState() {
    super.initState();
    // 2. 设置一个处理器来处理来自原生端的消息。
    _channel.setMethodCallHandler(_handle);
  }

  // 3. 处理器函数。
  Future<void> _handle(MethodCall call) async {
    switch (call.method) {
        // 如果原生端调用 "backFlutterView"...
      case "backFlutterView":
        // ...则弹出当前路由以返回。
        Navigator.pop(context);
        break;
    }
  }
  // ...
}
  1. iOS ( ios/Runner/AppDelegate.swift ):
    我们注册相同的通道,并且为了方便起见,将它存储在一个全局单例中,以便我们原生代码的其他部分可以轻松访问它。
/// 一个简单的单例来持有对通道的引用。
public class ChannelManager {
    static let shared = ChannelManager()
    var methodChannel: FlutterMethodChannel?
    private init() {}
}

@available(iOS 17.0, *)
@main
@objc class AppDelegate: FlutterAppDelegate {
    override func application( /* ... */ ) -> Bool {
        // ...
        /// 注册通道
        guard let flutterViewController = window?.rootViewController as? FlutterViewController else {
            fatalError("RootViewController is not FlutterViewController")
        }
        let channel = FlutterMethodChannel(name: "map_view", binaryMessenger: flutterViewController.binaryMessenger)
        // 将通道存储在我们的单例中。
        ChannelManager.shared.methodChannel = channel
        // ...
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

4.2. 从原生调用 Flutter

现在,我们的原生视图可以使用这个通道来发送消息。在我们的 MapContentView 中,我们有一个原生的返回按钮。当点击它时,它会在通道上调用 "backFlutterView" 方法。

// ...
struct MapContentView: View {
    // ...
    var body: some View {
        ZStack {
            // ... 地图和其他 UI ...
            VStack {
                HStack {
                    Image(systemName: "chevron.left")
                    // ... 样式 ...
                    .onTapGesture(perform: onBackTap) // 点击时调用 onBackTap。
                    Spacer()
                    // ... 菜单 ...
                }
                // ...
            }
        }
        .ignoresSafeArea()
    }

    // 这个函数向 Flutter 发送消息。
    private func onBackTap() {
        DispatchQueue.main.async {
            // 使用共享的通道来调用一个方法。
            ChannelManager.shared.methodChannel?.invokeMethod(
                "backFlutterView", // 要调用的方法名。
                arguments: nil
            )
        }
    }
}

当这段代码运行时,它会通过 "map_view" 通道发送一条消息。我们的 Flutter _handle 函数接收到此消息,看到方法名是 "backFlutterView",然后执行 Navigator.pop(context),从而关闭地图屏幕并返回到主 Flutter 页面。

5. 总结

PlatformView 是与原生平台进行深度集成不可或缺的工具。虽然它可能会有性能方面的影响(尤其是在旧版 Android 上),但它提供了一种强大的方式来在你的 Flutter 应用中利用完整的原生生态系统。

6. 演示

录屏 2025年7月19日.gif

Flutter 中使用NestedScrollView + SliverAppBar 打造关注/粉丝列表(并结合 Bloc 状态管理)

作者 Daniel02
2025年7月19日 17:50

Flutter 中使用 NestedScrollView + SliverAppBar 打造关注/粉丝列表(并结合 Bloc 状态管理)

Demo 仓库github.com/wutao23yzd/…


前言

在日常社区类 App 开发中,「用户主页」往往需要展示 关注 / 粉丝 等多 Tab 列表,并且顶部通常伴随着可吸顶的用户信息栏。要做到:

  1. 顶部信息栏(SliverAppBar)通过 pinned: true 始终固定在顶部;
  2. TabBarTabBarView 滑动手势联动;
  3. 列表切换时保持滚动位置 & 避免重复构建;
  4. 状态统一交由 Bloc 管理;

本文将基于一段完整的 Demo 代码,拆解实现思路与关键细节。


目录结构概览

lib/
├─ main.dart                 // 入口 & Provider 注入
├─ app_scaffold.dart         // 通用 Scaffold 封装
└─ follow/
   ├─ bloc/                  // ✨ Bloc
   |  ├─ user_profile_bloc.dart
   |  ├─ user_profile_event.dart
   |  └─ user_profile_state.dart
   └─ view/
      ├─ user_profile_statistics.dart  // 页面主体
      ├─ user_profile_followers.dart   // 粉丝列表
      ├─ user_profile_following.dart   // 关注列表
      ├─ user_profile_list_tile.dart   // 列表项
      ├─ user_profile_button.dart      // 通用按钮
      └─ tappable.dart                 // 自定义点击反馈

命名约定:所有与「用户资料」相关的文件统一前缀 user_profile_,查看起来更直观。


关键依赖

Package 说明
flutter_bloc Bloc/Cubit 状态管理
equatable Immutable state 对比

UI 结构拆解

1. 入口 & BlocProvider

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.black)),
      home: BlocProvider(
        create: (_) => UserProfileBloc(),
        child: const UserProfileStatistics(tabIndex: 0),
      ),
    );
  }
}

通过 顶层 BlocProvider 注入 UserProfileBloc,后续所有子组件可直接 context.select(...) 读取状态。

2. UserProfileStatistics 页面核心

class UserProfileStatistics extends StatefulWidget {
  const UserProfileStatistics({super.key, required this.tabIndex});
  final int tabIndex;
  ...
}

class _UserProfileStatisticsState extends State<UserProfileStatistics>
    with TickerProviderStateMixin {
  late final TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 2, vsync: this);
    // 根据外部传入的 tabIndex 初始定位
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _tabController.animateTo(widget.tabIndex);
    });
  }

  @override
  Widget build(BuildContext context) {
    return AppScaffold(
      body: NestedScrollView(
        headerSliverBuilder: (_, __) => [
          SliverOverlapAbsorber(
            handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
            sliver: UserProfileStatisticsAppBar(controller: _tabController),
          ),
        ],
        body: TabBarView(
          controller: _tabController,
          children: const [
            UserProfileFollowers(),
            UserProfileFollowing(),
          ],
        ),
      ),
    );
  }
}
  • SliverOverlapAbsorber/Injector 解决 Tab 内部滚动冲突
  • AutomaticKeepAliveClientMixin 保证 TabBarView 内部列表 不被销毁

3. 顶部 SliverAppBar + TabBar

class UserProfileStatisticsAppBar extends StatelessWidget {
  const UserProfileStatisticsAppBar({super.key, required this.controller});
  final TabController controller;

  @override
  Widget build(BuildContext context) {
    final followers = context.select((UserProfileBloc b) => b.state.followersCount);
    final followings = context.select((UserProfileBloc b) => b.state.followingsCount);
    final user = context.select((UserProfileBloc b) => b.state.user);

    return SliverAppBar(
      pinned: true,
      title: Text(user.username ?? ''),
      bottom: TabBar(
        controller: controller,
        indicatorWeight: 1,
        labelColor: context.adaptiveColor,
        tabs: [
          Tab(text: '$followers 粉丝'),
          Tab(text: '$followings 关注'),
        ],
      ),
    );
  }
}

pinned: trueSliverAppBar 在滚动到顶后保持可见;两侧 Tab 文案通过 Bloc 实时反映 关注/粉丝数量。

4. 列表项 UserProfileListTile

class UserProfileListTile extends StatelessWidget {
  const UserProfileListTile({super.key, required this.user, required this.follower});
  final User user; // 当前渲染的用户
  final bool follower; // 是否粉丝列表

  @override
  Widget build(BuildContext context) {
    return Tappable.faded(
      onTap: () {},
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      child: Row(
        children: [
          CircleAvatar(foregroundImage: NetworkImage(user.avatarUrl ?? ''), radius: 26),
          const SizedBox(width: 12),
          Expanded(child: _UserInfo(follower: follower, user: user)),
        ],
      ),
    );
  }
}
  • 自定义 Tappable.faded 组件通过 FadeTransition 实现 点击渐变动效
  • UserActionButton 根据当前登录用户/目标用户身份动态切换「关注 / 粉丝 / 移除」等按钮文案。

Bloc 状态管理

1. State

class UserProfileState extends Equatable {
  const UserProfileState({
    required this.user,
    required this.followings,
    required this.followers,
  });

  // 省略初始 mock 数据...
  @override
  List<Object> get props => [user, followings, followers];
}

2. Event

目前 Demo 仅展示静态数据,正式接入接口后可以新增:

sealed class UserProfileEvent extends Equatable {
  const UserProfileEvent();
}

class FollowToggled extends UserProfileEvent {
  final User target;
  const FollowToggled(this.target);
}

3. Bloc

class UserProfileBloc extends Bloc<UserProfileEvent, UserProfileState> {
  UserProfileBloc() : super(UserProfileState.initial()) {
    on<FollowToggled>(_onFollowToggled);
  }

  void _onFollowToggled(
    FollowToggled event,
    Emitter<UserProfileState> emit,
  ) {
    // 根据 event.target.id 更新 followings / followers
    // emit(newState);
  }
}

推荐再配合 `` 将用户资料进行本地持久化,优化首屏体验。


交互与细节

细节 方案
点击反馈 Tappable.faded 使用 AnimationController + FadeTransition,避免手写 GestureDetector 重复代码
iOS / Android 状态栏主题 SystemUiOverlayStyle 封装,自适应亮/暗模式
垂直方向锁定 SystemUiOverlayTheme.setPortraitOrientation() 限制为竖屏

性能优化点

  1. 缓存列表cacheExtent: 2760SliverList 预缓存更多 item,提升滚动流畅度;
  2. 避免重建:两列表分别混入 AutomaticKeepAliveClientMixin,切 Tab 不重跑 build
  3. Selector 粒度context.select 精准订阅,只有关心字段变化时才重建 widget;

小结

  • 解决「顶部信息随滚动吸顶」与「内部 Tab 列表滑动」的场景。
  • 通过 Bloc 将 UI 与业务完全解耦,状态单向流动,易于测试与维护。
  • 一些常见痛点(滚动冲突、点击反馈、亮暗适配)都可以通过封装组件复用。

希望本文能帮助你在实际项目中快速落地关注/粉丝页,或者提供一些架构上的启发。🎉

慢跑

作者 云风
2025年7月20日 12:21

我这两年攀岩时总是体力不够用,出去野攀如果需要先爬山接近的话,往往爬到岩壁下就累个半死不想动了。而我那帮 50 多岁的岩友一个个都比我有活力的多。所以我想通过有氧运动改善一下心肺功能。岩友建议我试试慢跑。

去年底痛风发作 后也考虑过减少一些体重,据说有利于降低尿酸。但有点懒就一直没有开始跑。

我的身体状态是这样的:

目前身高 187 ,大学毕业时大约 183 ,后来 20 多年陆续又长了几厘米。大学刚毕业时体重只有 71 kg ,非常瘦。在 2002 年左右开始去健身房撸铁增肌,最高长到过 78kg 。后来去杭州没那么勤快了,又掉下来不少。到 2011 年回到广州时只剩下 74kg 不到。当时身高 185 - 186 之间,后来这 15 年又长了点身高,体重却在孩子出生后暴增,最高到过 90 kg 以上 。

前几年有一段时间,我自己在家做 HIIT 希望可以减重。2020 年时,因为尿路结石看了急症 。之后改做跳绳(希望可以排石),最后体重降到了 84 kg 。

最近一年因为不再上班工作了,除了偶尔(一周两到三次)出门去岩馆攀岩,几乎都在家里。体重在 3 个月前又升到了 91kg 。


大约在两个半月前,我下决心增加一些运动量。除了每周三次的攀岩外,另外四天每天做半个小时以上的慢跑。听取岩友建议,买了双软底的跑步鞋(体重较大,应重点保护膝盖)。选择在家旁边的公园,有质地比较软的跑步道。根据网上信息的测算,根据我的年龄,应该在慢跑时把心率控制在 140 以下。配速不重要,重要的是心率以及每次的时长(不低于 30 分钟),并避免受伤。

两个多月之前,我第一次尝试时,跑到 600 米左右,心率就超过了 150 ,赶紧停下来走路休息。

到现在坚持了两个多月,已经成为习惯。今天刮完台风,特别凉快。跑步时状态很好。第一公里用时 7 分钟,最后心率升到 140 。如果连续再跑下去还会上升,所以我选择走路休息到心率下降到 120 再继续。如此把心率维持在 120~140 之间,半个小时大约可以跑 3.5km 。

跑完再快走 5 分钟左右回家,不太觉得累。相比刚开始跑步时,到家就想躺下休息。这段时间在岩馆更也有动力爬。有岩友称,你终于有点老岩友的样子了。

至于体重,最近三天都在 86kg ,从数字上看已经减少了 5kg 。

控制尿酸方面:过去尿酸在 600 以上(体检报告记录)。现在戒掉了平时爱喝的含糖饮料,只在攀岩时喝一些运动饮料补充体力。日常喝苏打汽水(碱性),虽然以前也没有过多吃海鲜,现在是几乎不碰了。没有吃降尿酸的药。最近尿酸日常在 450 ~ 550 之间(每两天自测一次)。高低感觉和休息状态有关。如果白天过于劳累,晚上又没有好好休息的话,尿酸值也会明显升高。

脚没有再疼过,但总有点隐隐的感觉,可能是心理作用罢了。如果明年还不能降到 400 以下,考虑吃点药。


我知道跑步锻炼是一个漫长的过程,无法立竿见影。等半年以后再追加记录。

siwftui代码,.sheet不能跳转

作者 逆向APP
2025年7月19日 16:54

错误描述:

siwftui代码,在GroupImageView中展示了16个ImageItemView,点击每个ImageItemView都能跳转到ImageDetailView。但是出现了bug: 

前 8 张图点击能跳转到 ImageDetailView,后面的图点击却不能跳转;但点击后面的图的下载按钮却会跳转并下载。 

 从你提供的完整代码中,我已经定位到了问题的根本原因:

 ✅ 问题分析:

ImageItemView 的 index 是固定为 0~15,而 images[index] 实际可能超过 16 张 真正的逻辑错误出现在这个语句:

.sheet(isPresented: $isPresentingDetail) {
    NavigationView {
        ImageDetailView(image: image, index: index)
    }
}

你在点击下载按钮中保存图片再跳转时,是用系统权限请求的方式,所以 sheet 不生效问题就被掩盖了。

🔍 真正的关键问题:SwiftUI 的 .sheet() 绑定在了局部状态变量上,状态无法持久于多个 ImageItemView 实例

ImageItemView 是 GroupImageView 的一个子视图,每个都持有自己的 @State var isPresentingDetail。

当你点击第 9 张(或以后)图片时,虽然你设置了 isPresentingDetail = true,但 SwiftUI 很可能不会触发 .sheet,因为系统无法正确判断哪个视图弹窗状态被改变了。

这在 SwiftUI 的 .sheet 使用中是一个经典坑:

.sheet 应该绑定在父视图中唯一的变量上,而不是每个子视图都各自持有 @State 控制弹窗。

✅ 解决方案(核心改动)

将 .sheet 逻辑移到父视图 GroupImageView 中统一管理,不要在 ImageItemView 内部持有自己的 isPresentingDetail。

🔧 修改步骤如下: 

1. 在 GroupImageView 中添加两个状态变量: 

@State private var selectedImage: UIImage? = nil
@State private var selectedIndex: Int = 0
@State private var isShowingDetail = false

 2. 修改 ImageItemView,添加一个点击回调:

struct ImageItemView: View {
    var image: UIImage
    var index: Int
    var onTap: () -> Void  // <-- 添加这个

    ...
    .onTapGesture {
        print("点击了图片: \(index)")
        onTap()  // <-- 调用外部的回调
    }
    ...
}

3. 在 GroupImageView 的 ForEach 中这样写:

ForEach(0..<images.count, id: \.self) { index in
    ImageItemView(image: images[index], index: index) {
        self.selectedImage = images[index]
        self.selectedIndex = index
        self.isShowingDetail = true
    }
    .aspectRatio(1, contentMode: .fit)
}

 4. 在 GroupImageView 的 .sheet 添加如下:

.sheet(isPresented: $isShowingDetail) {
    if let image = selectedImage {
        NavigationView {
            ImageDetailView(image: image, index: selectedIndex)
        }
    }
}

🧠 为什么前 8 张能跳转、后面的不能?

 因为前 8 张视图刚好布局在屏幕可见区域内,SwiftUI 对这些视图的状态处理更稳定;而后面图片滑动进来时,视图重建了,状态失效,所以 .sheet 无法响应。 这正是 SwiftUI 的 .sheet 在 List 或 LazyVGrid 等懒加载容器中常见的问题。

SwiftUI Bug记录:.sheet首次点击弹出空白视图,第二次才正常显示

作者 逆向APP
2025年7月19日 16:22

在使用 SwiftUI 开发图片展示应用时,我遇到了一个令人困惑的问题:在进入 GroupImageView 后点击任意图片,.sheet 会弹出一个空白视图,没有内容显示,也没有打印任何跳转相关的调试信息。但令人惊奇的是,第二次点击同一张或另一张图片时,一切又都恢复正常。

 这是一次典型的 SwiftUI 状态绑定陷阱,本文将记录这个 Bug 的现象、源码分析及最终修复方案,帮助他人(包括未来的我)避免类似问题。 

📍 问题现象

进入 GroupImageView 后,点击图片列表中的任何一张图片:

 • 控制台正确输出 点击了图片: 15

 • 但没有输出 jump--to--ImageDetailView--index:15 

 • .sheet 弹出的是一个空白页面 当我再次点击其他图片或同一张图片时:

 • 控制台输出: 

 点击了图片: 14

 jump--to--ImageDetailView--index:14

 • .sheet 正常弹出并展示 ImageDetailView 内容

❌ 错误源码 

 以下是触发这个问题的相关简化代码片段:

@State private var selectedImageIndex: Int? = nil
@State private var showDetailSheet = false

var body: some View {
    ScrollView {
        LazyVGrid(columns: columns) {
            ForEach(images.indices, id: \.self) { index in
                let image = images[index]
                Button {
                    print("点击了图片: \(index)")
                    selectedImageIndex = index
                    showDetailSheet = true
                } label: {
                    Image(uiImage: image)
                        .resizable()
                        .scaledToFit()
                }
            }
        }
    }
    .sheet(isPresented: $showDetailSheet) {
        if let index = selectedImageIndex {
            print("jump--to--ImageDetailView--index:\(index)")
            ImageDetailView(image: images[index])
        }
    }
}

🔍 问题分析 

 乍一看逻辑是合理的,但这个 .sheet 弹空白的问题正是因为 selectedImageIndex 的值更新尚未完成,showDetailSheet 就已经变成 true 触发了 sheet 弹出。

 SwiftUI 的 .sheet 会在 showDetailSheet = true 这一刻立即尝试渲染内容,如果此时 selectedImageIndex 仍是 nil 或尚未被 SwiftUI 识别为已更新,.sheet 里的条件 if let index = selectedImageIndex 就无法满足,因此内容是空白。

 而第二次点击时,状态已经更新稳定,显示就一切正常了。

✅ 正确写法:绑定 Enum 或 Identifiable 对象

要解决这个问题,可以使用 sheet(item:) 绑定一个遵循 Identifiable 的对象,这样 SwiftUI 会等待该对象不为 nil 才呈现弹窗内容。代码改写如下:  

定义绑定项:

struct ImageIndexWrapper: Identifiable {
    var id: Int { index }
    let index: Int
}

修改状态:

@State private var selectedImage: ImageIndexWrapper? = nil

使用 .sheet(item:):

.sheet(item: $selectedImage) { wrapper in
    let index = wrapper.index
    print("jump--to--ImageDetailView--index:\(index)")
    ImageDetailView(image: images[index])
}

 更新点击事件:

Button {
    print("点击了图片: \(index)")
    selectedImage = ImageIndexWrapper(index: index)
} label: {
    Image(uiImage: image)
        .resizable()
        .scaledToFit()
}

🎉 效果验证

修复后: 

 • 第一次点击图片就能正确跳转;

 • 控制台完整打印跳转日志;

 • .sheet 内容始终正确展示;

 • 不再需要手动管理 showDetailSheet 状态。 

🧠 总结 

 SwiftUI 是声明式框架,对状态的依赖非常敏感。这个 .sheet 弹出空白视图的问题,本质上是由于 多个 @State 变量更新顺序与 SwiftUI 渲染机制的时机不匹配。使用 .sheet(item:) 可以让绑定行为更加可靠。

经验教训:

 • 避免同时依赖多个 @State 控制 .sheet;

 • 若弹窗内容依赖于某个值,尽量将该值直接作为绑定项;

 • .sheet(item:) 是更安全的做法。

将一个 现有 iOS Xcode 项目, 快速改造为 可以用 Android Studio 运行和调试 的项目

作者 imknown
2025年7月18日 17:08

环境

  1. 本文不需要 任何关于 KMP 代码相关 的修改

  2. Android Studio Narwhal 及其以上 + macOS

  3. 安装插件
    plugins.jetbrains.com/plugin/inde…

  4. 重启 Android Studio.

  5. 配置 IDE
    开启 Settings | Advanced Settings 中的
    Enable experimental Multiplatform IDE features,
    可以在 Swift 代码上 打断点 等.

  6. 关闭 Android Studio.

项目目录路径

  • 项目根目录: MyApplication
  • iOS Module: MyApplication/iosApp
  • iOS Project: MyApplication/iosApp/iosApp.xcodeproj

建议 完全关闭 Android Studio 再修改.
不然感觉 关闭 Android Studio 的时候,
内存缓存 会覆盖 MyApplication/.idea.

必需文件

MyApplication/.idea/xcode.xml

<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="XcodeMetaData"
    PROJECT_DIR="$PROJECT_DIR$/iosApp"
    PROJECT_FILE="$PROJECT_DIR$/iosApp/iosApp.xcodeproj/project.xcworkspace" />
</project>

可选文件 (用于 控制 AS 中的 目录名 和 目录结构)

MyApplication/.idea/.name

这个文件 里头的内容, 会覆盖 AS 展示的名字, 酌情删除

MyApplication/.idea/iosApp.iml

这个文件的文件名 可以改, 不过 不建议 乱改.
Android Studio 目录结构 可能会 变得很奇怪.

<?xml version="1.0" encoding="UTF-8"?>
<module classpath="AppCode" type="CIDR_MODULE" version="4" />

MyApplication/.idea/modules.xml

注意与 与上面 .iml 文件名相同.

<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="ProjectModuleManager">
    <modules>
      <module fileurl="file://$PROJECT_DIR$/.idea/iosApp.iml"
              filepath="$PROJECT_DIR$/.idea/iosApp.iml" />
    </modules>
  </component>
</project>

gRPC 与传统 REST API 的区别与实践详解

作者 J船长
2025年7月18日 14:46

gRPC 与传统 REST API 的区别与实践详解

一、前言

在现代分布式系统和微服务架构中,服务间通信协议的选择极其关键。RESTful API 是 Web 时代的标准,而 gRPC 作为 Google 推出的高性能远程调用框架,近年来在高性能、微服务场景下日益流行。很多开发者在选型时都会有疑问:gRPC 和 REST 有什么区别?什么时候用哪个更好?

本文将从最基础的 RPC 概念讲起,对比 gRPC 与 REST 的核心区别、原理、工程用法、以及实际选型建议。


二、什么是 RPC?

1. 概念解释

RPC(Remote Procedure Call,远程过程调用) 是一种让开发者像调用本地函数一样,去调用远程服务器上的方法的技术。

  • 本地调用 add(1, 2),在本机执行。
  • 用 RPC 调用 add(1, 2),底层会自动发请求到远程服务器,服务器处理后返回结果,本地像用普通函数一样得到结果。

2. RPC 的本质

  • 让网络调用变得像本地函数一样简单。
  • 不需要关心网络传输、序列化、协议细节。
  • 典型用途:微服务、分布式系统、服务化架构。

3. RPC 的发展

  • 早期:Java RMI、XML-RPC、SOAP、CORBA
  • 现代:gRPC(Google)、Thrift(Apache)、Dubbo(阿里巴巴)

三、REST API 基础回顾

REST(Representational State Transfer) 是一种面向资源的 Web API 设计风格。

  • 每个 URL 代表一个资源,用 HTTP 动词(GET/POST/PUT/DELETE)进行操作。
  • 以 JSON(或 XML)为主要数据格式,通用性好。
  • 天然适配浏览器、HTTP 客户端,易于调试和测试。

例子:

GET /users/123         # 获取用户
POST /users            # 创建用户
PUT /users/123         # 修改用户
DELETE /users/123      # 删除用户

四、gRPC 原理与特性

1. 基本原理

  • gRPC(Google Remote Procedure Call)是一种高性能、开源、支持多语言的 RPC 框架。
  • 基于 HTTP/2 协议进行通信,数据使用 Protocol Buffers(Protobuf)二进制序列化,效率高。
  • 接口定义用 .proto 文件,前后端根据接口定义自动生成代码。

2. gRPC 的主要特性

  • 高性能:HTTP/2、多路复用、头部压缩、二进制数据
  • 强类型:接口和数据结构由 .proto 文件约束,避免低级错误
  • 多语言支持:官方支持 10+ 语言(Java、Go、Python、C#、Node.js、Dart 等)
  • 流式通信:原生支持单向流、双向流(如实时推送、IM、音视频)
  • 自动生成代码:接口定义一次,多端生成,开发效率高

接口定义示例(.proto 文件):

syntax = "proto3";

service UserService {
  rpc GetUser (UserRequest) returns (UserReply) {}
}

message UserRequest {
  string user_id = 1;
}

message UserReply {
  string user_id = 1;
  string username = 2;
}

五、gRPC 和 REST API 的核心区别

方面 gRPC REST(HTTP API)
通信协议 HTTP/2 HTTP/1.1(主流)
序列化格式 Protobuf(二进制) JSON(文本)
调用方式 面向“方法/过程”,强类型 面向“资源”,松耦合
性能 高,低延迟,节省带宽 通用但体积大,延迟稍高
流式通信 原生支持双向/单向流 仅支持短连接/轮询/推送
浏览器支持 原生不支持,需 gRPC-Web 适配 天然支持
代码生成 支持多语言自动生成 一般用 Swagger/OpenAPI 生成文档
适用场景 微服务、服务间通信、实时场景 Web/Mobile API,对外服务

1. 通信协议和性能

  • gRPC 用 HTTP/2,支持连接复用、头压缩和流式,性能大幅领先 REST 的 HTTP/1.1。
  • REST 一般用 HTTP/1.1,每个请求/响应独立,效率较低。

2. 数据格式

  • gRPC 用 Protobuf,数据量小、序列化/反序列化快,但不易人读。
  • REST 用 JSON,通用易读,带宽/解析开销更高。

3. 调用方式与开发体验

  • gRPC:所有接口和数据结构在 .proto 中强类型定义,客户端像本地函数一样调用服务端(如 userService.getUser(req)),开发体验极好,易维护。
  • REST:用 HTTP 动词+URL,请求体/响应体无类型约束,出错风险高,开发需手动拼装参数。

4. 浏览器支持

  • REST:天然支持 Web/移动端各种客户端,前端调用无障碍。
  • gRPC:原生不能直接被浏览器 JS 调用,但可以用 gRPC-Web 适配(中间件转译)。

5. 代码生成

  • gRPC:后端和多端(Android/iOS/Go/Python 等)一套 .proto,自动生成所有模型和调用代码,极大减少手写/同步工作。
  • REST:只能用 Swagger/OpenAPI 生成接口文档和部分 SDK,类型安全性差,开发协同难度大。

六、代码对比示例

REST API 例子(Dart/Flutter 假代码)

final response = await http.get(Uri.parse('https://api.example.com/users/123'));
final data = json.decode(response.body);
final user = UserModel.fromJson(data);

gRPC 例子(Dart/Flutter 假代码)

final req = UserRequest(userId: '123');
final resp = await userServiceClient.getUser(req);
print(resp.username);

可以看到,gRPC 是强类型、面向对象的开发体验,更像直接调用本地方法。


七、实际选型建议

1. 推荐用 gRPC 的场景

  • 微服务体系内部服务间通信
  • 后端与后端、服务与服务之间的数据交互
  • 实时通信、高性能、大流量场景(IM、音视频、推流、物联网等)
  • 多端 SDK 自动生成,团队开发分工协作多

2. 推荐用 REST 的场景

  • 对外开放的 API、Web/Mobile 客户端
  • 快速原型开发、前后端协作以浏览器为主
  • 需要易于调试、易于集成第三方服务时

3. 混合用法

  • 很多大公司/中大型项目都采用“内部 gRPC、外部 REST”的双轨架构:

    • 微服务内部高效通信用 gRPC
    • 对外服务、前端调用仍暴露 RESTful API

八、常见问题

Q1: gRPC 为什么不能直接用于浏览器?
A1: 因为浏览器不直接支持 HTTP/2 的特性和 Protobuf 编解码,所以需要 gRPC-Web 适配。

Q2: gRPC 真的比 REST 快吗?
A2: 在高并发、流式通信和大数据量场景下,gRPC 性能明显优于 REST。但简单接口差距不大。

Q3: gRPC 支持哪些语言?
A3: Java、Go、C#、C++、Python、Dart、Kotlin、Swift、Node.js、Ruby 等,生态极其丰富。

Q4: REST 会被淘汰吗?
A4: 不会。REST 易于集成、调试、文档丰富,在公开 API 领域长期有生命力。


九、总结

  • gRPC 和 REST 本质区别在于通信协议、数据格式和开发方式。
  • gRPC 性能高、强类型、自动代码生成,适合服务间通信和高性能场景。
  • REST 通用易用、无平台壁垒,适合公开 API、Web/Mobile 客户端。
  • 实际开发中,按需选型/混合使用才是最优解

十、参考资料

ios 集成阿里云的AI智能体报错CocoaPods could not find compatible versions for pod "AUIAICal

作者 90后晨仔
2025年7月18日 12:40

Analyzing dependencies [!] CocoaPods could not find compatible versions for pod "AUIAICall/Chatbot":   In Podfile:     AUIAICall/Chatbot (from ./App/Lib/AUIAICall/) Specs satisfying the AUIAICall/Chatbot (from ./App/Lib/AUIAICall/) dependency were found, but they required a higher minimum deployment target.

解决方案:

说明你的项目目前的最低版本要比SDK的要求的最低版本要低导致的。直接点讲就是,集成的这个SDK最低要求系统版本是iOS 11.0可是你的实际项目这里是iOS 10.0导致的。所以把这里改成SDK要求的最低版本就好了。

Snip20250718_4.png

SwiftUI中如何实现子视图向父视图传递数据?

作者 干中学
2025年7月18日 11:34

在 SwiftUI 中,preference(偏好设置)机制是一种视图间通信方式,用于子视图向父视图传递数据,解决了 SwiftUI 中数据单向流动(通常是父到子)的限制。

它的核心思想是:子视图可以定义并设置一些 “偏好值”,父视图通过特定方式收集这些值并做出响应。

核心组成

  1. PreferenceKey(偏好键)

定义数据传递的 “协议”,指定数据类型和默认值,是父子视图通信的桥梁。

// 定义一个偏好键,用于传递CGFloat类型的数据 
struct MyPreferenceKey: PreferenceKey {
    // 数据类型 
    typealias Value = CGFloat 
    // 默认值 
    static var defaultValue: CGFloat = 0 
    // 合并多个子视图的偏好值(如存在多个子视图时) 
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { 
        value = max(value, nextValue()) // 示例:取最大值 
    } 
}
  1. 子视图设置偏好值

通过 .preference(key:value:) 修饰符设置具体值。

struct ChildView: View { 
    var body: some View { 
    Text("子视图") 
        .preference(key: MyPreferenceKey.self, value: 100) // 设置偏好值 
    } 
}
  1. 父视图读取偏好值

通过 .onPreferenceChange(_:perform:) 监听偏好值变化并处理。

struct ParentView: View {
    @State private var maxValue: CGFloat = 0 
    var body: some View { 
        VStack {
            ChildView() 
            Text("收到的值:\(maxValue)") 
        } 
        .onPreferenceChange(MyPreferenceKey.self) { value in 
            maxValue = value // 响应偏好值变化 
        }
    } 
}

典型应用场景

  1. 动态获取子视图尺寸
    例如,父视图需要根据子视图的实际宽度调整布局。
  2. 收集多个子视图状态
    如导航栏需要汇总多个子视图的选择状态。
  3. 跨层级传递数据
    无需通过 @Binding 或 ObservableObject 逐层传递,直接跨层级通信。

注意事项

  • 单向传递:只能从子视图向父视图传递,无法反向。
  • 合并策略:当多个子视图设置同一偏好键时,需在 reduce 方法中定义合并规则(如取最大值、累加等)。
  • 性能考量:频繁更新偏好值可能影响性能,需合理使用。

通过 preference 机制,SwiftUI 视图间的通信更加灵活,尤其适合处理布局相关的动态数据传递。

补充:

关于上述自定义PreferenceKey的代码中reduce函数进行进一步说明:

static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { 
    value = max(value, nextValue()) // 示例:取最大值 
}

reduce 方法是 SwiftUI 中 PreferenceKey 协议的核心方法之一,它的作用是合并多个子视图产生的偏好值。当多个子视图同时设置了同一个 PreferenceKey 的值时,系统会通过这个方法将这些分散的值合并成一个最终值,供父视图使用。以下是对方法进行解析:

  • value: 一个输入输出参数inout),表示当前已经合并的结果值。
  • nextValue: 一个闭包,调用后会返回下一个子视图设置的偏好值。

工作流程

当父视图监听某个 PreferenceKey 时:

  1. 系统会遍历所有设置了该偏好键的子视图,收集它们的值。
  2. 从 defaultValue 开始,逐个将子视图的值与当前结果通过 reduce 方法合并。
  3. 最终得到一个统一的值,传递给父视图的 onPreferenceChange 回调。

场景:假设三个子视图分别设置了偏好值 508060

  1. 初始时,value 为 defaultValue(示例中是 0)。
  2. 第一次合并:value = max(0, 50) → value 变为 50
  3. 第二次合并:value = max(50, 80) → value 变为 80
  4. 第三次合并:value = max(80, 60) → value 保持 80
  5. 最终父视图收到的值是 80(三个子视图中的最大值)。

其他常见合并策略

根据业务需求,reduce 可以实现不同的合并逻辑:

  • 累加value += nextValue()(适合计数场景)。
  • 取最小值value = min(value, nextValue())
  • 拼接字符串value += nextValue()(如果 Value 是 String 类型)。
  • 存储所有值:如果 Value 是数组类型,value.append(contentsOf: nextValue())

reduce 方法的核心作用是定义多值合并规则,让父视图能从多个子视图的偏好值中得到一个统一的结果。它是 PreferenceKey 协议中处理 “多对一” 数据传递的关键机制。

示例:使用PreferenceKey来实现以下案例效果:当多个子视图存在不同的状态时,每次子视图的状态更新都能实时同步到父视图中,效果如下:

钉钉录屏_2025-07-18+111405.gif

具体实现代码:

import SwiftUI

// 1. 定义偏好键,用于收集所有子视图的选中状态
struct SelectionPreferenceKey: PreferenceKey {
    // 使用字典存储所有子视图的选中状态,键为子视图ID,值为是否选中
    static var defaultValue: [String: Bool] = [:]
    // 合并多个子视图的偏好值
    static func reduce(value: inout [String: Bool], nextValue: () -> [String: Bool]) {
        // 将新的子视图状态合并到现有字典中
        value.merge(nextValue(), uniquingKeysWith: { _, new in new })
    }
}
  
// 2. 扩展View,方便设置选中状态偏好
extension View {
    func selectionStatus(id: String, isSelected: Bool) -> some View {
        preference(key: SelectionPreferenceKey.self, value: [id: isSelected])
    }
}

// 3. 子视图组件 - 可选中的选项卡
struct SelectableItemView: View {
    let id: String
    let title: String
    @State private var isSelected: Bool = false

    var body: some View {
        HStack {
            Text(title)
            Spacer()
            Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
                .foregroundColor(isSelected ? .accentColor : .secondary)
        }
        .padding()
        .background(Color(.systemBackground))
        .cornerRadius(8)
        .shadow(radius: 1)
        .onTapGesture {
            isSelected.toggle()
        }

        // 将当前选中状态通过偏好机制传递
        .selectionStatus(id: id, isSelected: isSelected)
    }
}

// 4. 父视图 - 汇总并展示所有子视图的选中状态
struct SummaryView: View {
    // 存储所有子视图的选中状态
    @State private var allSelections: [String: Bool] = [:]

    // 计算选中的数量
    private var selectedCount: Int {
        allSelections.values.filter { $0 }.count
    }

    // 计算总数量
    private var totalCount: Int {
        allSelections.count
    }

    var body: some View {
        VStack(spacing: 20) {
            // 显示汇总信息
            VStack {
                Text("选中状态汇总")
                    .font(.headline)
                Text("已选中: \(selectedCount)/\(totalCount)")
                    .font(.subheadline)
                    .foregroundColor(.secondary)
            }
 
           // 列出所有选项的选中状态
            VStack(alignment: .leading, spacing: 8) {
                ForEach(allSelections.sorted(by: { $0.key < $1.key }), id: \.key) { id, isSelected in
                    HStack {
                        Text("选项 \(id.last ?? "?"):")
                        Text(isSelected ? "已选中" : "未选中")
                            .foregroundColor(isSelected ? .accentColor : .secondary)
                    }
                }
            }
            
            Spacer()
           
            // 子视图区域
            VStack(spacing: 12) {
                SelectableItemView(id: "item1", title: "选项 1")
                SelectableItemView(id: "item2", title: "选项 2")
                SelectableItemView(id: "item3", title: "选项 3")
                SelectableItemView(id: "item4", title: "选项 4")
            }
        }
        .padding()
        .navigationTitle("选中状态汇总示例")
        // 监听偏好值变化,更新汇总数据
        .onPreferenceChange(SelectionPreferenceKey.self) { newSelections in
            allSelections = newSelections
        }
    }
}

// 5. 主视图容器
struct ContentView: View {
    var body: some View {
        NavigationView {
            SummaryView()
        }
    }
}
  
// 预览
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

这个案例展示了如何使用 PreferenceKey 汇总多个子视图的状态数据,主要实现了以下功能:

  1. 数据传递机制

    • 定义了 SelectionPreferenceKey 偏好键,使用字典 [String: Bool] 存储多个子视图的选中状态
    • reduce 方法通过合并字典的方式,收集所有子视图的状态数据
  2. 子视图实现

    • SelectableItemView 是可交互的子组件,包含一个选中状态 isSelected
    • 点击子视图时会切换选中状态,并通过 selectionStatus 方法将状态传递给父视图
  3. 父视图汇总

    • 父视图 SummaryView 通过 onPreferenceChange 监听所有子视图的状态变化
    • 实时计算并展示选中数量与总数量的比例
    • 列出每个子视图的具体选中状态
  4. 核心逻辑

    • 每个子视图维护自己的选中状态
    • 当子视图状态变化时,通过偏好机制自动通知父视图
    • 父视图汇总所有状态并更新 UI 展示

这种实现方式的优势在于:

  • 子视图与父视图解耦,子视图不需要知道父视图的存在
  • 可以轻松扩展更多子视图,父视图会自动汇总新添加的子视图状态
  • 符合 SwiftUI 单向数据流的设计理念

lipo 命令行指南

作者 Keya
2025年7月18日 11:15

lipo 命令行完全指南:静态库架构管理利器

引言

在 macOS 和 iOS 开发中,静态库是代码复用和分发的重要方式。然而,不同设备和模拟器使用不同的 CPU 架构,这就需要我们能够灵活地管理静态库的架构。lipo 命令行工具正是解决这一问题的利器,它允许开发者创建、查看、提取和修改通用二进制文件(Universal Binaries)中的架构。本文将详细介绍 lipo 命令的五大核心用法,帮助开发者高效管理静态库。

1. lipo 命令行的作用

lipo(全称 "Library I/O")是 macOS 系统自带的一款用于处理 Mach-O 通用二进制文件的命令行工具。它的主要功能包括:

创建:将多个单架构静态库合并为一个支持多架构的通用静态库

查看:显示静态库中包含的架构信息

提取:从通用静态库中拆分出特定架构的静态库

修改:替换或移除通用静态库中的特定架构

通用二进制文件(Universal Binaries)是一种可以包含多种 CPU 架构代码的文件格式,使得同一个可执行文件或库可以在不同架构的设备上运行,如 Intel 处理器和 Apple Silicon 处理器的 Mac,以及各种 iOS 设备和模拟器。

lipo 工具在以下场景中特别有用:

开发阶段合并模拟器和真机静态库,避免频繁切换

发布前移除不必要的架构,减小应用体积

验证静态库支持的架构类型

修复因架构不匹配导致的编译错误

2. lipo 命令行查看静态库

在使用静态库之前,了解其支持的架构类型至关重要。lipo 提供了两种主要方式来查看静态库的架构信息。

2.1 基本信息查看

使用 -info 选项可以快速查看静态库支持的架构列表:

lipo -info libexample.a

示例输出:

Architectures in the fat file: libexample.a are: armv7 arm64 x86_64

这个命令会告诉我们静态库是否为通用二进制(fat file)以及包含的具体架构。

2.2 详细信息查看

如果需要更详细的架构信息,可以使用 -detailed_info 选项:

lipo -detailed_info libexample.a

示例输出会包含每个架构的偏移量、大小和对齐方式等详细信息,这对于调试复杂的架构问题非常有帮助。

2.3 查看框架(Framework)架构

对于 Framework 类型的静态库,需要指定其内部的二进制文件路径:

lipo -info ./MyFramework.framework/MyFramework

3. lipo 命令行合并静态库

合并多个单架构静态库是 lipo 最常用的功能之一,这可以创建一个支持多种架构的通用静态库,方便开发过程中在不同设备和模拟器之间切换。

3.1 基本合并命令

使用 -create 选项可以合并多个静态库:

lipo -create lib_arm64.a lib_x86_64.a -output lib_universal.a

这个命令将 lib_arm64.a(ARM 架构)和 lib_x86_64.a(Intel 架构)合并为一个名为 lib_universal.a 的通用静态库。

3.2 合并模拟器和真机静态库

在 iOS 开发中,经常需要合并模拟器和真机静态库:

lipo -create \

/path/to/Debug-iphoneos/libMyLib.a \ # 真机静态库

/path/to/Debug-iphonesimulator/libMyLib.a \ # 模拟器静态库

-output libMyLib_universal.a # 输出通用静态库

3.3 合并 Framework

合并 Framework 中的二进制文件与合并普通静态库类似:

lipo -create \

./真机/MyFramework.framework/MyFramework \

./模拟器/MyFramework.framework/MyFramework \

-output ./MyFramework.framework/MyFramework

合并后,Framework 就可以同时在真机和模拟器上使用了。

3.4 注意事项

合并的静态库必须包含不同的架构,不能合并具有相同架构的静态库

确保要合并的静态库具有相同的代码和功能,只是针对不同架构编译

Xcode 12+ 编译的模拟器静态库默认包含 arm64 架构,可能导致与真机库冲突,需要特殊处理

4. lipo 命令行拆分静态库

当需要减小应用体积或只需要特定架构的静态库时,可以使用 lipo 将通用静态库拆分为单架构静态库。

4.1 提取单个架构

使用 -thin 选项可以从通用静态库中提取单个架构:

lipo lib_universal.a -thin arm64 -output lib_arm64.a

这个命令从 lib_universal.a 中提取出 arm64 架构,并保存为 lib_arm64.a。

4.2 提取架构家族

使用 -extract_family 选项可以提取某个架构家族的所有架构:

lipo lib_universal.a -extract_family arm -output lib_arm_family.a

这会提取所有 ARM 相关的架构(如 armv7, armv7s, arm64 等)。

4.3 移除特定架构

使用 -remove 选项可以从通用静态库中移除特定架构:

lipo lib_universal.a -remove i386 -output lib_without_i386.a

这在发布应用时非常有用,可以移除仅用于模拟器的 i386 和 x86_64 架构,减小最终应用体积:

移除模拟器架构,只保留真机架构

lipo lib_universal.a -remove i386 -remove x86_64 -output lib_device_only.a

5. lipo 命令行修改静态库的架构

lipo 提供了多种方式来修改静态库的架构,包括替换架构、添加新架构和移除不需要的架构。

5.1 替换架构

使用 -replace 选项可以替换通用静态库中的特定架构:

lipo lib_universal.a -replace arm64 new_arm64_lib.a -output lib_updated.a

这个命令将 lib_universal.a 中的 arm64 架构替换为 new_arm64_lib.a 中的版本。

5.2 添加新架构

添加新架构实际上是先拆分现有架构,然后与新架构合并:

提取现有所有架构

lipo lib_universal.a -thin arm64 -output lib_arm64.a
lipo lib_universal.a -thin x86_64 -output lib_x86_64.a

添加新架构并合并

lipo -create lib_arm64.a lib_x86_64.a new_armv7_lib.a -output lib_with_armv7.a

5.3 验证架构

使用 -verify_arch 选项可以验证静态库是否包含特定架构:

lipo lib_universal.a -verify_arch arm64 x86_64

如果静态库包含所有指定的架构,命令将返回 0;否则返回 1。这在脚本中非常有用,可以用于条件判断。

实际应用示例

示例 1:iOS 开发中合并模拟器和真机静态库

查看真机静态库架构

`lipo -info ./Build/Products/Debug-iphoneos/libMyLib.a

` 输出: Architectures in the fat file: ... are: armv7 arm64

查看模拟器静态库架构

lipo -info ./Build/Products/Debug-iphonesimulator/libMyLib.a

输出: Architectures in the fat file: ... are: x86_64

合并静态库

lipo -create \
    ./Build/Products/Debug-iphoneos/libMyLib.a \
    ./Build/Products/Debug-iphonesimulator/libMyLib.a \
    -output ./libMyLib_universal.a

验证合并结果

lipo -info ./libMyLib_universal.a

输出: Architectures in the fat file: ... are: armv7 arm64 x86_64

示例 2:为发布准备静态库(移除模拟器架构)

移除模拟器架构

lipo libMyLib_universal.a \
    -remove i386 \
    -remove x86_64 \
    -output libMyLib_device.a

验证结果

lipo -info libMyLib_device.a 输出: Architectures in the fat file: ... are: armv7 arm64

示例 3:修复 Xcode 12+ 模拟器架构冲突

Xcode 12+ 编译的模拟器静态库默认包含 arm64 架构,导致与真机库合并时冲突:

移除模拟器静态库中的 arm64 架构

lipo ./Debug-iphonesimulator/libMyLib.a -remove arm64 -output ./libMyLib_simulator_fixed.a

合并修复后的模拟器库和真机库

lipo -create ./Debug-iphoneos/libMyLib.a ./libMyLib_simulator_fixed.a -output ./libMyLib_universal.a

总结

lipo 命令行工具是 macOS 和 iOS 开发中管理静态库架构的强大工具,掌握它可以极大提高静态库管理效率。本文介绍了 lipo 的五大核心功能:

查看架构:使用 -info 或 -detailed_info 查看静态库支持的架构

合并静态库:使用 -create 将多个单架构静态库合并为通用静态库

拆分静态库:使用 -thin 或 -extract_family 提取特定架构

移除架构:使用 -remove 移除不需要的架构,减小库体积

修改架构:使用 -replace 替换静态库中的特定架构

合理使用这些功能,可以帮助开发者更好地管理静态库,解决架构兼容性问题,优化应用体积,提高开发效率。无论是日常开发还是发布准备,lipo 都是不可或缺的工具。


End


迁移至 Swift Actors

作者 Saafo
2025年7月18日 01:13

English Version

Actors 作为 Swift 并发模型的重要组成部分,于 WWDC21 上推出,并在 iOS 13 以上可用。它们通过确保串行访问,提供了在并发环境中安全管理状态的方法。已有多篇优秀文章介绍了 Actors 的概念和基本用法(Swift 新并发框架之 actor),我们假设你已熟悉这些内容。本文将重点分享在现有代码库中集成 Actors的经验和解决方案。

将数据模型类重构为 Actor

Actors 最常见且强大的用例之一是管理从多个线程访问的(串行)数据模型。在这个场景中,我们有一个 Uploader 类需要处理并发操作。以下是 Uploader 的简化版本:

protocol Uploader {
    func upload(file: String)
    func retry(file: String)
    func cancelUpload(file: String)
}

final class UploaderImp: Uploader {

    private var uploadingFiles = Set<String>()
    private let uploadService = UploadService()

    func upload(file: String) {}
    func retry(file: String) {}
    func cancelUpload(file: String) {}
}
class UploadService {
    func upload(_ file: String, completion: @escaping (Result<(), Error>) -> Void) {}
}

注意我们有一个 uploadingFiles 私有属性,需要在其他函数中更新。此外,其他函数可能会在多线程中被调用。

与传统方法相比的优势

传统上,多线程问题通过锁或队列解决。虽然简单直接,但这种方法要求开发人员手动确保对 uploadingFiles 等共享资源的每次访问都受到保护。这很容易出错,因为很容易遗漏某个情况,而且编译器不会帮助捕获这些错误。忽略必要的锁或队列可能导致竞态条件和其他并发相关的错误。

传统的基于队列的解决方案如下所示:

final class UploaderImp: Uploader {

    private var uploadingFiles = Set<String>()
    private let uploadService = UploadService()
    private let queue: DispatchQueue

    func upload(file: String) {
        queue.async {
            uploadingFiles.insert(file)
            uploadService.upload(file) { [weak self] in
                //  completion 可能在其他队列上执行,
                // 我们可能会忘记用 `queue.async` 包装,
                // 尤其是在更复杂的代码中。
                // 然后 `uploadingFiles` 可能会在其他队列中被访问,
                // 在这种情况下编译器无法提供任何帮助。
                uploadingFiles.remove(file)
            }
        }
    }
    func retry(file: String) {
        queue.async {
            uploadingFiles.insert(file)
        }
    }
    func cancelUpload(file: String) {
        queue.async {
            uploadingFiles.insert(file)
        }
    }
}

然而,很容易忘记用队列包装所有属性访问,从而导致潜在的数据竞争。

另一种传统方法是使用锁。但是,当处理多个属性时,确保原子操作可能具有挑战性。考虑使用(WWDC24 中引入的新) Mutex 作为示例:

import Synchronization

final class UploaderImp: Uploader {

    private var uploadingFiles = Mutex<Set<String>>()
    private var uploadedFiles = Mutex<Set<String>>()
    private let uploadService = UploadService()

    func upload(file: String) {
        uploadingFiles.withLock {
            $0.insert(file)
        }
        uploadService.upload(file) { [weak self] _ in
            // 将文件从 uploadingFiles 移动到 uploadedFiles
            // 不是原子操作,可能导致多线程问题
            // 如竞态条件或脏读。
            self?.uploadingFiles.withLock {
                $0.remove(file)
            }
            self?.uploadedFiles.withLock {
                $0.insert(file)
            }
        }
    }
    func retry(file: String) {}
    func cancelUpload(file: String) {}
    init() {}
}

如果数据模型包含多个属性,为每个属性单独包装锁并不能保证所有属性之间的原子操作。对所有属性使用单个锁与使用队列具有相同的缺点。

这就是 actor 的用武之地。以下是如何将 Uploader 重构为 actor:

actor UploaderImp: Uploader {

    private var uploadingFiles = Set<String>()
    private var uploadedFiles = Set<String>()
    private let uploadService = UploadService()

    nonisolated func upload(file: String) {}
    private func _upload(file: String) {
        uploadingFiles.insert(file)
        uploadService.upload(file) { [weak self] _ in
            self?.uploadingFiles.remove(file) // ERROR: Actor-isolated property 'uploadingFiles' can not be mutated from a nonisolated context
            self?.uploadedFiles.insert(file) // ERROR: Actor-isolated property 'uploadedFiles' can not be mutated from a nonisolated context
        }
    }
    nonisolated func retry(file: String) {}
    nonisolated func cancelUpload(file: String) {}
    init() {}
}

你可能已经注意到某些函数上的 nonisolated 关键字;我们稍后会讨论这一点。

编译器生成了一些错误,但这些错误可以解决。我们可以通过使用像 performInIsolation 这样的辅助方法(可以使用 sending 而不是 Sendable 进行优化)来修复编译错误并确保原子性:

public extension Actor {
    /// 为任何 actor 添加通用的 `perform` 方法,以访问其隔离域,使用闭包一次性执行多个操作。
    func performInIsolation<T>(_ block: sending (_ actor: isolated Self) throws -> sending T) rethrows -> sending T {
        try block(self)
    }
}
actor UploaderImp: Uploader {

    private var uploadingFiles = Set<String>()
    private var uploadedFiles = Set<String>()
    private let uploadService = UploadService()

    nonisolated func upload(file: String) {}
    private func _upload(file: String) {
        uploadingFiles.insert(file)
        uploadService.upload(file) { [weak self] _ in
            Task {
                await self?.performInIsolation { `self` in
                    self.uploadingFiles.remove(file)
                    self.uploadedFiles.insert(file)
                }
            }
        }
    }
    nonisolated func retry(file: String) {}
    nonisolated func cancelUpload(file: String) {}
    init() {}
}

现在,对 uploadingFilesuploadedFiles 的访问保证在特定的串行线程上(由 actor 保证),并且上传完成中的修改是原子的。

你可能认为代码量与传统方法相似。然而,关键优势在于编译器现在保证 actor 属性仅在其隔离环境中被访问。这种编译时安全性防止了数据竞争和其他并发问题,减轻了开发人员的认知负担,并在代码运行前就防止了错误。

从同步环境桥接到隔离环境

我们的上传器现在内部是串行和隔离的。然而,代码库的其余部分可能不知道 actors 或隔离。它可能存在于传统的同步世界中。因此,我们需要在这两个环境之间创建一个桥接。

我们向代码库的其余部分公开 Uploader 协议。如果可能,我们可以用 async 关键字标记 Uploader 协议中的函数,因为非隔离环境必须异步访问隔离环境。

protocol Uploader {
    func upload(file: String) async
    func retry(file: String) async
    func cancelUpload(file: String) /*async*/ // 如果我们不标记 async
}
actor UploaderImp: Uploader {
    func upload(file: String) {}
    func retry(file: String) {}
    func cancelUpload(file: String) {} // ERROR: Actor-isolated instance method 'cancelUpload(file:)' cannot be used to satisfy nonisolated protocol requirement
}

然而,要桥接到不使用 async 的世界,我们必须在 UploaderImp actor 中将协议的方法标记为 nonisolated。然后在 actor 内部,我们可以使用 Task 桥接到异步的隔离环境:

protocol Uploader {
    func upload(file: String)
    func retry(file: String)
    func cancelUpload(file: String)
}
actor UploaderImp: Uploader {
    nonisolated func upload(file: String) {
        Task { [weak self] in await self?._upload(file: file) }
    }
    private func _upload(file: String) {
        // 在这里执行我们的串行逻辑
    }
    // retry 和 cancelUpload 类似

这样,Uploader 协议可以在同步上下文中使用,而其实现则能充分利用现代并发特性。

如果你的方法需要返回值,它们必须是异步的。你可以通过使用闭包回调来实现,该回调在异步操作完成时被调用并返回结果。

注意:Task 不保证执行顺序。如果操作顺序很重要,请考虑使用像 swift-async-queue 这样的库。

使用 @MainActor 保证主线程执行

Actors 的另一个重要用途是 actor 属性(如 @MainActor),它可以应用于函数、类和其他声明,以确保它们在特定 Actor(如 MainActor)上执行。你甚至可以定义自己的自定义 actors。

长期以来,确保 API 在特定线程(通常是主线程)上调用的唯一方法是在文档中说明。作为开发人员,我们通过经验了解哪些 API 只能在主线程上使用(如 UIView),哪些可以在后台线程上使用(如 UIImage)。然而,在深层调用堆栈中很容易出错,这可能导致运行时崩溃。

另一方面,作为 API 提供者,你可能会在代码中添加保护措施以切换到正确的线程,使用锁或信号量来防止误用。这通常导致每个公共 API 中都有样板代码,并可能引入其自身的一系列线程问题。

@MainActor 属性解决了这个问题,允许我们指定 API 必须在主线程上调用。让我们看一个视图模型的示例:

import UIKit
class ViewModel {
    var isValid: Bool = false {
        didSet {
            updateViewHidden(isValid)
        }
    }

    private weak var view: UIView?
    private func updateViewHidden(_ isHidden: Bool) {
        view?.isHidden = isHidden
    }
}

这段代码看起来很熟悉,乍一看似乎是正确的,对吧?

现在,让我们看看 ViewModel 可能的用法:

class DataModel {
    let vm = ViewModel()

    func updateData(_ isValid: Bool) {
        vm.isValid = isValid
    }
}

我们很容易意外地从后台线程调用 updateData,这将尝试从后台线程更新 UIView 属性,导致崩溃。

现在,让我们将 @MainActor 属性添加到 updateViewHidden。这将导致编译器在 isValidupdateData 上标记错误:

class ViewModel {
    var isValid: Bool = false {
        didSet {
            updateViewHidden(isValid) // ERROR: Call to main actor-isolated instance method 'updateViewHidden' in a synchronous nonisolated context
        }
    }

    private weak var view: UIView?
    @MainActor
    private func updateViewHidden(_ isHidden: Bool) {
        view?.isHidden = isHidden
    }
}
class ViewModel {
    @MainActor
    var isValid: Bool = false {
        didSet {
            updateViewHidden(isValid)
        }
    }

    private weak var view: UIView?
    @MainActor
    private func updateViewHidden(_ isHidden: Bool) {
        view?.isHidden = isHidden
    }
}

class DataModel {
    let vm = ViewModel()

    func updateData(_ isValid: Bool) {
        vm.isValid = isValid // ERROR: Main actor-isolated property 'isValid' can not be mutated from a nonisolated context
    }
}
class DataModel {
    let vm = ViewModel()

    func updateData(_ isValid: Bool) {
        Task { @MainActor in
            self.vm.isValid = isValid
        }
        // 或者以传统方式,只要编译器知道
        // 当前环境在 MainActor 中:
        DispatchQueue.main.async {
            self.vm.isValid = isValid
        }
    }
}

如果你想知道为什么我们可以使用 DispatchQueue.main 来提供 MainActor 环境,请参阅:Swift 编译器如何知道 DispatchQueue.main 意味着 @MainActor – Ole Begemann

这些错误最终会引导你用 @MainActor 标记调用链中的每个函数和属性,确保整个链从一开始就在正确的线程上执行,并具有编译时检查。这类似于 Swift 在编译时提供的类型安全,与其他动态类型语言相较而言。@MainActor 属性赋予了我们从源头上修复线程问题,而不是在别的地方打补丁的能力。

Actor 属性基本概念

@MainActor 这样的 Actor 属性不仅可以应用于函数和属性,还可以应用于类、结构体、枚举和协议。

当应用于类时,类中的所有属性和方法默认继承 @MainActor 属性。你可以通过用 nonisolated 关键字标记特定函数不继承属性。

// 在类声明上:
@MainActor
class V {
    var a: Int = 1 // 类中的所有属性和函数
    func b() {} // 默认继承 `@MainActor`。

    // 你可以显式标记 `nonisolated` 以放弃 actor 继承。
    nonisolated func c() {}
}

// 在结构体和枚举声明上:
// 所有属性和函数默认也继承 `@MainActor`。
@MainActor
struct S {}
@MainActor
enum E {}

继承也适用于协议及其实现:

// 在协议属性和函数上:
protocol P {
    @MainActor
    var a: Int { get }
}
class C: P {
    var a: Int = 0 // C.a 继承 `@MainActor`
}

func b() {
    let c = C()
    c.a // ERROR: Main actor-isolated property 'a' can not be referenced from a nonisolated context
}

// 在协议本身上:
@MainActor
protocol P2 {}

class C2: P2 {} // C2 继承 `@MainActor`

func b2() {
    let c = C2() // ERROR: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
}

你可以充分利用 actor 属性来保证不仅是线程安全,以及我们可能称之为“线程正确性”——确保代码在正确的线程上运行。

关于 UIView 的情况

你可能已经注意到,虽然 UIView 被标记为 @MainActor,但从后台线程(在非隔离上下文中)调用 UIView 及其方法不会产生任何错误或警告。这是为什么呢?

import UIKit

@MainActor
class MyView {}

func a() {
    UIView() // 没有错误或警告!
    MyView() // ERROR: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
}

秘密在于 @preconcurrency。即使你在 UIView.swiftinterface 文件中没有明确看到 @preconcurrency,它也会被隐式标记,因为 UIView 是从 Objective-C 导入的。Objective-C 的声明在被导入时,编译器会自动给它们标记上 preconcurrency 属性。

如果代码不在并发环境中,@preconcurrency 属性会抑制编译器本应发出的警告:

import UIKit

@preconcurrency
@MainActor
class MyView {}

func a() {
    UIView() // 没有错误或警告!
    MyView() // 也没有错误或警告!
}

然而,如果你尝试在不在 MainActor 上的并发环境中调用与 UIView 相关的 API,编译器将发出警告(即使使用最小并发检查):

actor A {
    func a() {
        UIView() // WARN: Call to main actor-isolated initializer 'init()' in a synchronous actor-isolated context; this is an error in the Swift 6 language mode
        MyView() // 同上警告
    }
    nonisolated func b() {
        UIView() // WARN: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context; this is an error in the Swift 6 language mode
        MyView() // 同上警告
    }
}

在这种情况下,你应该使用 Task { @MainActor in } 或调度到主线程,以确保编译器的线程正确性。

重要的是要注意,在某些情况下,编译器可能不会提供错误或警告,尤其是在非并发环境中。因此,你应该意识到这些情况,如果你不在完整的并发环境中,不要仅仅依赖编译器错误。

Actors 内部的并发

虽然 actors 串行执行代码,但你仍然可以通过使用前面提到的 nonisolated 关键字在 actor内部并发运行代码。这意味着如果一个函数不隔离到其封闭的 actor,它就可以并发运行。

例如,如果你收到需要在传递给视图之前进行处理的数据,你可以将 nonisolated 函数与 async let(或 TaskGroup,或任何其他异步任务)结合使用,以在 actor 的串行执行上下文之外执行繁重的处理:

import UIKit

@MainActor
class ViewModel {
    private weak var view: UIView?
    // 此函数从封闭类继承 @MainActor
    func didReceiveData(_ data: Int) {
        // Task.init 从环境继承 @MainActor
        // 但是,如果我们使用 Task.detached,将会出错
        Task {
            print(Thread.isMainThread) // true
            async let processedData = processData(data)
            print(Thread.isMainThread) // true
            // 由编译器保证在主线程上
            view?.isHidden = await processedData > 0
        }
    }

    nonisolated private func processData(_ data: Int) -> Int {
        // 在这里模拟你的繁重处理逻辑
        Thread.sleep(forTimeInterval: 1)
        print(Thread.isMainThread) // false,意味着繁重的逻辑在主线程之外运行
        return data
    }
}

然而,编写异步代码会引入重入问题。如果 didReceiveData 被快速连续调用多次,你可能会有多个处理任务同时运行。为了解决这个问题,你可以存储最后一个运行的任务,在开始新任务之前取消它,并在更新视图之前检查取消:

import UIKit

@MainActor
class ViewModel {
    private weak var view: UIView?
    private var calculatingTask: Task<Void, Error>?
    // 此函数从封闭类继承 @MainActor
    func didReceiveData(_ data: Int) {
        // Task.init 从环境继承 @MainActor
        // 但是,如果我们使用 Task.detached,将会出错
        calculatingTask?.cancel()
        calculatingTask = Task {
            print(Thread.isMainThread) // true
            async let processedData = processData(data)
            let processedResult = await processedData
            print(Thread.isMainThread) // true
            try Task.checkCancellation()
            view?.isHidden = processedResult > 0
        }
    }

    nonisolated private func processData(_ data: Int) -> Int {
        // 在这里模拟你的繁重处理逻辑
        Thread.sleep(forTimeInterval: 1)
        print(Thread.isMainThread) // false
        return data
    }
}

结语

虽然 actors 尚未达到最终形态,但 Swift 语言及其并发模型正在不断发展。最近的 Swift 6.2 版本 引入了几个新特性,使得使用 actors 和并发比以往任何时候都更容易,例如“控制默认 actor 隔离推断”、“全局 actor 隔离一致性”以及“默认在调用者的 actor 上运行非隔离异步函数”的能力。这些改进与官方 Swift 文档中概述的 易用并发愿景 一致。随着 Swift 的不断成熟,我们可以期待更多增强功能,使并发编程更安全、直观和易用。

与此同时,我们现在就可以开始将 actor 和其他 Swift 并发特性引入到我们的项目中,以提升开发体验。

本文使用 Xcode 16.0 和 Swift 6.0 编写。

❌
❌