普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月28日iOS

欧陆风云5的游玩笔记

作者 云风
2025年11月28日 18:53

最近一个月共玩了 270 小时的欧陆风云5 ,这两天打算停下来。最近在游戏后期打大战役时,交互已经卡得不行。我已经是 i9-14900K 的 CPU ,估计升级硬件已经无法解决这个问题,只能等版本更新优化了。

ps. 其实只要把游戏暂停下来立刻就不卡了。虽然我直到这个游戏需要的计算量非常大,但是卡交互操作肯定是实现的不对。因为这并不是因为渲染负荷造成的卡顿,可以让游戏时间流逝更慢一些,也不应该让鼠标点击后的界面弹出时间变长。

在暂置游戏前,我先把一些关于游戏设计上的理解先记录下来。也是对上一篇的补充

在最初几十小时的游戏时间里,我一直想确认游戏经济系统的基础逻辑。和很多类似策略游戏不同,欧陆风云5 在游戏一开始,展现给玩家的是一个发展过(或者说是设定出来)的经济系统版图。玩家更需要了解的是他选择扮演的国家在当下到底面临什么问题,该怎样解决。这不只是经济,也包括政治、文化和军事。而很多游戏则是设定好规则,让玩家从零开始建设,找到合适的发展路径。

大多数情况下,EU5 玩家一开始考虑的并不是从头发展,所以在游戏新手期也没有强烈的理解游戏底层设计细节的动机。不过游戏也有开荒玩法,在游戏中后期势必会在远方殖民、开拓新大陆;甚至游戏还设计了让玩家直接转换视角以新殖民地为核心来继续游戏。但即使的重新殖民,在四周鸟无人烟的地方开荒,和在已有部分发展的区域附近拓展也完全不同。

我十分好奇这样一个复杂的经济系统是怎样启动起来的,所以仔细做了一点归纳笔记。不一定全对,但很多信息在游戏内的说明和目前的官方 wiki 都不完整,只能自己探索。


游戏中的一切来源于“原产”,官方称为 ROG ,比较类似异星工厂里的矿石。上层的一切都是从原产直接或间接获得。版图上的任何一个最小单位的地块,只要上面有人口,就会不断生产出唯一品种的原材料进入这个世界。它和国家控制力、市场接入度都无关系。比原材料更高级的产品都是由原产直接或间接转换而来。

货币本身在世界中不以资源形式存在,货币本身也没有价值。货币的存在在于推动包括原产在内的原材料和产品等在世界中的流动。所以,世界中即使不存在经济活动、没有货币,亦或是货币急剧膨胀,这些因为国家破产而债务消失等让货币总值急剧变化的行为也不会直接影响这个世界中的物资变化。即没有很多游戏中直接用钱凭空兑换成物资的途径。

换句话说,如果整个世界缺铁,那么只能通过生产手段慢慢的产出,再多的钱也无法变出铁来。但分配更多的人力去生产、更高的科技水平可以获得铁产量的提升、使用更高效的配方、各种提升生产率的增益等等都可以加快铁的产出速度。

从一个世界的局部看(这是一般玩家的视野),获得原材料的方式有三种:

  1. 养活更多的劳工或奴隶开发对应原产。
  2. 在合适的地理位置上用劳动或奴隶生产。
  3. 从附近的市场进口。

第一种方式,玩家拥有对应原产地,然后在地皮上增加人口。但新增人口是农民,还需要从农民升级成劳工。国家 buf 中,默认只有原住民满意度对产量有轻微的增益。

第二种方式,玩家有更大的自主性。以铁为例,只要是湿地地形或者地皮邻接湖泊,就可以主动产铁。这种生产除同样需要劳工外,还有原料开销:把炭转换为铁。这种生产方式直接被市场接入率打折,即离市场越远的地方单位人口的生产效率越低,但同时有更多增加生产率的增益途径:最基本的就有当地劳工识字率和市民满意度。和虽然和第一种方式一样需要劳工,但游戏似乎会先满足原产需求的劳工,多出部分才进行建筑生产。所以在劳工不足时,若需进行建筑生产,需要主动减少原产等级。因为生产建筑可以由玩家主动关闭,但原产似乎不行。

第三种方式,通常需要在本地市场拥有一定的市场容量。在不考虑成本时,甚至可以亏本进货。对开荒来说,进口原料比进口成品的优势就在于占用更少的贸易容量。

为什么上面以铁举例,因为铁是开荒时最重要的资源。虽然木头和石头也很重要,但游戏把木材、粘土、沙、石头设定为一般物资,所有地块都有一个很低的默认产能,从市场角度看,根据市场规模,每个市场总有一定量的供给。但铁不属于这种物资。

建造建筑需要的基本材料是砖头,砖头可以通过基础建筑,从粘土或石头转换。

开发原产需要的基本材料是木头或工具。大多数基础建筑的生产配方里都需要工具。而工具在非城市生产建筑中,只有乡村市场可以把铁转换为工具。所以、如果开荒时的市场中缺铁,就只能通过进口。进口制造工具的铁比进口工具更能利用上贸易容量(铁和工具的单位贸易容量相同,但铁到工具以 4:5 转换)。

铁矿在版图上相对其它资源更少,所以一般开荒需更关注市场覆盖下有无湿地地形或有无湖泊,同时需要用充足的木材供应,可以把木材转换为炭再转换为铁。

一旦单一地块上的人口超过 5000 ,就可以升级为城镇,这种生产就有更多选择。以工具制造来说,城镇里就多出了用石头或铜转换为工具的配方。尤其是石制工具的途径,虽然效率很低,通常利润也很低,但贵在石头有保底产出。城镇的升级需要砖头和玻璃,玻璃可通过沙子转换,而沙子有保底产出或通过工具加木材转换。

开荒期间,解决了木头、砖头、玻璃、工具这四种基本货物(前三种是各种基础建筑建设需求,最后是大部分生产转换配方的必须)后,就要考虑提高产能的问题,这里的核心之一是纸。因为劳工识字率影响着生产率。纸是印书的原料、书是图书馆的维持品,而图书馆以及更多提高识字率相关的建筑都需要书。

造纸术需要纤维作物或皮革或布匹。纤维作物的基本生产方法是在对应农场通过牲畜木材工具制造;而牲畜则在耕种村落通过工具加粘土转换;皮革则可以在森林村落通过沙加焦油和野味转换,其中焦油在一般木材产区都可以通过木材转换得到。

另一个重要的资源是人口。它在游戏中和钱一样重要、甚至更重要。因为一切的生产行为都需要人。对开荒而言,升级到城市对效率影响最大,这里的硬性要求就是 5000 基础人口。除了主动殖民,就是本地土著转换、周边迁移(集中)以及自然生长率。这些都可以通过内阁行为略微加速,同样关键是修定居点(同时加移民吸引度和自然生长率)。定居点除了 250 农民外的维持成本是石头、木头、羊毛、野味。前两个一定有保底产出,后两个不是必须,缺少只会让效率打折,但供应充足可以发挥全部性能。定居点和乡村市场都占用农民,在最初阶段,我感觉乡村市场更重要一些,毕竟可以制造工具,还能提供宝贵的贸易容量。

不同阶层人口除了产生固定需求(吃掉本地市场的部分产出)外,更基本的需求是食物。EU5 中的食物系统设计,我觉得也是很巧妙的。

食物并不是货物的一种,而是和钱一样,表示为一个单一值。最主要的食物来源是生产带食物属性的货物(被称为食物原料)的副产品。属于食物原料的货物,被设定了不同的食物倍率,这就让有些食物原料产生食物的效率更高。不工作的劳工默认会以一个较低的产能生产食物,所以不必担心多出来的劳动力被浪费。另外,农民在森林村落里,虽然产品皮革并非食物原料,但生产行为本身被设定了食物产能。另外,农村相对城市有额外的食物产能的乘数增益。

食物的仓储按省份为单位计算,省份归属的若干地块共享一个食物仓库。在每个省份会优先填满仓亏,多出的部分卖给了所属市场,这里是不计市场接入度的。而一旦仓库有空间,就会从所属市场购买。战争是影响它的一个变数。因为军队也会从这个仓库中获取食物,围攻会阻止市场上的食物交易。

食物在本地市场上的交易行为会影响到本地食物价格,购买食物的开销和销售食物的收入是分开计算的,全部通过国库完成。市场间并不能单独交易食物,但通过对有食物原料属性的商品的交易,会产生附带的食物流通(但并不会产生额外的货币流动)。我觉得理论上会出现市场仓库中的食物储量为 0 ,但依然出口食物原料的情况,但实际玩的时候并没有发现,所以不清楚游戏怎样处理。但我猜测,食物流通是在单独层面计算的。既然超出市场食物容量的食物似乎就消失了,那么也可以接受万一食物储量为 0 却继续出口的情况,把储量设为 0 即可。


最后,写写我对税收的看法。

简单说,游戏里的经济活动产生了税基。税基中按王室影响力直接把钱进入国库,另外的钱按阶层影响力分到了各阶层。但玩家可以对阶层分得的钱征税,让这些钱进入国库。

看起来,在不造成阶层不满的前提下,税率越高,国库收入就越高。但实际我玩的感觉是,其实税基才是整个国家的收入,国库仅仅是玩家可以主动调配的部分。阶层保留更多的收入,也会投入到国家发展中去,只不过有时不是玩家想要的方向,甚至是负方向。例如当玩家想削弱某个阶层的影响力时,阶层把钱投入都修建扩大本阶层影响力的建筑上。但总的来说,如果国库钱够用,更低的税收更好。因为税基相同时,税收影响的是分配。低税收必定增加阶层满意度,带来的正面增益是额外的。正所谓藏富于民。

而影响税基最重要的是地区控制度。当然地区控制度不仅仅影响税基,还影响了更多建筑的效率。从这个意义上来说,地方分权比中央集权更有利于经济发展。分封属国,尤其是朝贡国,比大一统国家会获得更好的经济局面。

但权力分配在游戏中也相当重要,因为它直接影响调配价值观的能力。价值观在一盘游戏进程中必须配合时代发展而演变才能更好的发展经济。而集权以及王室影响力是权利分配能力的来源。

所以说,最终玩整个游戏的体验还是在和面,只是多出了一份历史感。有了真实历史这种后验知识,才更为有趣。

flutter 集成flutter_Boost

2025年11月28日 17:22

flutter_Boots 是咸鱼开源的三方框架,主要是为原生和flutter之间混合跳转提供的解决方案,下面说一下集成flutter_Boots的步骤和如何在项目中使用flutter_Boots。

  1. 创建原生工程和flutter module

    1. 使用xcode创建iOS app原生工程,这个比较简单,这里面就不去贴图了。
    2. 创建flutter module,执行命令 flutter create -t module my_flutter_module。
    3. 这样在本地就把iOS工程和flutter module创建好了,如下图: image.png
  2. flutter安装flutter_Boots依赖

    1. 需要注意的是,flutter_boost的高版本需要使用git这种方式去安装依赖。
    2. 安装截图配置依赖,然后执行命令 flutter pub get按钮依赖。

    image.png

  3. ios 配置pod

    1. cd my_ios_app
    2. pod init
    3. 修改podfile文件
    4. pod install
    # Uncomment the next line to define a global platform for your project
    platform :ios, '13.0'
    
    flutter_application_path = '../my_flutter_module'
    load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
    
    target 'my_ios_app' do
      # Comment the next line if you don't want to use dynamic frameworks
      use_frameworks!
    
      install_all_flutter_pods(flutter_application_path)
    
      # Pods for my_ios_app
    
    end
    
    post_install do |installer|
      flutter_post_install(installer) if defined?(flutter_post_install)
    end
    
  4. flutter 编写flutter_boost集成代码

    1. 导入flutter_boost

      import 'package:flutter_boost/flutter_boost.dart';
      
    2. 创建CustomFlutterBinding

      class CustomFlutterBinding extends WidgetsFlutterBinding
          with BoostFlutterBinding {}
      
    3. 测试页面

      class DefaultPage extends StatelessWidget {
        const DefaultPage({super.key});
        @override
        Widget build(BuildContext context) {
          return Scaffold(
            appBar: AppBar(title: const Text('Flutter Boost')),
            body: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  ElevatedButton(
                    onPressed: () {
                      BoostNavigator.instance.push('/one',
                          arguments: {'msg': 'hello from default page 1'});
                    },
                    child: const Text('go to page one'),
                  ),
                  ElevatedButton(
                    onPressed: () {
                      BoostNavigator.instance.push('/two',
                          arguments: {'msg': 'hello from default page 2'});
                    },
                    child: const Text('go to page two'),
                  ),
                  ElevatedButton(
                    onPressed: () {
                      BoostNavigator.instance.push('/home',
                          arguments: {'msg': 'hello from default page 2'});
                    },
                    child: const Text('go to page native home'),
                  )
                ],
              ),
            ),
          );
        }
      }
      
      class OnePage extends StatelessWidget {
        const OnePage({super.key, required this.pramas});
        final Map pramas;
        @override
        Widget build(BuildContext context) {
          return Scaffold(
            appBar: AppBar(title: const Text('page one')),
            body: Text('page one, 参数: ${pramas['msg']}'),
          );
        }
      }
      
      class TwoPage extends StatelessWidget {
        const TwoPage({super.key, required this.pramas});
        final Map pramas;
        @override
        Widget build(BuildContext context) {
          return Scaffold(
            appBar: AppBar(title: const Text('page two')),
            body: Text('page two, 参数: ${pramas['msg']}'),
          );
        }
      }
      
    4. 编写widget和路由代码

      void main() {
        CustomFlutterBinding();
        runApp(const MyApp());
      }
      
      class MyApp extends StatefulWidget {
        const MyApp({super.key});
      
        @override
        State<StatefulWidget> createState() => _MyAppState();
      }
      
      class _MyAppState extends State<MyApp> {
        @override
        Widget build(BuildContext context) {
          return FlutterBoostApp(routeFactory);
        }
      
        Widget appBuilder(Widget home) {
          return MaterialApp(
            home: home,
            debugShowCheckedModeBanner: true,
            builder: (_, __) {
              return home;
            },
          );
        }
      }
      
      Route<dynamic>? routeFactory(
          RouteSettings settings, bool isContainerPage, String? uniqueId) {
        final pramas = (settings.arguments as Map?) ?? {};
        switch (settings.name) {
          case '/':
            return MaterialPageRoute(
                settings: settings, builder: (_) => const DefaultPage());
          case '/one':
            return MaterialPageRoute(
                settings: settings, builder: (_) => OnePage(pramas: pramas));
          case '/two':
            return MaterialPageRoute(
                settings: settings, builder: (_) => TwoPage(pramas: pramas));
          default:
            return null;
        }
      }
      

      flutter端代码集成完毕。

  5. iOS端代码集成

    1. 先创建一个BoostDelegate继承FlutterBoostDelegate,里面主要的逻辑就是实现push原生、push flutter、pop的方法.

      import Foundation
      import flutter_boost
      
      class BoostDelegate: NSObject, FlutterBoostDelegate {
          
          //push导航栏
          var navigationController: UINavigationController?
          
          //记录返回flutter侧返回结果列表
          var resultTable: Dictionary<String, ([AnyHashable: Any]?) -> Void> = [:]
          
          func pushNativeRoute(_ pageName: String!, arguments: [AnyHashable : Any]!) {
              let isPresent = arguments["isPresent"] as? Bool ?? false
              let isAnimated = arguments["isAnimated"] as? Bool ?? true
              var targetViewController = UIViewController()
              if pageName == "/home" {
                  targetViewController = HomeViewController()
              }
              if isPresent {
                  navigationController?.present(targetViewController, animated: isAnimated)
              } else {
                  navigationController?.pushViewController(targetViewController, animated: isAnimated)
              }
          }
          
          func pushFlutterRoute(_ options: FlutterBoostRouteOptions!) {
              let vc: FBFlutterViewContainer = FBFlutterViewContainer()
              vc.setName(options.pageName,
                         uniqueId:options.uniqueId,
                         params: options.arguments,
                         opaque: options.opaque)
              let isPresent = options.arguments["isPresent"] as? Bool ?? false
              let isAnimated = options.arguments["isAnimated"] as? Bool ?? true
              
              //对这个页面设置结果
              resultTable[options.pageName] = options.onPageFinished
              
              if (isPresent || !options.opaque) {
                  navigationController?.present(vc, animated: isAnimated)
              } else {
                  navigationController?.pushViewController(vc, animated: isAnimated)
              }
          }
          
          func popRoute(_ options: FlutterBoostRouteOptions!) {
              //如果当前被present的vc是container,那么就执行dismiss逻辑
              if let vc = self.navigationController?.presentedViewController as? FBFlutterViewContainer,vc.uniqueIDString() == options.uniqueId{
                  
                  //这里分为两种情况,由于UIModalPresentationOverFullScreen下,生命周期显示会有问题
                  //所以需要手动调用的场景,从而使下面底部的vc调用viewAppear相关逻辑
                  if vc.modalPresentationStyle == .overFullScreen {
                      
                      //这里手动beginAppearanceTransition触发页面生命周期
                      self.navigationController?.topViewController?.beginAppearanceTransition(true, animated: false)
                      
                      vc.dismiss(animated: true) {
                          self.navigationController?.topViewController?.endAppearanceTransition()
                      }
                  }else{
                      //正常场景,直接dismiss
                      vc.dismiss(animated: true, completion: nil)
                  }
              }else{
                  self.navigationController?.popViewController(animated: true)
              }
              //否则直接执行pop逻辑
              //这里在pop的时候将参数带出,并且从结果表中移除
              if let onPageFinshed = resultTable[options.pageName] {
                  onPageFinshed(options.arguments)
                  resultTable.removeValue(forKey: options.pageName)
              }
          }
      }
      
  6. 修改Appdelegate文件

     var boostDelegate = BoostDelegate() 
        
        func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
            // Override point for customization after application launch.
            FlutterBoost.instance().setup(application, delegate: boostDelegate, callback: { engine in
            })
            return true
        }
    
  7. 添加跳转交互

    1. 跳转flutter

       if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
                  appDelegate.boostDelegate.navigationController = self.navigationController
              }
              let ops = FlutterBoostRouteOptions()
              ops.pageName = "/"
              ops.arguments = ["msg":"app"]
              FlutterBoost.instance().open(ops)
      
    2. 跳转原生

       ElevatedButton(
                    onPressed: () {
                      BoostNavigator.instance.push('/home',
                          arguments: {'msg': 'hello from default page 2'});
                    },
                    child: const Text('go to page native home'),
                  )
      

通过以上的集成步骤和代码编写,我们就可以流畅的在flutter和原生之间互相跳转了。

政务App如何真正成为便民好帮手?

作者 FinClip
2025年11月28日 15:32

你是否曾在微信、支付宝、各个政务APP之间反复切换,只为办理一项简单的业务?是否曾因不同平台需要重复注册登录而感到困扰?为何费心费力推出的政务APP,有的让群众真正享受到了“高效办成一件事”的便利,有的却给群众带来了困惑?

项目背景

政务APP作为“互联网 + 政务服务”的核心载体,已然成为提升政府治理能力与服务水平的关键手段。随着《整治形式主义为基层减负若干规定》与《政务数据共享条例》的相继颁布,政务数据整合共享迎来了政策机遇期。然而,政务APP在发展过程中仍面临多重挑战:

重复建设:服务应用在多个App重复开发,标准不一,难以统一管理;

入口分散:服务应用散落各处,缺乏统一入口,导致用户体验碎片化;

更新迟缓:应用开发发布流程繁琐,无法快速响应政策和用户需求; 

集成困难:内部系统标准各异,对接难度大,且数据敏感,安全要求高;

运维复杂:应用缺乏统一治理,各部门各自为政,运维效率和难度增加;

解决方案:携手FinClip,打造全省一体化数据平台

在此背景下,某省单位携手超级应用智能平台FinClip,打造全省一体化数据基础平台,最终形成了定位清晰、协同发展的三大服务入口,全面覆盖便民服务、企业服务与协同办公等场景。

图片

►【便民服务】统一入口,打造核心政务阵地

作为面向民众的统一服务入口,该平台全面整合社保公积金、交通出行、医疗健康、办事服务等核心政务功能,如:育儿补贴、文旅休闲、农林牧渔、民政婚育等,成为民众办理事务的核心平台。

同时,通过构建统一用户体系,实现一次登录、全网通办,有效提升用户服务体验。

►【企业服务】政策服务一站通,精准赋能企业发展

该入口聚焦企业全生命周期服务,整合“政策”与“办事”两大核心板块。

政策板块:汇聚“即申即享”惠企政策与热点资讯,推动政策精准直达、免申即享,助力企业“零跑腿、快兑现”。 

服务板块:集成“高效办成一件事”主题服务,覆盖开办企业、水电气报装、纳税缴费、融资人才等高频事项,实现“找政府、找资金、找人才”一键直达。

►【协同办公】构建政务工作平台,协同业务均在线

FinClip超级应用智能平台, 提供了统一的开发标准与开放架构,降低内部系统对接门槛。

组织在线:支持全程百万用户同事在线、可快速找人、找组织,支持千人千面的通讯录权限,保护隐私安全; 

协同在线:工作通知、待办、消息、日程、会议等关键工作一目了然; 

业务在线:工作台帮助用户整合工作、聚焦业务、满足多种办公场景; 

沟通在线:支持政务工作人员进行安全、可靠的实时在线交流沟通。

图片

技术赋能:高效、降本、自主可控

► 流程化/低代码开发,大幅提升开发效率

利用FinClip IDE的流程化/低代码开发能力,政务流程类应用的开发实现可视化搭建、组件化配置。开发人员可通过拖拽方式快速构建业务流程,后端服务通过标准化接口快速对接。 

实施效果:政务流程类应用开发周期缩短30%,业务需求响应速度显著提升。

► 性能优化成效显著,用户体验大幅提升

通过集成FinClip SDK,政务办事、内部办公两端应用在运行小程序及H5类应用时的性能得到显著优化:应用打开白屏现象得到有效控制,等待时间降低25%;界面加载速度提升20%。

► 跨端兼容,降本增效

FinClip的小程序特性,让应用只需一次开发,便能无缝运行在iOS、Android、鸿蒙,以及各类信创终端上。这意味着政府部门无需为不同的操作系统重复投入研发资源,运营成本能大幅降低50%以上,大幅提升了研发效率和资源利用率。

图片

► 安全可控,信创适配

作为国内首批完成信创全栈适配的小程序平台,FinClip从底层架构上满足自主可控的严苛要求。全面支持鲲鹏、飞腾等国产CPU,兼容统信UOS、麒麟等国产操作系统,并采用国密算法保障数据传输,为政务数据筑起一道坚不可摧的安全堡垒。

图片

实施成效:全省协同效率显著提升

目前,全省一体化平台,已成为省单位移动端服务的核心载体,有效驱动了服务创新加速,为便民、利民政务服务注入了持续动能。

提升用户活跃与留存:通过场景融合与服务整合,月活跃用户超千万,小程序用户数环比增长20%,用户满意度和粘性显著提升。

增强业务敏捷:业务需求平均上线周期缩短70%以上,政策响应速度快人一步,市场竞争力大幅增强。

降低运营成本:生态引入成本降低60%-80%,现有小程序生态迁移成本近乎为零,资源利用效率显著提升。 

保障安全合规:建立完善的数据安全防护体系,实现业务创新与风险控制的平衡,为可持续发展奠定基础。

该省政务平台的成功实践,是FinClip在政务领域深度赋能的标杆案例。未来,FinClip将继续携手各级政府,依托其云原生、中台化、组件化的技术架构,共同推进数字政府建设着眼于群众办事需求,以“高效办成一件事”为牵引,让政务服务更高效、更便捷。

📩 联系我们:FinClip官网免费注册体验或者咨询。

Flutter 图纸标注功能的实现:踩坑与架构设计

作者 明君87997
2025年11月28日 11:20

写在前面

最近在做一个工程验收的项目,有个需求是要在 CAD 图纸上标注问题点。一开始觉得挺简单,不就是显示个图片,点一下加个 Marker 吗?真动手做了才发现,这里面的坑多到怀疑人生。

比如说:

  • 工地现场网络差到爆,必须完全离线
  • 图纸动辄几千像素,加载和交互都卡
  • 业务逻辑一堆,担心后面没法维护
  • 各种坐标系转来转去,脑壳疼

折腾了两周,终于把这个东西搞定了。整个过程中踩了不少坑,也积累了一些经验,所以写篇文章记录一下,顺便分享给有类似需求的朋友。

整体思路

搞这个东西之前,我先理了理需求,发现核心就是:在一张离线图纸上,支持用户点击标注,还得支持区域限制(不能乱点)

听起来简单,但要做好,必须解决几个问题:

  1. **怎么让代码不和业务绑死?**毕竟这个功能不止一个地方用
  2. **怎么管理状态?**标记点、多边形、图纸这些东西状态管理一团乱
  3. **怎么保证性能?**大图加载、高频交互都得优化

想来想去,决定按这个思路来:

CustomMapWidget (视图组件)
     ↓
CustomMapController (控制器,处理逻辑)
     ↓
CustomMapState (状态管理,响应式更新)
     ↓
MapDataSource (抽象接口,业务自己实现)

简单说就是:视图负责展示,控制器负责协调,状态负责响应式更新,业务逻辑通过接口注入

这样的好处是,核心框架和具体业务完全解耦,换个场景只需要实现不同的 DataSource 就行。

关键设计:业务抽象层

这个是整个架构的核心。我定义了一个抽象接口 MapDataSource

abstract class MapDataSource {
  // 加载图纸(可能从本地、可能从服务器)
  Future<MapSourceConfig> loadMapDrawingResource(CrsSimple crs);
  
  // 创建一个标记点(业务自己决定样式)
  Marker addMarker(LatLng point, {String? number});
  
  // 批量加载已有的标记点
  List<Marker> loadMarkers(List<Point<double>>? latLngList, CrsSimple crs);
  
  // 加载多边形(比如房间轮廓、限制区域等)
  dynamic loadPolygons(CrsSimple crs);
}

为什么要这么设计?因为每个业务场景的需求都不一样

  • 验收系统可能需要红色图钉标记问题点
  • 测量系统可能需要数字标记测量点
  • 巡检系统可能需要设备图标

把这些差异抽象出来,让业务层自己实现,核心框架就不用改了。

具体实现

一、状态管理怎么搞

一开始用 Provider 写的,后来发现状态更新太频繁,性能不行。改成 GetX 之后丝滑多了。

class CustomMapState {
  // Flutter Map 的控制器,用来控制缩放、移动等
  MapController mapController = MapController();
  
  // 坐标系统(这个是关键,后面会讲为什么用 CrsSimple)
  final CrsSimple crs = const CrsSimple();
  
  // 配置信息(响应式的,方便动态修改)
  final Rx<MapDrawingConfig> config = MapDrawingConfig().obs;
  
  // 当前使用的图纸
  final Rx<MapSourceConfig?> currentMapSource = Rx<MapSourceConfig?>(null);
  
  // 地图边界(用来做自适应显示)
  LatLngBounds? mapBounds;
  
  // 标记点列表(Rx开头的都是响应式的,改了自动刷新UI)
  final RxList<Marker> markers = <Marker>[].obs;
  
  // 多边形列表(比如房间轮廓)
  final RxList<Polygon> polygons = <Polygon>[].obs;
  
  // 当前正在绘制的点
  final RxList<LatLng> currentDrawingPoints = <LatLng>[].obs;
  
  // 有效区域(用户只能在这个范围内标注)
  List<LatLng> houseLatLngList = [];
}

这里有几个关键点:

  • Rx 系列:GetX 的响应式类型,状态改了UI自动更新,不用手动 setState
  • CrsSimple:简单笛卡尔坐标系,因为图纸用的是像素坐标,不是真的经纬度
  • 多图层分离:标记点、多边形、绘制点分开管理,互不影响

二、控制器的核心逻辑

控制器主要负责协调各个部分,处理用户交互。

初始化流程

_initData() async {
  state.config.value = config;
  try {
    // 调用业务层加载图纸
    var result = await dataSource.loadMapDrawingResource(state.crs);
    state.currentMapSource.value = result;
    state.mapBounds = result.defaultSource.bounds;
  } catch (e) {
    // 这里可能失败,比如文件不存在、网络问题等
    logDebug('加载图纸失败: $e');
  } finally {
    onMapReady(); // 不管成功失败都要走后续流程
  }
}

地图渲染完成的回调

void onMapReady() {
  if (state.isMapReady) return; // 防止重复调用(之前遇到过bug,这里加个保险)
  
  state.isMapReady = true;
  
  // 加载多边形(比如房间轮廓、限制区域等)
  var parameter = dataSource.loadPolygons(state.crs);
  if (parameter['polygonList'] != null) {
    state.polygons.value = parameter['polygonList'];
  }
  
  // 如果有历史标记点,也一起加载进来
  if (config.latLngList.isNotEmpty) {
    state.markers.value = dataSource.loadMarkers(config.latLngList, state.crs);
  }
  
  // 自适应显示整个图纸(不然可能只看到一个角)
  if (state.mapBounds != null) {
    state.mapController.fitCamera(
      CameraFit.bounds(bounds: state.mapBounds)
    );
  }
}

点击事件处理(重点)

这是最核心的逻辑,处理用户在图纸上的点击:

void addDrawingPoint(TapPosition tapPosition, LatLng latlng) {
  // 第一步:坐标转换(从地图坐标转成像素坐标)
  // 为什么要转?因为后端存的是像素坐标,前端显示用的是地图坐标
  Point<double> cp = state.crs.latLngToPoint(
    latlng, 
    state.config.value.serverMapMaxZoom
  );
  
  // 第二步:检查是否超出图纸范围
  // 之前没加这个判断,用户点到图纸外面就报错,体验很差
  if (cp.x < 0 || cp.y < 0 || 
      cp.x > currentMapSource.width ||
      cp.y > currentMapSource.height) {
    showSnackBar('超出图纸范围');
    return;
  }
  
  // 第三步:检查是否在有效区域内
  // 比如验收系统要求只能在房间内标注,不能标到墙外面去
  if (state.houseLatLngList.isNotEmpty &&
      !MapUtils.isPointInPolygon(latlng, state.houseLatLngList)) {
    showSnackBar('请将位置打在画区内');
    return;
  }
  
  // 第四步:通知业务层(让业务层保存数据)
  config.onTap?.call(cp, latlng);
  
  // 第五步:在地图上显示标记点
  addMarker(position: latlng);
}

这个函数看起来简单,但每一步都是踩坑踩出来的:

  • 坐标转换那里,之前 zoom 值没对齐,导致标记点位置偏移
  • 边界检查是测试提的bug,用户点外面会崩
  • 区域约束是产品后来加的需求,还好架构预留了扩展性

三、视图层的设计

视图层就是负责显示,用 Flutter Map 的多图层机制:

@override
Widget build(BuildContext context) {
  return GetBuilder<CustomMapController>(
    tag: tag,  // 用tag支持多实例,不然多个地图会冲突
    id: 'map', // 局部刷新用的,只刷新地图部分
    builder: (controller) {
      return FlutterMap(
        mapController: controller.state.mapController,
        options: _buildMapOptions(),
        children: [
          _buildTileLayer(),      // 底图层(图纸)
          _buildPolygonLayer(),   // 多边形层(房间轮廓)
          _buildMarkerLayer(),    // 标记点层
          ...?children,           // 预留扩展位,可以加自定义图层
        ],
      );
    },
  );
}

Flutter Map 用的是图层叠加的方式,从下往上渲染。顺序很重要,搞错了标记点就被图纸盖住了(别问我怎么知道的)。

底图层的实现

Widget _buildTileLayer() {
  return Obx(() {  // Obx 会监听里面用到的响应式变量
    final currentSource = controller.state.currentMapSource.value;
    
    // 图纸还没加载完,显示loading
    if (currentSource?.defaultSource.localPath?.isEmpty ?? true) {
      return const Center(child: CircularProgressIndicator());
    }
    
    // 加载本地图纸文件
    return OverlayImageLayer(
      overlayImages: [
        OverlayImage(
          imageProvider: FileImage(File(currentSource.defaultSource.localPath)),
          bounds: currentSource.defaultSource.bounds  // 图纸的边界
        )
      ]
    );
  });
}

这里用 OverlayImageLayer 把本地图片当成地图底图,bounds 定义了图片的坐标范围。一开始我还尝试用瓦片图的方式切片加载,后来发现图纸不大(2-3M),直接整图加载反而更简单。

四、工厂模式的应用

为了方便使用,封装了一个工厂类:

class CustomMapFactory {
  static CustomMapWidget createDefault({
    required MapDataSource dataSource,
    required MapDrawingConfig config,
    String? tag,
  }) {
    late CustomMapController controller;
    
    // 检查是否已经创建过(避免重复创建导致内存泄漏)
    if (Get.isRegistered<CustomMapController>(tag: tag)) {
      controller = Get.find<CustomMapController>(tag: tag);
    } else {
      controller = CustomMapController(
        dataSource: dataSource,
        config: config,
      );
      Get.lazyPut(() => controller, tag: tag);  // 懒加载,用的时候才创建
    }
    
    return CustomMapWidget(
      controller: controller,
      tag: tag,
    );
  }
  
  // 页面销毁时记得调用,不然内存泄漏
  static void disposeController(String tag) {
    if (Get.isRegistered<CustomMapController>(tag: tag)) {
      Get.delete<CustomMapController>(tag: tag);
    }
  }
}

使用示例

// 创建地图组件
final mapWidget = CustomMapFactory.createDefault(
  dataSource: MyDataSourceImpl(),  // 你自己的业务实现
  config: MapDrawingConfig(
    serverMapMaxZoom: 8.0,
    onTap: (pixelPoint, latlng) {
      print('点击了坐标: $pixelPoint');
    },
  ),
  tag: 'project_01',  // 用唯一标识,支持多个地图实例
);

踩坑记录

坑一:坐标系统的选择

一开始我用的是常规的地理坐标系(EPSG:3857),结果发现图纸上的坐标根本对不上。后来才明白,CAD 图纸用的是像素坐标,不是经纬度

后端存的坐标是这样的:{x: 1234, y: 5678},单位是像素。而 Flutter Map 默认用的是经纬度坐标。

解决办法是用 CrsSimple(简单笛卡尔坐标系)

// CrsSimple 可以把像素坐标当成"伪经纬度"
final CrsSimple crs = const CrsSimple();

// 地图坐标 → 像素坐标(给后端用)
Point<double> pixelPoint = crs.latLngToPoint(
  latlng, 
  serverMapMaxZoom  // zoom 级别要和后端约定好
);

// 定义图纸的边界
LatLngBounds bounds = LatLngBounds(
  LatLng(0, 0),                      // 图纸左上角
  LatLng(imageHeight, imageWidth)    // 图纸右下角
);

这里有几个坑:

  1. zoom 级别必须和后端一致,不然坐标会偏移。我们约定的是 8
  2. Y 轴方向:CrsSimple 的 Y 轴是向下的,和传统坐标系相反
  3. 小数精度:坐标转换会有浮点误差,存数据库时要注意

坑二:点在多边形内判定

产品要求用户只能在房间内标注,不能标到墙外面去。这就需要判断点是否在多边形内。

我用的是射线法(Ray Casting),原理很简单:从点向右发射一条射线,数射线和多边形边界交点的个数,奇数次就在内部,偶数次就在外部。

static bool isPointInPolygon(LatLng point, List<LatLng> polygon) {
  int intersectCount = 0;
  
  // 遍历多边形的每条边
  for (int i = 0; i < polygon.length; i++) {
    // 取当前点和下一个点(首尾相连)
    final LatLng vertB = 
      i == polygon.length - 1 ? polygon[0] : polygon[i + 1];
    
    // 检查射线是否和这条边相交
    if (_rayCastIntersect(point, polygon[i], vertB)) {
      intersectCount++;
    }
  }
  
  // 奇数次相交说明在内部
  return (intersectCount % 2) == 1;
}

static bool _rayCastIntersect(LatLng point, LatLng vertA, LatLng vertB) {
  final double aY = vertA.latitude;
  final double bY = vertB.latitude;
  final double aX = vertA.longitude;
  final double bX = vertB.longitude;
  final double pY = point.latitude;
  final double pX = point.longitude;
  
  // 优化:快速排除明显不相交的情况
  // 如果AB两个点都在P的上方/下方/左侧,肯定不相交
  if ((aY > pY && bY > pY) || 
      (aY < pY && bY < pY) || 
      (aX < pX && bX < pX)) {
    return false;
  }
  
  // 特殊情况:垂直的边
  if (aX == bX) return true;
  
  // 计算射线与边的交点X坐标(直线方程 y = mx + b)
  final double m = (aY - bY) / (aX - bX);  // 斜率
  final double b = ((aX * -1) * m) + aY;   // 截距
  final double x = (pY - b) / m;           // 交点的X坐标
  
  // 如果交点在P的右侧,说明射线和这条边相交了
  return x > pX;
}

这个算法看起来复杂,其实就是初中的直线方程 y = mx + b。第一次写的时候没考虑垂直边的情况,结果遇到矩形房间就挂了。

坑三:内存泄漏

GetX 虽然好用,但不注意的话很容易内存泄漏。尤其是在列表页,每个 item 都创建一个地图实例,来回滚动几次内存就爆了。

解决方案:

@override
void onClose() {
  if (_isDisposed) return;  // 防止重复释放
  
  super.onClose();
  
  // 释放地图控制器
  state.mapController.dispose();
  
  // 清空所有列表
  state.markers.clear();
  state.polygons.clear();
  state.currentDrawingPoints.clear();
  
  // 重置状态
  state.config.value = MapDrawingConfig();
  state.currentMapSource.value = null;
  state.isMapReady = false;
  
  _isDisposed = true;
}

页面销毁时记得调用:

@override
void dispose() {
  CustomMapFactory.disposeController('project_${projectId}');
  super.dispose();
}

数据模型设计

配置模型

class MapDrawingConfig {
  // 样式相关
  final Color defaultMarkerColor;      // 标记点颜色
  final double defaultMarkerSize;      // 标记点大小
  
  // 缩放相关(这几个参数很重要)
  final double serverMapMaxZoom;  // 后端用的zoom级别(要对齐)
  final double realMapMaxZoom;    // 前端实际最大zoom(影响流畅度)
  final double minZoom;           // 最小zoom(防止缩太小)
  
  // 交互相关
  final bool singleMarker;  // 是否单点模式(有些场景只能选一个点)
  Function(Point<double>, LatLng)? onTap;  // 点击回调
  
  // 数据相关
  List<Point<double>> latLngList; // 已有的标记点(用来回显)
}

配置项不算多,但每个都是实际用到的。一开始想做成超级灵活的配置系统,后来发现太复杂了,就简化成这样。

地图源模型

class MapSource {
  final String localPath;     // 图纸的本地路径
  final LatLngBounds bounds;  // 图纸的边界
  final double height;        // 图纸高度(像素)
  final double width;         // 图纸宽度(像素)
}

class MapSourceConfig {
  final MapSource defaultSource;  // 默认使用的图纸
  
  // 工厂方法:快速创建本地图纸配置
  factory MapSourceConfig.customLocal({
    required String customPath,
    required double height,
    required double width,
  }) { ... }
}

这个模型设计得比较简单,因为我们的需求就是加载一张本地图纸。如果你的场景需要多个图纸切换,可以扩展 availableSources 列表。


性能优化

图层懒加载

没有数据的图层直接返回空 Widget,不渲染:

Widget _buildMarkerLayer() {
  return Obx(() {
    if (controller.state.markers.isEmpty) {
      return const SizedBox.shrink();  // 空图层
    }
    return MarkerLayer(markers: controller.state.markers);
  });
}

局部刷新

用 GetBuilder 的 id 参数实现精准刷新:

update(['map']);  // 只刷新地图,不影响页面其他部分

这个太重要了,之前没加 id,每次更新都全页面刷新,卡得要命。

图片缓存

FileImage 自带缓存,不需要额外处理。但如果图纸特别大(>10M),建议在加载前先压缩一下。


使用指南

第一步:实现数据源接口

根据你的业务需求,实现 MapDataSource

class MyProjectDataSource implements MapDataSource {
  @override
  Future<MapSourceConfig> loadMapDrawingResource(CrsSimple crs) async {
    // 从服务器下载或本地读取图纸
    String localPath = await getDrawingPath();  // 你的业务逻辑
    
    return MapSourceConfig.customLocal(
      customPath: localPath,
      height: 1080,  // 图纸高度
      width: 1920,   // 图纸宽度
    );
  }
  
  @override
  Marker addMarker(LatLng point, {String? number}) {
    // 创建一个标记点(自定义样式)
    return Marker(
      point: point,
      width: 40,
      height: 40,
      child: Icon(Icons.location_pin, color: Colors.red),
    );
  }
  
  @override
  List<Marker> loadMarkers(List<Point<double>>? points, CrsSimple crs) {
    // 加载已有的标记点(比如从数据库读取)
    return points?.map((point) {
      LatLng latlng = crs.pointToLatLng(point, 8.0);
      return addMarker(latlng);
    }).toList() ?? [];
  }
  
  @override
  dynamic loadPolygons(CrsSimple crs) {
    // 加载多边形(房间轮廓、限制区域等)
    return {
      'polygonList': [...],  // 你的多边形数据
      'houseLatLngList': [...],  // 限制区域
    };
  }
}

第二步:创建地图组件

final mapWidget = CustomMapFactory.createDefault(
  dataSource: MyProjectDataSource(),
  config: MapDrawingConfig(
    serverMapMaxZoom: 8.0,
    singleMarker: false,  // 是否单点模式
    onTap: (pixelPoint, latlng) {
      // 用户点击了,这里保存坐标到数据库
      saveToDatabase(pixelPoint);
    },
  ),
  tag: 'project_${projectId}',  // 用唯一ID作为tag
);

第三步:在页面中使用

class MyPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('图纸标注')),
      body: mapWidget,
    );
  }
}

// 页面销毁时记得释放资源
@override
void dispose() {
  CustomMapFactory.disposeController('project_${projectId}');
  super.dispose();
}

几个注意事项

  1. zoom 级别要和后端对齐,不然坐标会偏
  2. tag 必须唯一,建议用项目ID或其他唯一标识
  3. 记得释放资源,不然内存泄漏
  4. 图纸路径要正确,文件不存在会报错

总结

这套架构最大的优点是解耦。核心框架不关心你的业务,只负责地图展示和交互。所有业务逻辑都通过 DataSource 接口注入,换个场景只需要写一个新的 DataSource 实现就行。

当然也有一些不足:

  • 对于特别复杂的标注需求(比如绘制曲线、多边形编辑),还需要扩展
  • 大图纸(>10M)的加载性能还有优化空间
  • 离线缓存目前还没做

不过对于大部分场景来说,已经够用了。

如果你也有类似的需求,希望这篇文章能帮到你。有问题欢迎交流!


2024年实战项目总结,代码已脱敏。

深入理解 UINavigationController:生命周期、动画优化与性能调优

2025年11月28日 10:42

在日常开发中,UINavigationController 是我们最常用的容器控制器之一。但你是否真正理解:

  • 页面 push/pop 时,两个 ViewController 的生命周期方法如何调用?
  • 为什么首次进入新页面会卡顿?
  • 如何让导航切换更丝滑?
  • 又该如何定位动画卡顿的“罪魁祸首”?

本文将从 基础生命周期 → 动画优化 → 性能检测 三个层次,带你系统掌握 UINavigationController 的核心机制,并提供可落地的 Objective-C 实践方案。


一、页面切换时的生命周期:谁先谁后?

场景 1:Push 新页面(A → B)

假设当前栈顶是 ViewControllerA,点击按钮 push 到 ViewControllerB

// ViewControllerB 首次创建
- (void)viewDidLoad {
    [super viewDidLoad];
    NSLog(@"B: viewDidLoad");
}

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    NSLog(@"B: viewWillAppear");
}

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    NSLog(@"B: viewDidAppear");
}
// ViewControllerA 被压入栈底
- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    NSLog(@"A: viewWillDisappear");
}

- (void)viewDidDisappear:(BOOL)animated {
    [super viewDidDisappear:animated];
    NSLog(@"A: viewDidDisappear");
}

调用顺序如下:

B: viewDidLoad
A: viewWillDisappear
B: viewWillAppear
A: viewDidDisappear
B: viewDidAppear

✅ 注意:viewDidLoad 仅在视图首次加载时调用一次。


场景 2:Pop 返回(B → A)

当用户点击返回或手势滑动 pop 回 A:

B: viewWillDisappear
A: viewWillAppear
B: viewDidDisappear
A: viewDidAppear

❗ 关键点:A 的 viewDidLoad 不会再次调用!
所以,若需每次进入都刷新数据,请放在 viewWillAppear: 中。


二、为什么页面切换会卡顿?常见原因

  1. viewDidLoadviewWillAppear: 中执行耗时操作

    • 网络请求、JSON 解析、数据库查询
    • 复杂 Auto Layout 计算
    • 大量子视图创建或图片解码
  2. 首次 push 时构建整个视图层级

    • 导致主线程阻塞,动画掉帧
  3. 离屏渲染(Offscreen Rendering)

    • 圆角 + 阴影 + mask 同时使用
    • 触发 GPU 额外绘制

三、优化策略:让导航切换如丝般顺滑

✅ 1. 异步加载 & 延迟初始化

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 轻量级 UI 初始化
    [self setupUI];
    
    // 耗时任务放后台
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        NSArray *data = [self fetchHeavyData];
        dispatch_async(dispatch_get_main_queue(), ^{
            [self reloadData:data];
        });
    });
}

⚠️ 切记:UI 更新必须回到主线程!


✅ 2. 预加载目标 ViewController(减少首次卡顿)

// 在父页面中预创建
- (DetailViewController *)cachedDetailVC {
    if (!_cachedDetailVC) {
        _cachedDetailVC = [[DetailViewController alloc] init];
        // 提前触发 loadView,构建视图层级
        UIView *temp = _cachedDetailVC.view;
        (void)temp; // 避免编译器警告
    }
    return _cachedDetailVC;
}

- (IBAction)showDetail:(id)sender {
    [self.navigationController pushViewController:self.cachedDetailVC animated:YES];
}

💡 适用于高频跳转页面(如商品详情、用户主页)。


✅ 3. 自定义转场动画(提升体验)

实现 UINavigationControllerDelegate

// MyNavigationControllerDelegate.m
- (id<UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController
                                   animationControllerForOperation:(UINavigationControllerOperation)operation
                                                fromViewController:(UIViewController *)fromVC
                                                  toViewController:(UIViewController *)toVC {
    if (operation == UINavigationControllerOperationPush) {
        return [[FadePushAnimator alloc] init];
    }
    return nil; // 使用默认 pop 动画
}

自定义动画器(简化版淡入):

// FadePushAnimator.m
- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext {
    return 0.35;
}

- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext {
    UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    UIView *container = [transitionContext containerView];
    
    [container addSubview:toVC.view];
    toVC.view.alpha = 0.0;
    
    [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
        fromVC.view.alpha = 0.3;
        toVC.view.alpha = 1.0;
    } completion:^(BOOL finished) {
        fromVC.view.alpha = 1.0;
        [transitionContext completeTransition:!transitionContext.transitionWasCancelled];
    }];
}

🎨 自定义动画可用于品牌化设计,但务必保证流畅性。


四、如何检测性能瓶颈?实战工具链

🔧 1. 使用 Xcode Instruments

(1)Core Animation 模板

  • 运行真机,执行 push/pop
  • 观察 FPS 曲线(目标 ≥ 55)
  • 开启调试选项:
    • Color Blended Layers:红色 = 图层混合过多
    • Color Offscreen-Rendered:黄色 = 离屏渲染

(2)Time Profiler 模板

  • 定位 viewDidLoad / viewWillAppear 中的 CPU 热点
  • 检查是否在主线程做 I/O 或复杂计算

📝 2. 代码埋点测耗时

@property (nonatomic, assign) CFTimeInterval appearStartTime;

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    self.appearStartTime = CACurrentMediaTime();
}

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    CFTimeInterval duration = CACurrentMediaTime() - self.appearStartTime;
    NSLog(@"viewWillAppear → viewDidAppear 耗时: %.2f ms", duration * 1000);
}

若超过 16ms(1帧),就可能影响动画流畅度。


🚨 3. 启用 Main Thread Checker

Xcode 默认开启。若在子线程更新 UI,会立即 crash 并提示:

“Main Thread Checker: UI API called on a background thread”

确保所有 UI 操作都在主线程:

dispatch_async(dispatch_get_main_queue(), ^{
    self.titleLabel.text = newText;
});

五、总结:最佳实践 Checklist

项目 是否做到
viewDidLoad 只做 UI 初始化
✅ 数据加载异步化
✅ 高频页面预加载
✅ 避免离屏渲染(用贝塞尔路径切圆角)
✅ 使用 Instruments 定期检测 FPS
✅ 返回手势未被遮挡

结语

UINavigationController 看似简单,但其背后的生命周期与渲染机制直接影响用户体验。流畅的页面切换不是偶然,而是对细节的极致把控。

希望本文能帮你:

  • 理清生命周期调用顺序
  • 避开常见性能陷阱
  • 掌握一套完整的性能分析方法

真正的高手,不仅写得出功能,更调得稳帧率。

如果你有具体的卡顿案例,欢迎留言交流!


延伸阅读

Apple StoreKit 2 开发指南

作者 Lexiaoyao20
2025年11月27日 19:15

目录

  1. StoreKit 2 核心概念与优势
  2. 基础准备:产品类型与配置
  3. 核心实战 I:获取商品与购买
  4. 核心实战 II:交易验证与监听
  5. 订阅管理:状态、续期与退款
  6. 深度讲解:恢复购买 (Restore Purchases)
  7. 营销功能:折扣与优惠 (Offers)
  8. 测试指南:沙盒 (Sandbox) 与 TestFlight
  9. 最佳实践与常见坑点
  10. 总结

1. StoreKit 2 核心概念与优势

在 StoreKit 2 之前,我们进行内购开发充满了痛苦:复杂的收据验证、晦涩的 API、漏单等... StoreKit 2 利用 Swift 的现代特性(Concurrency)重构了整个框架。

核心优势

StoreKit 2 是 Apple 在 iOS 15+ / macOS 12.0+ 引入的全新内购框架,相比于旧版 StoreKit 具有以下优势:

  • 基于 Swift 并发:使用 async/await 替代回调地狱。
  • 自动交易验证:无需手动解析复杂的 Receipt 文件,系统自动处理 JWS(JSON Web Signature)验证。
  • 交易历史管理:直接通过 API 获取完整的用户购买历史,无需维护复杂的本地数据库。
  • 状态同步:跨设备同步更加顺滑,用户换个手机登录,权益自动同步。

核心概念

概念 说明
Product 商品对象,包含价格、名称、描述等信息
Transaction 交易记录,每次购买产生一个 Transaction
PurchaseResult 购买结果,包含成功、待处理、用户取消等状态
VerificationResult 验证结果,确保交易来自 Apple 服务器
Product.SubscriptionInfo 订阅信息,包含订阅组、续期信息等

流程图解

flowchart LR
    A["App 启动"] --> B["监听交易更新<br/>Transaction Updates"]
    C["用户点击购买"] --> D["获取商品<br/>Products"]
    D --> E["发起购买<br/>Purchase"]
    E --> F{支付结果}
    F -- 成功 --> G["验证交易<br/>Verify"]
    G -- 通过 --> H["发放权益<br/>Unlock Content"]
    H --> I["结束交易<br/>Finish Transaction"]
    F -- 失败/取消 --> J["处理错误 UI"]

    %% 样式定义
    classDef start fill:#E3F2FD,stroke:#2196F3,stroke-width:2px,color:#0D47A1;
    classDef action fill:#FFFFFF,stroke:#90A4AE,stroke-width:2px,color:#37474F;
    classDef decision fill:#FFF8E1,stroke:#FFC107,stroke-width:2px,color:#FF6F00;
    classDef endState fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px,color:#1B5E20;
    classDef error fill:#FFEBEE,stroke:#F44336,stroke-width:2px,color:#B71C1C;

    %% 样式应用
    class A start;
    class B,C,D,E,H action;
    class F decision;
    class I endState;
    class J error;

2. 基础准备:产品类型与配置

在编写代码前,我们首先需要了解 Apple 定义的四种商品类型:

类型 英文名 特点 典型场景
消耗型 Consumable 可重复购买,购买后即消耗 游戏金币、道具
非消耗型 Non-Consumable 一次购买,永久拥有,支持恢复购买 解锁完整版、移除广告、终身会员
自动续期订阅 Auto-Renewing Subscription 按周期扣费,自动续订 视频会员、SaaS 服务
非续期订阅 Non-Renewing Subscription 有效期固定,不自动续费 赛季通行证

环境配置

你可能以为必须先去 App Store Connect 创建商品才能写代码,其实无需这么麻烦,Xcode 提供了一个本地配置文件 (.storekit),让你在没有开发者账号、没联网的情况下也能开发。 操作步骤:

  1. Xcode -> File -> New -> File from Template... -> 搜索 StoreKit Configuration File (或者用快捷键 Command + N).
  2. 不要勾选 "Sync this file with an app in App Store Connect" (除非你已经在 App Store Connect 配置好了商品信息)。
  3. 建好后,在 Xcode 底部点 + 按钮,配置你的商品信息。
  4. 关键一步:点击 Xcode 顶部菜单 Product -> Scheme -> Edit Scheme -> Run -> Options -> StoreKit Configuration,选择你刚才创建的文件。

💡 老鸟经验:建议使用这个本地配置!它不仅能模拟购买成功,还能模拟扣费失败、退款、订阅过期等真实环境很难复现的场景。


3. 核心实战 I:获取商品与购买

我们将创建一个 StoreKitManager 类来管理所有逻辑。

3.1 获取商品信息

import StoreKit

// 定义你的商品 ID 列表
enum ProductID: String, CaseIterable {
    case proMonthly = "com.myapp.pro.monthly" // 订阅
    case removeAds = "com.myapp.remove.ads"   // 非消耗型
    case coins100 = "com.myapp.coins.100"     // 消耗型
}

@MainActor
class StoreKitManager: ObservableObject {
    @Published var products: [Product] = []
    @Published var purchasedProductIDs = Set<String>() // 已买过的 ID (非消耗/订阅)

    // 获取商品列表
    func fetchProducts() async {
        do {
            // 将 String 转换为 Set<String>
            let productIds = Set(ProductID.allCases.map { $0.rawValue })
            // 异步请求商品详情
            let fetchedProducts = try await Product.products(for: productIds)
            // 按价格排序(可选, 看实际需求)
            self.products = fetchedProducts.sorted(by: { $0.price < $1.price })

            // 加载完商品后,立即检查用户当前的购买状态
            await updateCustomerProductStatus()
        } catch {
            print("获取商品失败: \(error)")
        }
    }
}

3.2 商品信息详解

此方法主要方便调试,打印商品信息。

func displayProductInfo(_ product: Product) {
    print("━━━━━━━━━━━━━━━━━━━━━━")
    print("商品 ID: \(product.id)")
    print("名称: \(product.displayName)")
    print("描述: \(product.description)")
    print("价格: \(product.displayPrice)")  // 已格式化的价格字符串
    print("价格数值: \(product.price)")     // Decimal 类型
    print("货币代码: \(product.priceFormatStyle.currencyCode)")
    print("类型: \(product.type)")

    // 订阅专属信息
    if let subscription = product.subscription {
        print("━━━ 订阅信息 ━━━")
        print("订阅组 ID: \(subscription.subscriptionGroupID)")
        print("订阅周期: \(subscription.subscriptionPeriod)")

        // 订阅周期详解
        switch subscription.subscriptionPeriod.unit {
        case .day:
            print("周期单位: \(subscription.subscriptionPeriod.value) 天")
        case .week:
            print("周期单位: \(subscription.subscriptionPeriod.value) 周")
        case .month:
            print("周期单位: \(subscription.subscriptionPeriod.value) 月")
        case .year:
            print("周期单位: \(subscription.subscriptionPeriod.value) 年")
        @unknown default:
            break
        }

        // 介绍性优惠(新用户优惠)
        if let introOffer = subscription.introductoryOffer {
            print("新用户优惠: \(introOffer.displayPrice)")
            print("优惠类型: \(introOffer.paymentMode)")
        }
    }

    print("━━━━━━━━━━━━━━━━━━━━━━")
}

3.2 发起购买流程

StoreKit 2 的购买结果是一个枚举:success, userCancelled, pending

extension StoreKitManager {
    // 购买指定商品
    func purchase(_ product: Product) async throws {
        // 1. 发起购买请求
        let result = try await product.purchase()

        // 2. 处理结果
        switch result {
        case .success(let verification):
            // 购买成功,需要验证签名
            try await handlePurchaseVerification(verification)

        case .userCancelled:
            // 用户点击了取消
            print("User cancelled the purchase")

        case .pending:
            // 交易挂起(例如家长控制需要审批)
            print("Transaction pending")

        @unknown default:
            break
        }
    }

    // 验证与权益发放
    private func handlePurchaseVerification(_ verification: VerificationResult<Transaction>) async throws {
        switch verification {
        case .unverified(let transaction, let error):
            // 签名验证失败,不要发放权益
            print("Verification failed: \(error)")
            // 建议:结束交易,但不发货
            // 如果不 finish,这笔脏数据会每次启动 App 都发过来,卡在队列里
            await transaction.finish()

        case .verified(let transaction):
            // 验证通过
            print("Purchase verified: \(transaction.productID)")

            // 3. 发放权益(更新本地状态)
            await updateUserEntitlements(transaction)

            // 4. 重要:通知 App Store 交易已完成
            await transaction.finish()
        }
    }
}

4. 核心实战 II:交易验证与监听

StoreKit 2 有两个关键的数据源:

  1. Transaction.updates:监听实时的交易流(购买发生时、续订成功时、退款时)。
  2. Transaction.currentEntitlements:查询用户当前拥有的权益(用于恢复购买)。

4.1 监听交易更新 (Transaction Updates)

最佳实践:必须在 App 启动时立即开始监听,以处理应用在后台或未运行时发生的交易(如订阅自动续期)。

extension StoreKitManager {
    // 启动监听任务
    func listenForTransactions() -> Task<Void, Error> {
        return Task.detached {
            // 遍历异步序列
            for await result in Transaction.updates {
                do {
                    // 收到新交易(续费、购买、恢复)
                    // 这里复用之前的验证逻辑
                    try await self.handlePurchaseVerification(result)
                } catch {
                    print("Transaction update handling failed")
                }
            }
        }
    }
}

确保它随 App 启动而运行:

// 在 App 入口处调用
@main
struct MyApp: App {
    let storeKitManager = StoreKitManager()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    // 开启监听
                    await storeKitManager.listenForTransactions()
                }
        }
    }
}

4.2 检查当前权益 (Entitlements)

如何判断用户是不是会员呢? StoreKit 2,你不需要自己存本地数据库,直接调用 Transaction.currentEntitlements 来查询,它只返回当前有效的权益(过期的、退款的会自动过滤掉)。

extension StoreKitManager {
    // 更新用户权益状态
    func updateCustomerProductStatus() async {
        var purchasedIds: [String] = []

        // 遍历当前有效的权益(已自动过滤掉过期订阅、被撤销的交易)
        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                // 检查是否被撤销(退款)
                if transaction.revocationDate == nil {
                    purchasedIds.append(transaction.productID)
                }
            }
        }

        // 更新 UI 状态
        // self.isPro = purchasedIds.contains(ProductID.proMonthly.rawValue)
        print("User has active entitlements: \(purchasedIds)")
    }
}

5. 订阅管理:状态、续期与退款

订阅比一次性购买复杂,因为需要处理过期、宽限期等状态。

5.1 获取订阅详细

extension StoreKitManager {
    func checkSubscriptionStatus() async {
        // 假设我们只关心 proMonthly 这个组的订阅状态
        guard let product = products.first(where: { $0.id == ProductID.proMonthly.rawValue }) else { return }

        guard let subscriptionInfo = product.subscription else { return }

        do {
            // 获取该订阅组的状态
            let statuses = try await subscriptionInfo.status

            for status in statuses {
                switch status.state {
                case .subscribed:
                    print("用户处于订阅期")
                case .expired:
                    print("订阅已过期")
                case .inGracePeriod:
                    print("处于宽限期(扣费失败但Apple暂未关停),应视为已订阅")
                case .revoked:
                    print("订阅被撤销(退款)")
                case .inBillingRetryPeriod:
                    print("扣费重试中,通常应暂停服务")
                default:
                    break
                }

                // 获取续订信息
                if let renewalInfo = try? verify(status.renewalInfo) {
                    print("自动续订状态: \(renewalInfo.willAutoRenew)")
                    print("自动续订时间: \(renewalInfo.autoRenewalDate)")
                }
            }
        } catch {
            print("Error checking subscription status: \(error)")
        }
    }

    // 辅助泛型方法:解包 VerificationResult
    func verify<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified(_, let error):
            throw error
        case .verified(let safe):
            // ✅ 验证通过,返回解包后的数据
            return safe
        }
    }
}

5.2 识别退款 (Refunds)

Transaction.updates 收到更新,或遍历 currentEntitlements 时:

  1. 检查 transaction.revocationDate 是否不为 nil。
  2. 检查 transaction.revocationReason
if let date = transaction.revocationDate {
    print("该交易已于 \(date) 被撤销/退款")
    // 移除对应的权益
    removeEntitlement(for: transaction.productID)
}

6. 恢复购买 (Restore Purchases)

恢复购买旨在帮助用户在换新手机或重装 App 后,找回之前购买过的非消耗型商品订阅。 而且苹果审核要求必须有“恢复购买”按钮。

概念与误区

  • SK1 vs SK2: 在旧版 SK1 中,必须调用 restoreCompletedTransactions 触发系统弹窗输入密码。
  • SK2 的机制: Transaction.currentEntitlements 已经包含了用户所有的有效权益。通常情况下,应用启动时刷新这个属性,就等同于“静默恢复”。
  • AppStore.sync(): 这是 StoreKit 2 的“显式恢复”接口。只有当用户在 UI 上点击“恢复购买”按钮时,或者你确信数据未同步时,才调用它。它可能会强制弹出 Apple ID 登录框。

示例代码

extension StoreKitManager {
    // 手动恢复购买 (对应 UI 上的 Restore 按钮)
    func restorePurchases() async {
        do {
            // 1. 强制同步 App Store 交易记录
            // 这可能会通过 FaceID/TouchID 验证用户身份
            try await AppStore.sync()

            // 2. 同步完成后,重新检查权益
            await updateCustomerProductStatus()

            // 3. UI 提示
            print("Restore completed successfully")
        } catch {
            print("Restore failed: \(error)")
        }
    }
}

最佳实践

  1. 自动恢复: App 启动时调用 updateCustomerProductStatus()(遍历 currentEntitlements),不要弹窗,静默让老用户获取权益。
  2. 手动恢复: 在设置页提供 "Restore Purchases" 按钮,点击后调用 restorePurchases()
  3. UI 提示: 恢复成功后,若发现用户确实有购买记录,弹窗提示“已成功恢复高级版权益”;若没有记录,提示“未发现可恢复的购买记录”。
  4. 多设备同步: StoreKit 2 自动处理。只要登录同一个 Apple ID,currentEntitlements 会包含所有设备上的购买。

7. 营销功能:折扣与优惠 (Offers)

想给新用户“首月免费”?或者给老用户“回归半价”? StoreKit 2 支持显示推介促销(Introductory Offers)和促销代码(Offer Codes)。

7.1 优惠类型

  • 首次优惠 (Introductory Offer): 通常是针对新订阅用户的特别折扣(如:免费试用 7 天,首月半价)。
  • 促销优惠 (Promotional Offer): 一般是针对现有或回归用户的限时优惠活动,例如续订折扣、节日促销等。
  • 优惠码 (Offer Codes): 一种需要用户输入兑换码的促销方式,可针对新用户、回流用户或特定人群。

7.2 判断是否展示首购优惠

StoreKit 2 可以直接判断当前用户是否符合推介优惠(比如是否已经用过免费试用)。你不需要手写复杂的逻辑。

// 检查是否有优惠
func checkIntroOffer(for product: Product) async {
    if let subscription = product.subscription,
        let introOffer = subscription.introductoryOffer {

        // 检查用户是否有资格享受这个优惠
        // StoreKit 2 会自动根据用户历史判断 isEligible
        let isEligible = await subscription.isEligibleForIntroOffer

        if isEligible {
            if introOffer.paymentMode == .freeTrial {
                print("免费试用 \(introOffer.period.value) \(introOffer.period.unit.localizedDescription)")
            } else {
                print("首月仅需: \(introOffer.price)")
            }
        } else {
            print("原价: \(product.price)")
        }
    }
}

7.3 购买带优惠的商品

对于 首次优惠(Intro Offer),直接调用 product.purchase() 即可,系统会自动应用。
对于 促销优惠(Promotional Offer),需要在购买参数中加入签名(需要服务器生成签名,较复杂,这里不展开介绍)。 如果是 优惠码 (Offer Codes),用户通常在 App Store 系统级界面输入。这里提供一个方法,可以手动弹出兑换码输入框。

// 弹出系统兑换码输入框
func presentCodeRedemptionSheet() {
    if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
        Task {
            try? await AppStore.presentOfferCodeRedeemSheet(in: windowScene)
        }
    }
}

8. 测试指南:沙盒 (Sandbox) 与 TestFlight

8.1 沙盒测试流程

  1. 创建账号: 登录 App Store Connect -> 用户和访问 -> 沙盒 -> 新增测试员。
    • 注意:不要在 iOS 设置中登录此账号!
  2. 登录: 在 App 内点击购买时,系统弹窗要求登录,此时输入沙盒账号。
  3. 管理订阅: iOS 设置 -> App Store -> 沙盒账户 -> 管理。

沙盒环境的时间过得很快:

  • 1 个月 = 5 分钟
  • 1 年 = 1 小时
  • 注意: 订阅会自动续期 5-6 次,然后自动取消。这是为了测试完整的生命周期。

8.2 测试场景 Checklist

  • 新购: 首次购买流程是否顺畅。
  • 续期: 保持 App 打开,观察 Transaction.updates 是否收到续订通知。
  • 过期: 等待沙盒订阅自动过期,检查 App 权益是否收回。
  • 中断购买: 点击购买后,在支付界面取消,App 是否处理了 .userCancelled
  • 退款: 在沙盒设置中找不到退款?需要去 Xcode -> Debug -> StoreKit -> Manage Transactions (如果是本地配置) 或通过 App Store Connect 模拟。

8.3 调试技巧

在 Xcode 中使用 .storekit 配置文件时:

  • Debug -> StoreKit -> Manage Transactions: 可以看到所有本地交易。
  • 模拟退款: 选中交易,右键点击 "Refund Transaction"。
  • 模拟 Ask to Buy: 开启 "Enable Ask to Buy" 模拟家长审批流程。

9. 最佳实践与常见坑点

常见坑 (Pitfalls)

  1. 验证失败: 遇到 VerificationResult.unverified 怎么办?
    • 原因: 可能是越狱设备、中间人攻击或者 Xcode 本地配置证书不匹配。
    • 处理: 绝对不要解锁权益。提示用户“验证失败,请重试”。
  2. App Store Server Notifications:
    • 虽然 StoreKit 2 客户端很强,但为了数据准确性(特别是退款、续费失败),建议后端对接 Server Notifications V2。
  3. 漏单:
    • 如果 App 闪退,transaction.finish() 未调用,下次启动监听 updates 时会再次收到该交易,确保逻辑幂等(重复处理同一笔交易不会出错)。

错误处理最佳实践

enum StoreError: Error {
    case failedVerification
    case userCancelled
    case pending
    case unknown
}

// 友好的错误提示
func errorMessage(for error: Error) -> String {
    if let storeError = error as? StoreKitError {
        switch storeError {
        case .userCancelled: return "您取消了购买"
        case .networkError: return "网络连接失败,请检查网络"
        default: return "购买发生未知错误,请稍后重试"
        }
    }
    return error.localizedDescription
}

发布前 Checklist

发布前请对照这张清单:

  1. App 启动监听了吗? 确保 listenForTransactions 在最早的时机运行。
  2. Finish 所有的交易了吗? 不管成功还是失败(验证不过),都要调用 .finish(),否则队列会堵死。
  3. 是否处理了 .pending 状态(家长控制)?
  4. “恢复购买”按钮是否能正常找回权益?
  5. 是否正确处理了订阅过期和退款?
  6. 是否在 TestFlight 环境下验证过真实服务器的商品?
  7. 不要自己存 Bool 值。 尽量每次启动 App 时通过 Transaction.currentEntitlements 动态计算用户是不是 VIP。本地存个 isPro = true 很容易因为卸载重装或跨设备导致数据不一致。
  8. UI 交互。 购买过程中给个 Loading 转圈圈,不要让用户连续点击或因为网络环境以为卡住了。

10. 总结

StoreKit 2 大大降低了内购开发的门槛。核心记住三点:

  1. 监听: 全局监听 Transaction.updates。
  2. 同步: 使用 Transaction.currentEntitlements 获取当前权益。
  3. 结束: 处理完必须调用 transaction.finish()。

最后,附上一个较为完整的 Demo,地址:StoreKitDemo

昨天以前iOS

2025年11月27日年解决隐私清单导致审核总是提示二进制无效的问题

作者 90后晨仔
2025年11月27日 12:45

最新新上架一个产品,但是由于有些三方库没有隐私清单的问题导致提交到苹果后台之后总是会提示二进制无效,这里特别说明一下,如果你的app已经是线上的话,貌似没啥问题。(只是问了几个朋友),但是如果你要是新的产品,1.0上线的话那么就会因为这个导致二进制无效无法提交。

  • 提交时后苹果那边给发的邮件内容,有好几个库的警告这里就拿"AFNetworking"举例说明下解决方案。下边是警告:

Please correct the following issues and upload a new binary to App Store Connect. ITMS-91061: Missing privacy manifest - Your app includes “Frameworks/AFNetworking.framework/AFNetworking”, which includes AFNetworking, an SDK that was identified in the documentation as a commonly used third-party SDK. If a new app includes a commonly used third-party SDK, or an app update adds a new commonly used third-party SDK, the SDK must include a privacy manifest file. Please contact the provider of the SDK that includes this file to get an updated SDK version with a privacy manifest. For more details about this policy, including a list of SDKs that are required to include signatures and manifests, visit:

  • 解决方案的思路是自己在打包的时候让Trae帮我写了一个脚本然后给指定的库进行了添加。当然网上也有好多其他的解决方案,自己都尝试过了并没有起作用。脚本内容如下:

set -euo pipefail
FRAMEWORKS_DIR="${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}"
# 生成标准的 XML plist 隐私清单(不跟踪、不收集、不使用“需要理由”的 API)
write_manifest_basic() {
  dst="$1"
  mkdir -p "$(dirname "$dst")"
  cat > "$dst" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSPrivacyTracking</key>
  <false/>
  <key>NSPrivacyCollectedDataTypes</key>
  <array/>
  <key>NSPrivacyTrackingDomains</key>
  <array/>
  <key>NSPrivacyAccessedAPITypes</key>
  <array/>
</dict>
</plist>

PLIST

}


# 给指定 framework 注入隐私清单(若已有则不覆盖)

inject_manifest_basic() {

  fwdir="$1"

  dst="${fwdir}/PrivacyInfo.xcprivacy"

  if [ -f "$dst" ]; then

    echo "Already present: $(basename "$fwdir")/PrivacyInfo.xcprivacy"

  else

    write_manifest_basic "$dst"

    /usr/bin/plutil -lint "$dst"

    echo "Injected PrivacyInfo.xcprivacy into $(basename "$fwdir")"

  fi

}


# 注入后重新签名,避免签名失效

resign_framework() {

  fwdir="$1"

  if [ "${CODE_SIGNING_ALLOWED:-YES}" = "YES" ] && [ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" ]; then

    /usr/bin/codesign --force --sign "${EXPANDED_CODE_SIGN_IDENTITY}" --timestamp=none "$fwdir"

    echo "Resigned $(basename "$fwdir")"

  else

    echo "Skip resign: CODE_SIGNING_ALLOWED=${CODE_SIGNING_ALLOWED:-} EXPANDED_CODE_SIGN_IDENTITY=${EXPANDED_CODE_SIGN_IDENTITY:-}"

  fi

}

  


process_framework() {

  name="$1"

  fw="${FRAMEWORKS_DIR}/${name}"

  if [ -d "$fw" ]; then

    inject_manifest_basic "$fw"

    resign_framework "$fw"

  else

    echo "Skip ${name}: not found at ${fw}"

  fi

}

process_framework "AFNetworking.framework"

  • 具体的配置位置如下:

Snip20251127_1.png

  • 完成上边的配置之后重新打包上传就可以了,如果不放心的小伙伴可以在打包完成之后,导出.ipa的包,然后找到Frameworks这个文件夹,然后在它的下边可以找到AFNetworking.framework的文件夹然后你会看到如下图所示的文件,那么证明你添加成功了。

Snip20251127_2.png

  • 有了上边的文件之后你再次提交审核就不会出现AFNetworking这个库没有隐私清单的警告了。

iOS Audio后台模式下能否执行非Audio逻辑

作者 songgeb
2025年11月27日 11:02

测试设备:iPhone 13mini / iOS 26

验证方法

  1. 开启Audio, AirPlay, and Picture in Picture模式,播放声音
  2. 执行与声音无关的代码和网络请求逻辑,并写入日志文件,代码如下所示
  3. 让App进入后台,App持续播放声音,过一段时间(5分钟)回到App前台,检查日志文件中是否预期内容
private func test() {
    var sum = 0
    timer?.invalidate()
    timer = YYTimer(timeInterval: 1, repeats: true, block: { t in
        sum += 1
                
        // 验证代码执行
        DDLogInfo("[BackgroundTest] Timer fired. Sum: \(sum)")
                
        // 验证网络请求
        let url = URL(string: "https://www.baidu.com")!
        URLSession.shared.dataTask(with: url) { _, response, error in
            if let error = error {
                DDLogInfo("[BackgroundTest] Network failed: \(error.localizedDescription)")
            } else if let httpResponse = response as? HTTPURLResponse {
                DDLogInfo("[BackgroundTest] Network success. Status: \(httpResponse.statusCode)")
            }
        }.resume()
    })
}

日志结果:

2025-11-27 10:23:13.575 [INFO] [BackgroundTest] Timer fired. Sum: 5
2025-11-27 10:23:13.612 [INFO] [BackgroundTest] Network success. Status: 200
2025-11-27 10:23:14.008 [INFO] Report OnlineStatus background, badge number 1 // 进入后台
2025-11-27 10:23:14.582 [INFO] [BackgroundTest] Timer fired. Sum: 6
2025-11-27 10:23:14.612 [INFO] [BackgroundTest] Network success. Status: 200
2025-11-27 10:23:15.586 [INFO] [BackgroundTest] Timer fired. Sum: 7
2025-11-27 10:23:15.622 [INFO] [BackgroundTest] Network success. Status: 200
.....//省略
2025-11-27 10:28:19.578 [INFO] [BackgroundTest] Timer fired. Sum: 311
2025-11-27 10:28:19.610 [INFO] [BackgroundTest] Network success. Status: 200
2025-11-27 10:28:20.579 [INFO] [BackgroundTest] Timer fired. Sum: 312
2025-11-27 10:28:20.611 [INFO] [BackgroundTest] Network success. Status: 200
2025-11-27 10:28:21.574 [INFO] [BackgroundTest] Timer fired. Sum: 313
2025-11-27 10:28:21.602 [INFO] [BackgroundTest] Network success. Status: 200
2025-11-27 10:28:22.449 [INFO] Report OnlineStatus foreground                 // 回到前台
2025-11-27 10:28:22.573 [INFO] [BackgroundTest] Timer fired. Sum: 314
2025-11-27 10:28:22.600 [INFO] [BackgroundTest] Network success. Status: 200

结论

  • 开启Audio, AirPlay, and Picture in Picture模式,开启声音,进入后台,可以正常执行与声音无关的代码逻辑和网络请求

注:仅为测试结果,不排除系统会做任何形式的拦截或中断

Swift的Extension简单说明

作者 如此风景
2025年11月26日 20:46

Swift Extension(扩展)是 Swift 中用于给已有类型(类、结构体、枚举、协议)添加功能的核心特性,无需继承、无需修改原类型源码,在 AppDelegate.swift 中可以看到大量 extension AppDelegate { ... } 的核心原因(用于分类管理代码、遵守协议、扩展方法)。

一、核心定义

  • 作用:给任意类型(系统类型如 NSMenu、自定义类型如 AppDelegate)添加方法、计算属性、协议实现、初始化器等,实现「模块化编程」和「代码解耦」。

  • 优势

  1. 避免类体积过大(把不同功能拆分到扩展中);

  2. 无需继承即可扩展功能(比如给 StringInt 加自定义方法);

  3. 集中实现协议方法(代码更清晰);

  4. 系统类型扩展(比如给 NSMenuItem 加通用方法)。

二、基本语法


// 基础语法:扩展已有类型

extension 类型名 {

    // 要添加的功能(方法、计算属性、协议实现等)

}

  


// 带约束的扩展(比如给遵循某协议的类型扩展)

extension 类型名: 协议1, 协议2 where 泛型约束 {

    // 协议方法实现 + 自定义功能

}

  


// 示例(代码中)

extension AppDelegate: ClashProcessDelegate {

    // 实现 ClashProcessDelegate 协议方法

    func startProxyCore() { ... }

}

三、核心功能(结合 AppDelegate 代码实例)

AppDelegate.swift 中大量使用 Extension,是 Swift 模块化编程的典型实践,以下逐一拆解核心用法:

1. 遵守并实现协议(最常用场景)

给已有类扩展并遵守协议,实现协议方法,避免把所有协议方法写在类的主定义中,代码更清晰。

代码实例


// 扩展 AppDelegate 遵守 Clash 核心进程代理协议,并实现协议方法

extension AppDelegate: ClashProcessDelegate {

    func startProxyCore() { ... } // 协议方法:启动核心

    func clashLaunchPathNotFound(_ msg: String) { ... } // 协议方法:处理路径不存在

}

  


// 扩展 AppDelegate 遵守菜单代理协议,实现菜单更新/高亮逻辑

extension AppDelegate: NSMenuDelegate {

    func menuNeedsUpdate(_ menu: NSMenu) { ... } // 菜单即将显示时更新

    func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) { ... } // 菜单项高亮

}

核心价值:把「协议实现」和「类核心逻辑」分离,AppDelegate 主定义只保留属性,协议方法集中在扩展中,便于维护。

2. 分类管理类方法(按功能拆分)

把类的不同功能(如「主菜单项点击事件」「配置操作」「崩溃处理」)拆分到不同扩展中,用 // MARK: 标记,代码结构一目了然。

代码实例


// MARK: Main actions - 主菜单项点击事件扩展

extension AppDelegate {

    @IBAction func actionDashboard(_ sender: NSMenuItem?) { ... } // 仪表盘点击

    @IBAction func actionQuit(_ sender: Any) { ... } // 退出点击

}

  


// MARK: Config actions - 配置相关操作扩展

extension AppDelegate {

    @IBAction func openConfigFolder(_ sender: Any) { ... } // 打开配置文件夹

    @IBAction func actionUpdateConfig(_ sender: AnyObject) { ... } // 重载配置

}

  


// MARK: crash hanlder - 崩溃处理扩展

extension AppDelegate {

    func registCrashLogger() { ... } // 注册崩溃日志

    func failLaunchProtect() { ... } // 启动失败保护

}

核心价值

  • 避免 AppDelegate 主定义上千行代码,按功能模块化;

  • 查找功能时直接定位对应 MARK 扩展,无需翻找整个类。

3. 扩展实例/类方法

给任意类型添加自定义方法(系统类型/自定义类型均可),比如给 String 加「验证URL」方法,给 AppDelegate 加「重置代理」方法。

示例(通用场景)


// 扩展系统类型:给 String 加 URL 验证方法

extension String {

    func isValidURL() -> Bool {

        return URL(string: self) != nil

    }

}

  


// 代码中:给 AppDelegate 加实例方法(重置代理)

extension AppDelegate {

    @objc func resetProxySettingOnWakeupFromSleep() { ... } // 睡眠唤醒后重置代理

    @objc func healthCheckOnNetworkChange() { ... } // 网络变化时健康检查

}

4. 扩展计算属性(注意:不能加存储属性)

Extension 可以添加「计算属性」(只读/读写),但不能添加存储属性var xxx: Int = 0 这类带内存占用的属性),因为扩展不允许修改类型的内存布局。

示例


// 扩展 NSMenuItem 加计算属性:是否为代理模式项

extension NSMenuItem {

    var isProxyModeItem: Bool {

        get {

            return self.identifier?.rawValue == "proxyModeItem"

        }

    }

}

  


// 扩展 Int 加计算属性:转文件大小字符串(KB/MB)

extension Int {

    var fileSizeString: String {

        if self < 1024 {

            return "\(self) B"

        } else if self < 1024 * 1024 {

            return "\(Double(self)/1024) KB"

        } else {

            return "\(Double(self)/(1024*1024)) MB"

        }

    }

}

5. 扩展初始化器

给值类型(结构体、枚举)或类添加自定义初始化器,补充原类型的初始化逻辑。

示例


// 扩展自定义结构体:添加便捷初始化器

struct ProxyConfig {

    var port: Int

    var ip: String

}

  


extension ProxyConfig {

    // 便捷初始化器:默认IP为127.0.0.1

    init(port: Int) {

        self.port = port

        self.ip = "127.0.0.1"

    }

}

  


// 使用

let config = ProxyConfig(port: 7890) // ip 自动为 127.0.0.1

6. 带泛型约束的扩展

给泛型类型(如 Array)添加约束扩展,仅对满足条件的泛型生效。

示例


// 仅对元素为 Int 的 Array 扩展求和方法

extension Array where Element == Int {

    func sum() -> Int {

        return reduce(0, +)

    }

}

  


let numbers = [1,2,3]

print(numbers.sum()) // 6

四、关键注意事项(避坑)

  1. 不能添加存储属性  

   Extension 只能加「计算属性」,不能加 var xxx: Int = 0 这类存储属性(Swift 设计限制,避免破坏原类型的内存布局)。

   ❌ 错误:


extension AppDelegate {

    var test: Int = 0 // 编译报错:Extensions may not contain stored properties

}

   ✅ 正确(计算属性):


extension AppDelegate {

    var isProxyRunning: Bool {

        return ConfigManager.shared.isRunning

    }

}

  1. 不能重写原类型的方法  

   Extension 只能添加新方法,不能重写类原有方法(重写需用继承)。

  1. 协议扩展的优先级  

   如果类和扩展都实现了协议方法,类的主定义方法优先级更高;如果多个扩展实现同一方法,编译报错(歧义)。

  1. @objc 兼容  

   给 Objective-C 兼容类型(如 NSObject 子类)扩展的方法,若需被 OC 调用(如 @IBAction、代理方法),需加 @objc


extension AppDelegate {

    @objc func handleURL(event: NSAppleEventDescriptor, reply: NSAppleEventDescriptor) { ... }

}

  1. 静态方法/属性扩展  

   可给类型添加静态方法/计算属性:


extension AppDelegate {

    static let appVersion = AppVersionUtil.currentVersion

    static func logLaunchInfo() {

        Logger.log("Version: \(appVersion)")

    }

}

五、实战场景总结(结合 AppDelegate 代码)

AppDelegate.swift 是 Extension 最佳实践,核心场景:

扩展类型 作用  示例代码位置 
协议实现扩展            分离协议方法,解耦核心逻辑            extension AppDelegate: NSMenuDelegate
功能分类扩展            按业务拆分方法(如配置、菜单、崩溃)  // MARK: Config actions 扩展       
@objc 方法扩展          兼容 OC 运行时(如 URL Scheme 处理) @objc func handleURL(...)          
事件处理扩展            集中管理 IBAction 点击事件            // MARK: Main actions 扩展         

六、扩展 vs 继承(补充)

很多新手会混淆扩展和继承,两者核心区别:

特性         Extension(扩展) 继承(Inheritance)
核心目的     给已有类型添加功能                    基于父类创建子类,重写/扩展功能      
内存布局     不修改原类型内存                      子类有独立内存布局                   
方法重写     不支持                                支持重写父类方法                     
存储属性     不支持                                支持添加存储属性                     
耦合度       低(无需关联原类型源码) 高(子类依赖父类)

总结

Swift Extension 是「模块化编程」的核心,AppDelegate 代码通过扩展可以实现:

  1. 协议方法与核心逻辑分离;

  2. 按业务功能拆分代码(配置、菜单、崩溃、网络等);

  3. 兼容 OC 运行时(@objc 方法);

  4. 扩展自定义方法(如 startProxyCoreresetStreamApi)。

掌握 Extension 的核心是「拆分功能、解耦代码、不入侵原类型」,这也是 Swift 推崇的「组合优于继承」设计思想的体现。

打个广告,帮忙招一个iOS开发的扛把子~

作者 iOS研究院
2025年11月26日 18:08

打破 35 + 职业魔咒|AI 出海创业梦之队诚招 iOS 技术负责人

我们拒绝「35 岁职场干电池」标签,坚信经验是最宝贵的财富 —— 诚邀深耕 iOS 领域的技术大佬,与我们并肩开拓 AI 出海新赛道,在碰撞中创新,在实战中共同成长!

关于我们:无短板的出海「六边形战士」梦之队

  • 核心成员均来自陌陌、米可、莱熙等一线出海团队,深耕泛娱乐赛道多年,打造过多个非游出海明星产品;
  • 运营端手握千万级优质资源,技术核心源自红客联盟,擅长落地黑科技创新玩法;
  • 市场团队是流量运营专家,仅靠出海 0-1 阶段顾问服务,不到两年便实现年营收破百万;
  • 项目已跑通商业闭环,数据表现亮眼,无需依赖融资即可稳定自造血,创业路上底气十足。

我们需要这样的你:iOS 技术领路人

岗位职责

  1. 主导搭建创业公司 iOS 技术体系,负责 AI 驱动型 App 核心架构设计与关键模块开发,深度集成 OpenAI 等第三方 AI 服务;
  2. 攻克海外业务适配难题:完成多语言本地化落地,合规适配 GDPR/CCPA 等海外法规,解决跨地区网络稳定性问题;
  3. 统筹海外 App Store 上架全流程,精准解读审核规则,保障版本顺利上线,高效排查线上突发问题;
  4. 搭建轻量化工程化流程,聚焦 App 启动速度、崩溃率等核心指标,实现性能攻坚与优化。

任职要求

  1. 本科及以上学历,5-10 年 iOS 开发经验,有创业公司或海外 App 完整开发 / 落地经历;
  2. 精通 Swift/Objective-C 及 iOS 核心框架,具备扎实的架构设计能力与复杂项目把控经验;
  3. 有 AI 服务移动端集成实战经验,熟悉接口调用逻辑与数据处理全流程;
  4. 深谙海外 iOS 生态,对 App Store 审核规则、海外合规要求有清晰认知;
  5. 适应创业快节奏,能快速响应并解决性能优化、跨地区适配等复杂技术问题。

加分项

  • 主导过 AI 驱动型 App 海外上架,成功落地美区、欧区等核心市场;
  • 有海外合规改造或性能优化标杆案例,能提供明确数据成果(如崩溃率降低 X%、启动速度提升 X%);
  • 熟悉 Stripe/PayPal 支付集成、Firebase 等海外常用第三方服务,或具备 Flutter 混合开发经验。

投递须知

  1. 工作地点:北京(可出厂开发优先考虑),技术过硬可以接受远程 / 异地;
  2. 为高效匹配,确保你对出海 AI 赛道有强烈意愿,且符合上述核心要求后再投递;
  3. 简历投递邮箱:1689630415@qq.com,邮件主题建议注明「iOS 技术负责人 + 姓名 + 工作年限」。

我们不设年龄焦虑,只看能力与潜力;这里没有层级束缚,只有并肩作战的伙伴。期待你加入,成为我们不可或缺的核心力量,一起在 AI 出海赛道共创下一个爆款!

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

相关推荐

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

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

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

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

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

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

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

Swift UI 状态管理

作者 Haha_bj
2025年11月26日 17:56

一、@State State修饰的属性是值传递

SwiftUI管理声明为state的存储属性。当值发生变化时,SwiftUI会更新视图层次结构中依赖于该值的部分。对这个属性进行赋值的操作将会触发 View 的刷新,它的 body 会被再次调用,底层渲染引擎会找出界面上被改变的部分,根据新的属性值计算出新的 View,并进行刷新。

struct JLStateView: View {
    @State var count = 0
    var body: some View {
        VStack {
            Text("\(count)")
            Button("按钮点击加1") {
                count += 1
            }
            .background(.orange)
            
        }
    }
}

通过@State定义变量count,点击按钮会触发Text中数字的显示

  • 不要在视图层次结构中实例化视图的位置初始化视图的状态属性,因为这可能与SwiftUI提供的存储管理冲突。

  • 为了避免这种情况,总是将state声明为private,并将其放在视图层次结构中需要访问该值的最高视图中。

@State private var count = 0

二、@Binding

@State修饰的属性是值传递,因此在父视图和子视图之间传递属性时。子视图针对属性的修改无法传递到父视图上。

Binding修饰后会将属性会变为一个引用类型,视图之间的传递从值传递变为了引用传递,将父视图和子视图的属性关联起来。这样子视图针对属性的修改,会传递到父视图上。

需要在属性名称前加上一个美元符号$来获得这个值。

被声明为 @Binding 的属性进行赋值,改变的将不是属性本身,而是它的引用,这个改变将被向外传递.

import SwiftUI
struct JLBtnView: View {
    @Binding var isShowText: Bool
    var body: some View {
        Button("按钮点击") {
            isShowText.toggle()
        }
    }
}

struct JLContentView: View {
    @State private var isShowText: Bool = true
    var body: some View {
        VStack {
            if isShowText{
                Text("点击后会被隐藏")
            }else{
                Text("点击后会被显示")
            }
            /// $isShowText 双向绑定
            JLBtnView(isShowText: $isShowText)
        }
    }
}
  • 按钮在JLBtnView视图中,并且通过点击,修改isShowText的值。

  • 将jLBtnView视图添加到JLContentView上作为它的子视图。并且传入isShowText。

  • 此时的传值是指针传递,会将点击后的属性值传递到父视图上。

  • 父视图拿到后也作用在自己的属性,因此他的文本视图会依据该属性而隐藏或显示

  • 如果将@Binding改为@State,会发现点击后不起作用。这是因为值传递子视图的更改不会反映到父视图上

struct JLContentView: View {
    @State private var name: String = ""
    var body: some View {
        VStack {
            TextField("请输入您的名字",text: $name)
            Text(name)
            
        }
    }
}
  • 在文本输入框中输入的数据,就会传入到name中

  • 同时name又绑定在文本视图上,所以会将文本输入框输入的文本显示到文本视图上

  • 这就是数据绑定的快捷实现。

三、@ObservedObject

如果说 @State 是全自动驾驶的话,ObservableObject 就是半自动,它需要一些额外的声明。ObservableObject 协议要求实现类型是 class,它只有一个需要实现的属性:objectWillChange。在数据将要发生改变时,这个属性用来向外进行“广播”,它的订阅者 (一般是 View 相关的逻辑) 在收到通知后,对 View 进行刷新。
创建 ObservableObject 后,实际在 View 里使用时,我们需要将它声明为 @ObservedObject。这也是一个属性包装,它负责通过订阅 objectWillChange 这个“广播”,将具体管理数据的 ObservableObject 和当前的 View 关联起来。

  • 绑定的数据是一个对象。

  • 被修饰的对象,其类必须遵守ObservableObject协议

  • 此时这个类中被@Published修饰的属性都会被绑定

  • 使用@ObservedObject修饰这个对象,绑定这个对象。

  • 被@Published修饰的属性发生改变时,SwiftUI就会进行更新。

import SwiftUI
internal import Combine

class Persion: ObservableObject{
    /// 属性只有被@Published修饰时,属性的值修改时,才能被监听到
    @Published var name = ""
}

struct JLContentView: View {
    @ObservedObject var p = Persion()
    var body: some View {
        VStack {
            Text(p.name)
                .padding()
            Button("修改") {
                p.name = "哈哈"
            }
            
        }
    }
}

@ObservedObject修饰的必须是遵守ObservableObject 协议的class对象
class对象的属性只有被@Published修饰时,属性的值修改时,才能被监听到

四、@EnvironmentObject

在多视图中,为了避免数据的无效传递,可以直接将数据放到环境中,供多个视图进行使用

在 SwiftUI 中,View 提供了 environmentObject( 方法,来把某个 ObservableObject 的值注入到当前 View 层级及其子层级中去。在这个 View 的子层级中,可以使用 @EnvironmentObject 来直接获取这个绑定的环境值。

extension View {

    @inlinable nonisolated public func environmentObject<T>(_ object: T) -> some View where T : ObservableObject
}

final class Persion: ObservableObject{
    @Published var name = "哈哈"
}
struct MapView: View {
    @EnvironmentObject var p : Persion
    var body: some View {
        VStack {
            Text(p.name)
            Button("点击") {
                p.name = "呵呵"
            }
        }
    }
}

struct JLContentView: View {
    
    var body: some View {
        VStack {
            let p = Persion()
            MapView().environmentObject(p)
        }
    }
}

@EnvironmentObject 修饰器是针对全局环境的。通过它,我们可以避免在初始 View 时创建 ObservableObject, 而是从环境中获取 ObservableObject
可以看出我们获取 p这个 ObservableObject 是通过 @EnvironmentObject 修饰器,但是在入口需要传入 .environmentObject(p) 。@EnvironmentObject 的工作方式是在 Environment 查找 Person 实例。

import SwiftUI
internal import Combine

final class Persion: ObservableObject{
    @Published var name = "哈哈"
}

struct EnvView: View {
    @EnvironmentObject var p : Persion
    var body: some View {
        Text(p.name)
    }
}

struct BtnView: View {
    @EnvironmentObject var p: Persion
    var body: some View {
        Text(p.name)
        Button("修改") {
            p.name = "1123"
        }
    }
}


struct JLContentView: View {
    let p = Persion()
    var body: some View {
        VStack {
            EnvView().environmentObject(p)
            BtnView().environmentObject(p)
        }
    }
}
  • 给属性添加@EnvironmentObject修改,就将其放到了环境中。

  • 其他视图中想要获取该属性,可以通过.environmentObject从环境中获取。

  • 可以看到分别将EnvView和BtnvView的属性分别放到了环境中

  • 之后我们ContentView视图中获取数据时,可以直接通过环境获取。

  • 不需要将数据传递到ContentView,而是直接通过环境获取,这样避免了无效的数据传递,更加高效

  • 如果是在多层级视图之间进行传递,会有更明显的效果。

import SwiftUI
internal import Combine

final class Persion: ObservableObject{
    @Published var name = 1
    deinit{
        print("被销毁了")
    }
}

struct MapView: View {
    @ObservedObject var p = Persion()
    var body: some View {
        VStack{
            Text("\(p.name)")
            Button("+1") { //添加一个按钮,指定标题文字为 First button
                p.name += 1
            }
        
        }
    }
}

struct JLContentView: View {
    @State var count = 0
    var body: some View {
        VStack {
            Text("刷新:\(count)")
            Button("刷新"){
                count += 1
            }
            
            MapView()

        }
    }
}

点击刷新时,Person 的deinit方法被调用,说明p对象被销毁;
先连续点击+1,Text上的数字在一直递增,当点击刷新时Text上的数字恢复为1,这个现象也说明p对象被销毁

import SwiftUI
internal import Combine


final class Persion: ObservableObject{
    @Published var name = 1
    deinit{
        print("被销毁了")
    }
}

struct MapView: View {
    
    @StateObject var p = Persion()
    var body: some View {
        VStack{
            Text("\(p.name)")
            Button("+1") { //添加一个按钮,指定标题文字为 First button
                p.name += 1
            }
        
        }
    }
}

struct JLContentView: View {
    @State var count = 0
    var body: some View {
        VStack {
            Text("刷新:\(count)")
            Button("刷新"){
                count += 1
            }
            
            MapView()

        }
    }
}

和例1不同的是怎么操作,p都不会销毁

@StateObject的声明周期与当前所在View生命周期保持一致,即当View被销毁后,StateObject的数据销毁,当View被刷新时,StateObject的数据会保持;而ObservedObject不被View持有,生命周期不一定与View一致,即数据可能被保持或者销毁;

《Flutter全栈开发实战指南:从零到高级》- 19 -手势识别

2025年11月26日 11:40

引言

在移动应用开发中,流畅自然的手势交互是提升用户体验的关键。今天我们来深入探讨Flutter中的手势识别,带你从0-1掌握这个强大的交互工具。

1. GestureDetector

1.1 GestureDetector原理

下面我们先通过一个架构图来加深理解GestureDetector的工作原理:

graph TB
    A[触摸屏幕] --> B[RawPointerEvent事件产生]
    B --> C[GestureDetector接收事件]
    C --> D[手势识别器分析]
    D --> E{匹配手势类型}
    E -->|匹配成功| F[触发对应回调]
    E -->|匹配失败| G[事件传递给其他组件]
    F --> H[更新UI状态]
    G --> I[父组件处理]

核心原理解析:

  1. 事件传递机制

    • Flutter使用冒泡机制传递触摸事件
    • 从最内层组件开始,向外层组件传递
    • 每个GestureDetector都可以拦截和处理事件
  2. 多手势竞争

    • 多个手势识别器竞争处理同一组触摸事件
    • 通过规则决定哪个识别器获胜
    • 获胜者将处理后续的所有相关事件
  3. 命中测试

    • 确定触摸事件发生在哪个组件上
    • 通过HitTestBehavior控制测试行为

1.2 基础手势识别

下面演示一个基础手势识别案例:

class BasicGestureExample extends StatefulWidget {
  @override
  _BasicGestureExampleState createState() => _BasicGestureExampleState();
}

class _BasicGestureExampleState extends State<BasicGestureExample> {
  String _gestureStatus = '等待手势...';
  Color _boxColor = Colors.blue;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('基础手势识别')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // 手势检测区域
            GestureDetector(
              onTap: () {
                setState(() {
                  _gestureStatus = '单击 detected';
                  _boxColor = Colors.green;
                });
              },
              onDoubleTap: () {
                setState(() {
                  _gestureStatus = '双击 detected';
                  _boxColor = Colors.orange;
                });
              },
              onLongPress: () {
                setState(() {
                  _gestureStatus = '长按 detected';
                  _boxColor = Colors.red;
                });
              },
              onPanUpdate: (details) {
                setState(() {
                  _gestureStatus = '拖拽中: ${details.delta}';
                  _boxColor = Colors.purple;
                });
              },
              onScaleUpdate: (details) {
                setState(() {
                  _gestureStatus = '缩放: ${details.scale.toStringAsFixed(2)}';
                  _boxColor = Colors.teal;
                });
              },
              child: Container(
                width: 200,
                height: 200,
                decoration: BoxDecoration(
                  color: _boxColor,
                  borderRadius: BorderRadius.circular(16),
                  boxShadow: [
                    BoxShadow(
                      color: Colors.black26,
                      blurRadius: 10,
                      offset: Offset(0, 4),
                    )
                  ],
                ),
                child: Icon(
                  Icons.touch_app,
                  color: Colors.white,
                  size: 50,
                ),
              ),
            ),
            SizedBox(height: 30),
            // 状态显示
            Container(
              padding: EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: Colors.grey[100],
                borderRadius: BorderRadius.circular(8),
              ),
              child: Text(
                _gestureStatus,
                style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
              ),
            ),
            SizedBox(height: 20),
            // 手势说明
            _buildGestureInstructions(),
          ],
        ),
      ),
    );
  }

  Widget _buildGestureInstructions() {
    return Container(
      padding: EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _buildInstructionItem('单击', '快速点击一次'),
          _buildInstructionItem('双击', '快速连续点击两次'),
          _buildInstructionItem('长按', '按住不放'),
          _buildInstructionItem('拖拽', '按住并移动'),
          _buildInstructionItem('缩放', '双指捏合或展开'),
        ],
      ),
    );
  }

  Widget _buildInstructionItem(String gesture, String description) {
    return Padding(
      padding: EdgeInsets.symmetric(vertical: 8),
      child: Row(
        children: [
          Text(gesture, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
          SizedBox(width: 16),
          Text(description, style: TextStyle(fontSize: 14, color: Colors.grey[600])),
        ],
      ),
    );
  }
}

1.3 手势识别器类型总结

下面我们总结下手势识别器都包含哪些类型,并了解各种手势识别器的特性:

手势类型 识别器 触发条件 应用场景
点击 onTap 快速触摸释放 按钮点击、项目选择
双击 onDoubleTap 快速连续两次点击 图片放大/缩小、点赞
长按 onLongPress 长时间按住 显示上下文菜单、拖拽准备
拖拽 onPanUpdate 按住并移动 滑动删除、元素拖拽
缩放 onScaleUpdate 双指捏合/展开 图片缩放、地图缩放
垂直拖拽 onVerticalDragUpdate 垂直方向拖拽 滚动列表、下拉刷新
水平拖拽 onHorizontalDragUpdate 水平方向拖拽 页面切换、轮播图

1.4 多手势间竞争规则

我们先来演示下不同手势的触发效果 在这里插入图片描述

  • 竞争规则

竞争核心规则.png

2. 拖拽与缩放

2.1 实现原理

拖拽功能的实现基于以下事件序列:

sequenceDiagram
    participant U as 用户
    participant G as GestureDetector
    participant S as State
    
    U->>G: 手指按下 (onPanStart)
    G->>S: 记录起始位置
    Note over S: 设置_dragging = true
    
    loop 拖拽过程
        U->>G: 手指移动 (onPanUpdate)
        G->>S: 更新位置数据
        S->>S: setState() 触发重建
        Note over S: 根据delta更新坐标
    end
    
    U->>G: 手指抬起 (onPanEnd)
    G->>S: 结束拖拽状态
    Note over S: 设置_dragging = false

2.2 拖拽功能

下面是拖拽功能核心代码实现:

class DraggableBox extends StatefulWidget {
  @override
  _DraggableBoxState createState() => _DraggableBoxState();
}

class _DraggableBoxState extends State<DraggableBox> {
  // 位置状态
  double _positionX = 0.0;
  double _positionY = 0.0;
  
  // 拖拽状态
  bool _isDragging = false;
  double _startX = 0.0;
  double _startY = 0.0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('拖拽盒子')),
      body: Stack(
        children: [
          // 背景网格
          _buildBackgroundGrid(),
          
          // 拖拽盒子
          Positioned(
            left: _positionX,
            top: _positionY,
            child: GestureDetector(
              onPanStart: _handlePanStart,
              onPanUpdate: _handlePanUpdate,
              onPanEnd: _handlePanEnd,
              child: AnimatedContainer(
                duration: Duration(milliseconds: 100),
                width: 120,
                height: 120,
                decoration: BoxDecoration(
                  color: _isDragging ? Colors.blue[700] : Colors.blue[500],
                  borderRadius: BorderRadius.circular(12),
                  boxShadow: _isDragging ? [
                    BoxShadow(
                      color: Colors.black.withOpacity(0.3),
                      blurRadius: 15,
                      offset: Offset(0, 8),
                    )
                  ] : [
                    BoxShadow(
                      color: Colors.black.withOpacity(0.2),
                      blurRadius: 8,
                      offset: Offset(0, 4),
                    )
                  ],
                  border: Border.all(
                    color: Colors.white,
                    width: 2,
                  ),
                ),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Icon(
                      _isDragging ? Icons.touch_app : Icons.drag_handle,
                      color: Colors.white,
                      size: 40,
                    ),
                    SizedBox(height: 8),
                    Text(
                      _isDragging ? '拖拽中...' : '拖拽我',
                      style: TextStyle(color: Colors.white),
                    ),
                  ],
                ),
              ),
            ),
          ),
          
          // 位置信息
          Positioned(
            bottom: 20,
            left: 20,
            child: Container(
              padding: EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: Colors.black.withOpacity(0.7),
                borderRadius: BorderRadius.circular(8),
              ),
              child: Text(
                '位置: (${_positionX.toStringAsFixed(1)}, '
                    '${_positionY.toStringAsFixed(1)})',
                style: TextStyle(color: Colors.white),
              ),
            ),
          ),
        ],
      ),
    );
  }

  void _handlePanStart(DragStartDetails details) {
    setState(() {
      _isDragging = true;
      _startX = details.globalPosition.dx - _positionX;
      _startY = details.globalPosition.dy - _positionY;
    });
  }

  void _handlePanUpdate(DragUpdateDetails details) {
    setState(() {
      _positionX = details.globalPosition.dx - _startX;
      _positionY = details.globalPosition.dy - _startY;
      
      // 限制在屏幕范围内
      final screenWidth = MediaQuery.of(context).size.width;
      final screenHeight = MediaQuery.of(context).size.height;
      
      _positionX = _positionX.clamp(0.0, screenWidth - 120);
      _positionY = _positionY.clamp(0.0, screenHeight - 200);
    });
  }

  void _handlePanEnd(DragEndDetails details) {
    setState(() {
      _isDragging = false;
    });
  }

  Widget _buildBackgroundGrid() {
    return Container(
      width: double.infinity,
      height: double.infinity,
      child: CustomPaint(
        painter: _GridPainter(),
      ),
    );
  }
}

class _GridPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.grey[300]!
      ..strokeWidth = 1.0
      ..style = PaintingStyle.stroke;

    // 绘制网格
    const step = 40.0;
    for (double x = 0; x < size.width; x += step) {
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
    }
    for (double y = 0; y < size.height; y += step) {
      canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

2.3 缩放功能

缩放功能涉及到矩阵变换,下面是核心代码实现:

class ZoomableImage extends StatefulWidget {
  final String imageUrl;
  
  const ZoomableImage({required this.imageUrl});

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

class _ZoomableImageState extends State<ZoomableImage> {
  // 变换控制器
  Matrix4 _transform = Matrix4.identity();
  Matrix4 _previousTransform = Matrix4.identity();
  
  // 缩放限制
  final double _minScale = 0.5;
  final double _maxScale = 4.0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('可缩放图片')),
      body: Center(
        child: GestureDetector(
          onScaleStart: _onScaleStart,
          onScaleUpdate: _onScaleUpdate,
          onDoubleTap: _onDoubleTap,
          child: Transform(
            transform: _transform,
            child: Container(
              width: 300,
              height: 300,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(12),
                boxShadow: [
                  BoxShadow(
                    color: Colors.black26,
                    blurRadius: 10,
                    offset: Offset(0, 4),
                  )
                ],
                image: DecorationImage(
                  image: NetworkImage(widget.imageUrl),
                  fit: BoxFit.cover,
                ),
              ),
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _resetTransform,
        child: Icon(Icons.refresh),
      ),
    );
  }

  void _onScaleStart(ScaleStartDetails details) {
    _previousTransform = _transform;
  }

  void _onScaleUpdate(ScaleUpdateDetails details) {
    setState(() {
      // 计算新的缩放比例
      double newScale = _getScale(_previousTransform) * details.scale;
      newScale = newScale.clamp(_minScale, _maxScale);
      
      // 创建变换矩阵
      _transform = Matrix4.identity()
        ..scale(newScale)
        ..translate(
          details.focalPoint.dx / newScale - details.localFocalPosition.dx,
          details.focalPoint.dy / newScale - details.localFocalPosition.dy,
        );
    });
  }

  void _onDoubleTap() {
    setState(() {
      // 双击切换原始大小和放大状态
      final currentScale = _getScale(_transform);
      final targetScale = currentScale == 1.0 ? 2.0 : 1.0;
      
      _transform = Matrix4.identity()..scale(targetScale);
    });
  }

  void _resetTransform() {
    setState(() {
      _transform = Matrix4.identity();
    });
  }

  double _getScale(Matrix4 matrix) {
    // 从变换矩阵中提取缩放值
    return matrix.getMaxScaleOnAxis();
  }
}

3. 手势冲突解决

3.1 手势冲突类型分析

手势冲突主要分为三种类型,我们可以用下面的UML图来表示:

classDiagram
    class GestureConflict {
        <<enumeration>>
        ParentChild
        Sibling
        SameType
    }
    
    class ParentChildConflict {
        +String description
        +Solution solution
    }
    
    class SiblingConflict {
        +String description
        +Solution solution
    }
    
    class SameTypeConflict {
        +String description
        +Solution solution
    }
    
    GestureConflict <|-- ParentChildConflict
    GestureConflict <|-- SiblingConflict
    GestureConflict <|-- SameTypeConflict

具体冲突类型说明:

  1. 父子组件冲突

    • 现象:父组件和子组件都有相同类型的手势识别
    • 案例:可点击的卡片中包含可点击的按钮
    • 解决方法:使用HitTestBehavior控制事件传递
  2. 兄弟组件冲突

    • 现象:相邻组件的手势区域重叠
    • 案例:两个重叠的可拖拽元素
    • 解决方法:使用Listener精确控制事件处理
  3. 同类型手势冲突

    • 现象:同一组件注册了多个相似手势
    • 案例:同时监听点击和双击
    • 解决方法:设置手势识别优先级

3.2 冲突解决具体方案

方案1:使用HitTestBehavior
class HitTestBehaviorExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GestureDetector(
        // 父组件手势
        onTap: () => print('父组件点击'),
        behavior: HitTestBehavior.translucent, // 关键设置
        child: Container(
          color: Colors.blue[100],
          padding: EdgeInsets.all(50),
          child: GestureDetector(
            // 子组件手势
            onTap: () => print('子组件点击'),
            child: Container(
              width: 200,
              height: 200,
              color: Colors.red[100],
              child: Center(child: Text('点击测试区域')),
            ),
          ),
        ),
      ),
    );
  }
}
方案2:使用IgnorePointer和AbsorbPointer
class PointerControlExample extends StatefulWidget {
  @override
  _PointerControlExampleState createState() => _PointerControlExampleState();
}

class _PointerControlExampleState extends State<PointerControlExample> {
  bool _ignoreChild = false;
  bool _absorbPointer = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('指针控制案例')),
      body: Column(
        children: [
          // 控制面板
          _buildControlPanel(),
          
          Expanded(
            child: Stack(
              children: [
                // 底层组件
                GestureDetector(
                  onTap: () => print('底层组件被点击'),
                  child: Container(
                    color: Colors.blue[200],
                    child: Center(child: Text('底层组件')),
                  ),
                ),
                
                // 根据条件包装子组件
                if (_ignoreChild)
                  IgnorePointer(
                    child: _buildTopLayer('IgnorePointer'),
                  )
                else if (_absorbPointer)
                  AbsorbPointer(
                    child: _buildTopLayer('AbsorbPointer'),
                  )
                else
                  _buildTopLayer('正常模式'),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildControlPanel() {
    return Container(
      padding: EdgeInsets.all(16),
      color: Colors.grey[100],
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          ElevatedButton(
            onPressed: () => setState(() {
              _ignoreChild = false;
              _absorbPointer = false;
            }),
            child: Text('正常'),
          ),
          ElevatedButton(
            onPressed: () => setState(() {
              _ignoreChild = true;
              _absorbPointer = false;
            }),
            child: Text('IgnorePointer'),
          ),
          ElevatedButton(
            onPressed: () => setState(() {
              _ignoreChild = false;
              _absorbPointer = true;
            }),
            child: Text('AbsorbPointer'),
          ),
        ],
      ),
    );
  }

  Widget _buildTopLayer(String mode) {
    return Positioned(
      bottom: 50,
      right: 50,
      child: GestureDetector(
        onTap: () => print('顶层组件被点击 - $mode'),
        child: Container(
          width: 200,
          height: 150,
          color: Colors.red[200],
          child: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text('顶层组件'),
                Text('模式: $mode', style: TextStyle(fontWeight: FontWeight.bold)),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

4. 自定义手势识别

4.1 架构图

自定义手势识别器的实现基于以下类结构:

graph TD
    A[GestureRecognizer] --> B[OneSequenceGestureRecognizer]
    B --> C[自定义识别器]
    
    C --> D[addPointer]
    C --> E[handleEvent]
    C --> F[resolve]
    
    D --> G[开始跟踪指针]
    E --> H[处理事件序列]
    F --> I[决定竞争结果]
    
    H --> J{Ptr Down}
    H --> K{Ptr Move}
    H --> L{Ptr Up}
    
    J --> M[记录起始状态]
    K --> N[更新手势数据]
    L --> O[触发最终回调]

4.2 实现自定义滑动手势

// 自定义滑动手势
class SwipeGestureRecognizer extends OneSequenceGestureRecognizer {
  final VoidCallback? onSwipeLeft;
  final VoidCallback? onSwipeRight;
  final VoidCallback? onSwipeUp;
  final VoidCallback? onSwipeDown;
  
  // 配置参数
  static const double _minSwipeDistance = 50.0;    // 最小滑动距离
  static const double _minSwipeVelocity = 100.0;   // 最小滑动速度
  
  // 状态变量
  Offset? _startPosition;
  Offset? _currentPosition;
  int? _trackedPointer;
  DateTime? _startTime;

  @override
  void addPointer(PointerDownEvent event) {
    print('跟踪指针: ${event.pointer}');
    
    startTrackingPointer(event.pointer);
    _startPosition = event.position;
    _currentPosition = event.position;
    _trackedPointer = event.pointer;
    _startTime = DateTime.now();
    
    // 声明参与竞争
    resolve(GestureDisposition.accepted);
  }

  @override
  void handleEvent(PointerEvent event) {
    if (event.pointer != _trackedPointer) return;
    
    if (event is PointerMoveEvent) {
      _currentPosition = event.position;
    } else if (event is PointerUpEvent) {
      _evaluateSwipe();
      stopTrackingPointer(event.pointer);
      _reset();
    } else if (event is PointerCancelEvent) {
      stopTrackingPointer(event.pointer);
      _reset();
    }
  }

  void _evaluateSwipe() {
    if (_startPosition == null || _currentPosition == null || _startTime == null) {
      return;
    }

    final offset = _currentPosition! - _startPosition!;
    final distance = offset.distance;
    final duration = DateTime.now().difference(_startTime!);
    final velocity = distance / duration.inMilliseconds * 1000;

    print('滑动评估 - 距离: ${distance.toStringAsFixed(1)}, '
        '速度: ${velocity.toStringAsFixed(1)}, 方向: $offset');

    // 检查是否达到滑动阈值
    if (distance >= _minSwipeDistance && velocity >= _minSwipeVelocity) {
      // 判断滑动方向
      if (offset.dx.abs() > offset.dy.abs()) {
        // 水平滑动
        if (offset.dx > 0) {
          print('向右滑动');
          onSwipeRight?.call();
        } else {
          print('向左滑动');
          onSwipeLeft?.call();
        }
      } else {
        // 垂直滑动
        if (offset.dy > 0) {
          print('向下滑动');
          onSwipeDown?.call();
        } else {
          print('向上滑动');
          onSwipeUp?.call();
        }
      }
    } else {
      print('滑动未达到阈值');
    }
  }

  void _reset() {
    _startPosition = null;
    _currentPosition = null;
    _trackedPointer = null;
    _startTime = null;
  }

  @override
  void didStopTrackingLastPointer(int pointer) {
    print('停止跟踪指针: $pointer');
  }

  @override
  String get debugDescription => 'swipe_gesture';

  @override
  void rejectGesture(int pointer) {
    super.rejectGesture(pointer);
    stopTrackingPointer(pointer);
    _reset();
  }
}

// 使用自定义手势的组件
class SwipeDetector extends StatelessWidget {
  final Widget child;
  final VoidCallback? onSwipeLeft;
  final VoidCallback? onSwipeRight;
  final VoidCallback? onSwipeUp;
  final VoidCallback? onSwipeDown;

  const SwipeDetector({
    Key? key,
    required this.child,
    this.onSwipeLeft,
    this.onSwipeRight,
    this.onSwipeUp,
    this.onSwipeDown,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return RawGestureDetector(
      gestures: {
        SwipeGestureRecognizer: GestureRecognizerFactoryWithHandlers<
          SwipeGestureRecognizer>(
          () => SwipeGestureRecognizer(),
          (SwipeGestureRecognizer instance) {
            instance
              ..onSwipeLeft = onSwipeLeft
              ..onSwipeRight = onSwipeRight
              ..onSwipeUp = onSwipeUp
              ..onSwipeDown = onSwipeDown;
          },
        ),
      },
      child: child,
    );
  }
}

// 调用规则
class SwipeExample extends StatefulWidget {
  @override
  _SwipeExampleState createState() => _SwipeExampleState();
}

class _SwipeExampleState extends State<SwipeExample> {
  String _swipeDirection = '等待滑动手势...';
  Color _backgroundColor = Colors.white;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('自定义滑动手势')),
      body: SwipeDetector(
        onSwipeLeft: () => _handleSwipe('左滑', Colors.red[100]!),
        onSwipeRight: () => _handleSwipe('右滑', Colors.blue[100]!),
        onSwipeUp: () => _handleSwipe('上滑', Colors.green[100]!),
        onSwipeDown: () => _handleSwipe('下滑', Colors.orange[100]!),
        child: Container(
          color: _backgroundColor,
          width: double.infinity,
          height: double.infinity,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(Icons.swipe, size: 80, color: Colors.grey),
              SizedBox(height: 20),
              Text(
                _swipeDirection,
                style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
              ),
              SizedBox(height: 10),
              Text(
                '在任意位置滑动试试',
                style: TextStyle(fontSize: 16, color: Colors.grey),
              ),
              SizedBox(height: 30),
              _buildDirectionIndicators(),
            ],
          ),
        ),
      ),
    );
  }

  void _handleSwipe(String direction, Color color) {
    setState(() {
      _swipeDirection = '检测到: $direction';
      _backgroundColor = color;
    });
    
    // 2秒后恢复初始状态
    Future.delayed(Duration(seconds: 2), () {
      if (mounted) {
        setState(() {
          _swipeDirection = '等待滑动手势...';
          _backgroundColor = Colors.white;
        });
      }
    });
  }

  Widget _buildDirectionIndicators() {
    return Container(
      padding: EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: Colors.black12,
        borderRadius: BorderRadius.circular(16),
      ),
      child: Column(
        children: [
          Icon(Icons.arrow_upward, size: 40, color: Colors.green),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              Icon(Icons.arrow_back, size: 40, color: Colors.red),
              Text('滑动方向', style: TextStyle(fontSize: 16)),
              Icon(Icons.arrow_forward, size: 40, color: Colors.blue),
            ],
          ),
          Icon(Icons.arrow_downward, size: 40, color: Colors.orange),
        ],
      ),
    );
  }
}

5. 交互式画板案例

5.1 画板应用架构设计

graph TB
    A[DrawingBoard] --> B[Toolbar]
    A --> C[CanvasArea]
    
    B --> D[ColorPicker]
    B --> E[BrushSizeSlider]
    B --> F[ActionButtons]
    
    C --> G[GestureDetector]
    G --> H[CustomPaint]
    
    H --> I[DrawingPainter]
    I --> J[Path数据]
    
    subgraph 状态管理
        K[DrawingState]
        L[Path列表]
        M[当前设置]
    end
    
    J --> L
    D --> M
    E --> M

5.2 画板应用实现

// 绘图路径数据类
class DrawingPath {
  final List<Offset> points;
  final Color color;
  final double strokeWidth;
  final PaintMode mode;

  DrawingPath({
    required this.points,
    required this.color,
    required this.strokeWidth,
    this.mode = PaintMode.draw,
  });
}

enum PaintMode { draw, erase }

// 主画板组件
class DrawingBoard extends StatefulWidget {
  @override
  _DrawingBoardState createState() => _DrawingBoardState();
}

class _DrawingBoardState extends State<DrawingBoard> {
  // 绘图状态
  final List<DrawingPath> _paths = [];
  DrawingPath? _currentPath;
  
  // 画笔设置
  Color _selectedColor = Colors.black;
  double _strokeWidth = 3.0;
  PaintMode _paintMode = PaintMode.draw;
  
  // 颜色选项
  final List<Color> _colorOptions = [
    Colors.black,
    Colors.red,
    Colors.blue,
    Colors.green,
    Colors.orange,
    Colors.purple,
    Colors.brown,
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('交互式画板'),
        backgroundColor: Colors.deepPurple,
        actions: [
          IconButton(
            icon: Icon(Icons.undo),
            onPressed: _undo,
            tooltip: '撤销',
          ),
          IconButton(
            icon: Icon(Icons.delete),
            onPressed: _clear,
            tooltip: '清空',
          ),
        ],
      ),
      body: Column(
        children: [
          // 工具栏
          _buildToolbar(),
          
          // 画布区域
          Expanded(
            child: Container(
              decoration: BoxDecoration(
                gradient: LinearGradient(
                  begin: Alignment.topLeft,
                  end: Alignment.bottomRight,
                  colors: [Colors.grey[100]!, Colors.grey[200]!],
                ),
              ),
              child: GestureDetector(
                onPanStart: _onPanStart,
                onPanUpdate: _onPanUpdate,
                onPanEnd: _onPanEnd,
                child: CustomPaint(
                  painter: _DrawingPainter(_paths),
                  size: Size.infinite,
                ),
              ),
            ),
          ),
          
          // 状态栏
          _buildStatusBar(),
        ],
      ),
    );
  }

  Widget _buildToolbar() {
    return Container(
      padding: EdgeInsets.all(12),
      color: Colors.white,
      child: Column(
        children: [
          // 颜色选择
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text('颜色:', style: TextStyle(fontWeight: FontWeight.bold)),
              Wrap(
                spacing: 8,
                children: _colorOptions.map((color) {
                  return GestureDetector(
                    onTap: () => setState(() {
                      _selectedColor = color;
                      _paintMode = PaintMode.draw;
                    }),
                    child: Container(
                      width: 32,
                      height: 32,
                      decoration: BoxDecoration(
                        color: color,
                        shape: BoxShape.circle,
                        border: Border.all(
                          color: _selectedColor == color ? 
                                Colors.black : Colors.transparent,
                          width: 3,
                        ),
                      ),
                    ),
                  );
                }).toList(),
              ),
              // 橡皮擦按钮
              GestureDetector(
                onTap: () => setState(() {
                  _paintMode = PaintMode.erase;
                }),
                child: Container(
                  padding: EdgeInsets.all(8),
                  decoration: BoxDecoration(
                    color: _paintMode == PaintMode.erase ? 
                          Colors.grey[300] : Colors.transparent,
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: Icon(
                    Icons.auto_fix_high,
                    color: _paintMode == PaintMode.erase ? 
                          Colors.red : Colors.grey,
                  ),
                ),
              ),
            ],
          ),
          
          SizedBox(height: 12),
          
          // 笔刷大小
          Row(
            children: [
              Text('笔刷大小:', style: TextStyle(fontWeight: FontWeight.bold)),
              Expanded(
                child: Slider(
                  value: _strokeWidth,
                  min: 1,
                  max: 20,
                  divisions: 19,
                  onChanged: (value) => setState(() {
                    _strokeWidth = value;
                  }),
                ),
              ),
              Container(
                padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
                decoration: BoxDecoration(
                  color: Colors.grey[200],
                  borderRadius: BorderRadius.circular(16),
                ),
                child: Text(
                  '${_strokeWidth.toInt()}px',
                  style: TextStyle(fontWeight: FontWeight.bold),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }

  Widget _buildStatusBar() {
    return Container(
      padding: EdgeInsets.all(8),
      color: Colors.black87,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text(
            _paintMode == PaintMode.draw ? '绘图模式' : '橡皮擦模式',
            style: TextStyle(color: Colors.white),
          ),
          Text(
            '路径数量: ${_paths.length}',
            style: TextStyle(color: Colors.white),
          ),
        ],
      ),
    );
  }

  void _onPanStart(DragStartDetails details) {
    setState(() {
      _currentPath = DrawingPath(
        points: [details.localPosition],
        color: _paintMode == PaintMode.erase ? Colors.white : _selectedColor,
        strokeWidth: _paintMode == PaintMode.erase ? _strokeWidth * 2 : _strokeWidth,
        mode: _paintMode,
      );
      _paths.add(_currentPath!);
    });
  }

  void _onPanUpdate(DragUpdateDetails details) {
    setState(() {
      _currentPath?.points.add(details.localPosition);
    });
  }

  void _onPanEnd(DragEndDetails details) {
    _currentPath = null;
  }

  void _undo() {
    if (_paths.isNotEmpty) {
      setState(() {
        _paths.removeLast();
      });
    }
  }

  void _clear() {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('清空画板'),
        content: Text('确定要清空所有绘图吗?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('取消'),
          ),
          TextButton(
            onPressed: () {
              setState(() {
                _paths.clear();
              });
              Navigator.pop(context);
            },
            child: Text('清空'),
          ),
        ],
      ),
    );
  }
}

// 绘图绘制器
class _DrawingPainter extends CustomPainter {
  final List<DrawingPath> paths;

  _DrawingPainter(this.paths);

  @override
  void paint(Canvas canvas, Size size) {
    // 绘制背景网格
    _drawBackgroundGrid(canvas, size);
    
    // 绘制所有路径
    for (final path in paths) {
      final paint = Paint()
        ..color = path.color
        ..strokeWidth = path.strokeWidth
        ..strokeCap = StrokeCap.round
        ..strokeJoin = StrokeJoin.round
        ..style = PaintingStyle.stroke;

      // 绘制路径
      if (path.points.length > 1) {
        final pathPoints = Path();
        pathPoints.moveTo(path.points[0].dx, path.points[0].dy);
        
        for (int i = 1; i < path.points.length; i++) {
          pathPoints.lineTo(path.points[i].dx, path.points[i].dy);
        }
        
        canvas.drawPath(pathPoints, paint);
      }
    }
  }

  void _drawBackgroundGrid(Canvas canvas, Size size) {
    final gridPaint = Paint()
      ..color = Colors.grey[300]!
      ..strokeWidth = 0.5;
    
    const gridSize = 20.0;
    
    // 绘制垂直线
    for (double x = 0; x < size.width; x += gridSize) {
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), gridPaint);
    }
    
    // 绘制水平线
    for (double y = 0; y < size.height; y += gridSize) {
      canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

6. 性能优化

6.1 手势性能优化策略

下面我们可以详细了解各种优化策略的效果:

优化策略 解决方法 应用场景
减少GestureDetector嵌套 合并相邻手势检测器 复杂布局、列表项
使用InkWell替代 简单点击使用InkWell 按钮、列表项点击
合理使用HitTestBehavior 精确控制命中测试范围 重叠组件、透明区域
避免频繁setState 使用TransformController 拖拽、缩放操作
列表项手势优化 使用NotificationListener 长列表、复杂手势

6.2 实际案例优化

class OptimizedGestureExample extends StatefulWidget {
  @override
  _OptimizedGestureExampleState createState() => _OptimizedGestureExampleState();
}

class _OptimizedGestureExampleState extends State<OptimizedGestureExample> {
  final TransformationController _transformController = TransformationController();
  final List<Widget> _items = [];

  @override
  void initState() {
    super.initState();
    // 初始化
    _initializeItems();
  }

  void _initializeItems() {
    for (int i = 0; i < 50; i++) {
      _items.add(
        OptimizedListItem(
          index: i,
          onTap: () => print('Item $i tapped'),
        ),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('优化手势')),
      body: Column(
        children: [
          // 可缩放拖拽区域
          Expanded(
            flex: 2,
            child: InteractiveViewer(
              transformationController: _transformController,
              boundaryMargin: EdgeInsets.all(20),
              minScale: 0.1,
              maxScale: 4.0,
              child: Container(
                color: Colors.blue[50],
                child: Center(
                  child: FlutterLogo(size: 150),
                ),
              ),
            ),
          ),
          
          // 优化列表
          Expanded(
            flex: 3,
            child: NotificationListener<ScrollNotification>(
              onNotification: (scrollNotification) {
                // 可以在这里处理滚动优化
                return false;
              },
              child: ListView.builder(
                itemCount: _items.length,
                itemBuilder: (context, index) => _items[index],
              ),
            ),
          ),
        ],
      ),
    );
  }

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

// 优化的列表项组件
class OptimizedListItem extends StatelessWidget {
  final int index;
  final VoidCallback onTap;

  const OptimizedListItem({
    required this.index,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.symmetric(vertical: 4, horizontal: 8),
      child: Material(
        color: Colors.white,
        borderRadius: BorderRadius.circular(8),
        elevation: 2,
        child: InkWell(  
          onTap: onTap,
          borderRadius: BorderRadius.circular(8),
          child: Container(
            padding: EdgeInsets.all(16),
            child: Row(
              children: [
                Container(
                  width: 40,
                  height: 40,
                  decoration: BoxDecoration(
                    color: Colors.primaries[index % Colors.primaries.length],
                    shape: BoxShape.circle,
                  ),
                  child: Center(
                    child: Text(
                      '$index',
                      style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
                    ),
                  ),
                ),
                SizedBox(width: 16),
                Expanded(
                  child: Text(
                    '优化列表项 $index',
                    style: TextStyle(fontSize: 16),
                  ),
                ),
                Icon(Icons.chevron_right, color: Colors.grey),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

总结

至此,手势识别相关知识点全部讲完了,通过本节的学习,我们掌握了Flutter手势识别的完整知识体系:GestureDetector拖拽与缩放手势冲突解决自定义手势识别

对于不同阶段的开发者,建议按以下路径学习:

graph LR
    A[初学者] --> B[基础手势]
    B --> C[拖拽缩放]
    
    C --> D[中级开发者]
    D --> E[手势冲突解决]
    E --> F[性能优化]
    
    F --> G[高级开发者]
    G --> H[自定义手势]
    H --> I[复杂交互系统]

如果觉得这篇文章对你有帮助,别忘了一键三连(点赞、关注、收藏)!你的支持是我持续创作的最大动力!有任何问题欢迎在评论区留言,我会及时解答!

iOS 内存管理深度解析:从原理到实践

作者 Sheffi
2025年11月26日 11:37

前言

内存管理是 iOS 开发中最核心的知识点之一,理解透彻的内存管理机制不仅能帮助我们写出高质量的代码,还能有效避免内存泄漏、野指针等常见问题。本文将从底层原理到实际应用,全面剖析 iOS 的内存管理机制。


一、内存管理的演进历程

1.1 MRC 时代(Manual Reference Counting)

在 iOS 5 之前,开发者需要手动管理对象的生命周期:

// MRC 时代的内存管理
NSObject *obj = [[NSObject alloc] init]; // retainCount = 1
[obj retain];                             // retainCount = 2
[obj release];                            // retainCount = 1
[obj release];                            // retainCount = 0,对象被销毁

黄金法则:谁创建(alloc/new/copy/mutableCopy),谁释放(release)。

1.2 ARC 时代(Automatic Reference Counting)

iOS 5 引入 ARC 后,编译器自动在适当位置插入 retain/release 代码:

// ARC 时代 - 编译器自动管理
func createObject() {
    let obj = MyClass()  // 编译器插入 retain
    // 使用 obj
}  // 函数结束,编译器插入 release

⚠️ 重要提示:ARC 不是垃圾回收(GC),它是编译时特性,不会带来运行时开销。


二、引用计数的底层实现

2.1 isa 指针与 SideTable

在 64 位系统中,苹果对 isa 指针进行了优化,采用了 Non-pointer isa 结构:

┌─────────────────────────────────────────────────────────────────┐
                        isa 指针结构(64位)                       
├─────────────────────────────────────────────────────────────────┤
 0       indexed       0: 纯指针  1: 优化的isa              
 1       has_assoc     是否有关联对象                        
 2       has_cxx_dtor  是否有C++析构函数                     
 3-35    shiftcls      类指针(33位)                        
 36-41   magic         用于调试                             
 42      weakly_ref    是否有弱引用                          
 43      deallocating  是否正在释放                          
 44      has_sidetable│ 引用计数是否存储在SideTable           
 45-63   extra_rc      额外的引用计数(19位)                 
└─────────────────────────────────────────────────────────────────┘

2.2 SideTable 结构

当引用计数超出 isa 的存储范围时,会使用 SideTable:

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

系统维护了一个 SideTables 哈希表,通过对象地址快速定位到对应的 SideTable:

// 获取对象的引用计数
static inline RefcountMap::iterator 
getRefcountMap(objc_object *obj) {
    SideTable& table = SideTables()[obj];
    return table.refcnts.find(obj);
}

2.3 retain 和 release 的源码分析

// objc_object::retain() 简化实现
id objc_object::retain() {
    // 1. TaggedPointer 直接返回
    if (isTaggedPointer()) return (id)this;
    
    // 2. 尝试在 isa 的 extra_rc 中增加引用计数
    if (fastpath(!ISA()->hasCustomRR())) {
        if (fastpath(bits.extra_rc++ < RC_HALF)) {
            return (id)this;
        }
    }
    
    // 3. extra_rc 溢出,转移到 SideTable
    return sidetable_retain();
}

三、四种引用类型详解

3.1 Strong(强引用)

class Person {
    var name: String
    var apartment: Apartment?  // 强引用
    
    init(name: String) {
        self.name = name
        print("\(name) is initialized")
    }
    
    deinit {
        print("\(name) is deinitialized")
    }
}

3.2 Weak(弱引用)

弱引用不会增加引用计数,对象释放时自动置为 nil:

class Apartment {
    let unit: String
    weak var tenant: Person?  // 弱引用,避免循环引用
    
    init(unit: String) {
        self.unit = unit
    }
}

弱引用的底层实现

// weak_table_t 结构
struct weak_table_t {
    weak_entry_t *weak_entries;  // 弱引用入口数组
    size_t    num_entries;        // 弱引用数量
    uintptr_t mask;               // 哈希掩码
    uintptr_t max_hash_displacement; // 最大哈希偏移
};

// 当对象被释放时,清理所有弱引用
void weak_clear_no_lock(weak_table_t *weak_table, id referent) {
    weak_entry_t *entry = weak_entry_for_referent(weak_table, referent);
    if (entry == nil) return;
    
    // 将所有指向该对象的弱引用置为 nil
    weak_referrer_t *referrers = entry->referrers;
    for (size_t i = 0; i < entry->num_refs; i++) {
        *referrers[i] = nil;
    }
    
    weak_entry_remove(weak_table, entry);
}

3.3 Unowned(无主引用)

class Customer {
    let name: String
    var card: CreditCard?
    
    init(name: String) {
        self.name = name
    }
}

class CreditCard {
    let number: UInt64
    unowned let customer: Customer  // 无主引用
    
    init(number: UInt64, customer: Customer) {
        self.number = number
        self.customer = customer
    }
}
特性 weak unowned
引用计数 不增加 不增加
对象释放时 自动置 nil 不处理(悬垂指针)
声明类型 Optional Non-optional
性能 略低(需维护weak表) 较高
安全性 安全 需保证生命周期

3.4 闭包中的引用

class HTMLElement {
    let name: String
    let text: String?
    
    // ❌ 循环引用
    lazy var asHTML: () -> String = {
        return "<\(self.name)>\(self.text ?? "")</\(self.name)>"
    }
    
    // ✅ 使用捕获列表打破循环
    lazy var asHTMLFixed: () -> String = { [weak self] in
        guard let self = self else { return "" }
        return "<\(self.name)>\(self.text ?? "")</\(self.name)>"
    }
    
    // ✅ 或使用 unowned(确保闭包执行时 self 存在)
    lazy var asHTMLUnowned: () -> String = { [unowned self] in
        return "<\(self.name)>\(self.text ?? "")</\(self.name)>"
    }
    
    init(name: String, text: String? = nil) {
        self.name = name
        self.text = text
    }
}

四、常见内存问题与解决方案

4.1 循环引用

场景一:Delegate 模式

// ❌ 错误示例
protocol DownloadDelegate: AnyObject {  // 注意这里必须用 AnyObject
    func downloadDidComplete()
}

class DownloadManager {
    var delegate: DownloadDelegate?  // ❌ 强引用导致循环
}

// ✅ 正确示例
class DownloadManager {
    weak var delegate: DownloadDelegate?  // ✅ 弱引用
}

场景二:闭包捕获

class NetworkManager {
    var completionHandler: (() -> Void)?
    
    func fetchData() {
        // ❌ 循环引用
        completionHandler = {
            self.handleData()
        }
        
        // ✅ 解决方案1:weak
        completionHandler = { [weak self] in
            self?.handleData()
        }
        
        // ✅ 解决方案2:在不需要时置空
        defer { completionHandler = nil }
    }
    
    func handleData() {
        print("Handle data")
    }
}

场景三:Timer

class TimerHolder {
    var timer: Timer?
    
    func startTimer() {
        // ❌ Timer 对 target 强引用
        timer = Timer.scheduledTimer(
            timeInterval: 1.0,
            target: self,
            selector: #selector(tick),
            userInfo: nil,
            repeats: true
        )
        
        // ✅ 解决方案:使用 block API
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            self?.tick()
        }
    }
    
    @objc func tick() {
        print("Tick")
    }
    
    deinit {
        timer?.invalidate()
        print("TimerHolder deinit")
    }
}

4.2 内存泄漏检测

使用 Instruments - Leaks

步骤:
1. Xcode -> Product -> Profile (⌘I)
2. 选择 Leaks
3. 运行并操作 App
4. 查看泄漏点和调用栈

使用 Debug Memory Graph

// 在特定点触发内存警告,观察对象是否正确释放
#if DEBUG
extension UIViewController {
    func checkMemoryLeak() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
            if self != nil {
                print("⚠️ 可能存在内存泄漏: \(type(of: self!))")
            }
        }
    }
}
#endif

自定义泄漏检测工具

class LeakDetector {
    static let shared = LeakDetector()
    private var trackedObjects: [ObjectIdentifier: WeakBox<AnyObject>] = [:]
    private let queue = DispatchQueue(label: "com.app.leakdetector")
    
    struct WeakBox<T: AnyObject> {
        weak var value: T?
        let className: String
    }
    
    func track(_ object: AnyObject, file: String = #file, line: Int = #line) {
        let id = ObjectIdentifier(object)
        let className = String(describing: type(of: object))
        
        queue.async {
            self.trackedObjects[id] = WeakBox(value: object, className: className)
            print("📍 Tracking: \(className) at \(file):\(line)")
        }
    }
    
    func checkLeaks() {
        queue.async {
            for (id, box) in self.trackedObjects {
                if box.value != nil {
                    print("⚠️ Potential leak: \(box.className)")
                } else {
                    self.trackedObjects.removeValue(forKey: id)
                }
            }
        }
    }
}

五、Autorelease Pool 深度解析

5.1 工作原理

┌──────────────────────────────────────────────────────────────────┐
│                    Autorelease Pool 结构                          │
├──────────────────────────────────────────────────────────────────┤
│                                                                   │
│   ┌─────────────┐   ┌─────────────┐   ┌─────────────┐            │
│   │   Page 1    │──>│   Page 2    │──>│   Page 3    │            │
│   │  (4096 B)   │   │  (4096 B)   │   │  (4096 B)   │            │
│   └─────────────┘   └─────────────┘   └─────────────┘            │
│         │                 │                 │                     │
│         ▼                 ▼                 ▼                     │
│   ┌───────────┐     ┌───────────┐     ┌───────────┐              │
│   │  obj1     │     │  obj5     │     │  obj9     │              │
│   │  obj2     │     │  obj6     │     │  obj10    │              │
│   │  obj3     │     │  obj7     │     │  ...      │              │
│   │  obj4     │     │  obj8     │     │           │              │
│   │ SENTINEL  │     │           │     │           │              │
│   └───────────┘     └───────────┘     └───────────┘              │
│                                              ▲                    │
│                                              │                    │
│                                           hotPage                 │
│                                          (当前页)                  │
│                                                                   │
└──────────────────────────────────────────────────────────────────┘

5.2 源码分析

class AutoreleasePoolPage {
    static size_t const SIZE = PAGE_MAX_SIZE;  // 4096 bytes
    static size_t const COUNT = SIZE / sizeof(id);
    
    magic_t const magic;
    id *next;                    // 下一个可存放对象的位置
    pthread_t const thread;      // 所属线程
    AutoreleasePoolPage *parent; // 父节点
    AutoreleasePoolPage *child;  // 子节点
    uint32_t depth;              // 深度
    
    // 添加对象到 pool
    static inline id *autoreleaseFast(id obj) {
        AutoreleasePoolPage *page = hotPage();
        if (page && !page->full()) {
            return page->add(obj);
        }
        return autoreleaseFullPage(obj, page);
    }
    
    // Pool 的 pop 操作
    static inline void pop(void *token) {
        AutoreleasePoolPage *page = pageForPointer(token);
        id *stop = (id *)token;
        
        // 释放对象
        page->releaseUntil(stop);
        
        // 删除空页
        if (page->child) {
            page->child->kill();
            page->child = nil;
        }
    }
};

5.3 主线程 RunLoop 与 Autorelease Pool

┌──────────────────────────────────────────────────────────────────┐
                    RunLoop  AutoreleasePool                     
├──────────────────────────────────────────────────────────────────┤
                                                                   
   ┌─────────────────────────────────────────────────────────┐    
                        Main RunLoop                             
   └─────────────────────────────────────────────────────────┘    
                                                                  
        ┌─────────────────────┼─────────────────────┐             
                                                               
   ┌─────────┐          ┌─────────┐          ┌─────────┐         
     Entry             Before              Exit            
    (Push)             Waiting             (Pop)           
    Order:             (Pop +             Order:           
     高优先              Push)              低优先           
   └─────────┘          └─────────┘          └─────────┘         
                                                                   
   时机说明:                                                       
   1. kCFRunLoopEntry: 创建 AutoreleasePool (push)                
   2. kCFRunLoopBeforeWaiting: 释放旧pool (pop),创建新pool (push) 
   3. kCFRunLoopExit: 释放 AutoreleasePool (pop)                  
                                                                   
└──────────────────────────────────────────────────────────────────┘

5.4 手动使用 Autorelease Pool

// 场景:大量临时对象的循环
func processLargeData() {
    for i in 0..<100000 {
        // ❌ 不使用 autoreleasepool,临时对象会累积
        let data = createTemporaryData(index: i)
        process(data)
    }
    
    for i in 0..<100000 {
        // ✅ 使用 autoreleasepool,每次迭代后释放临时对象
        autoreleasepool {
            let data = createTemporaryData(index: i)
            process(data)
        }
    }
    
    // ✅ 更优化的方案:批量处理
    let batchSize = 1000
    for batch in stride(from: 0, to: 100000, by: batchSize) {
        autoreleasepool {
            for i in batch..<min(batch + batchSize, 100000) {
                let data = createTemporaryData(index: i)
                process(data)
            }
        }
    }
}

六、Tagged Pointer 优化

6.1 什么是 Tagged Pointer

对于小对象(如小的 NSNumber、NSDate),苹果使用 Tagged Pointer 直接在指针中存储数据:

┌──────────────────────────────────────────────────────────────────┐
│                    Tagged Pointer 结构                            │
├──────────────────────────────────────────────────────────────────┤
│                                                                   │
│   普通对象指针:                                                   │
│   ┌─────────────────────────────────────────────────────────┐    │
│   │         64位地址指向堆中的对象                            │    │
│   └─────────────────────────────────────────────────────────┘    │
│                              │                                    │
│                              ▼                                    │
│   ┌─────────────────────────────────────────────────────────┐    │
│   │                    堆中的对象                             │    │
│   │  ┌──────┬──────────┬──────────┬─────────────────────┐   │    │
│   │  │ isa  │ refCount │ 其他信息  │      实际数据       │   │    │
│   │  └──────┴──────────┴──────────┴─────────────────────┘   │    │
│   └─────────────────────────────────────────────────────────┘    │
│                                                                   │
│   Tagged Pointer:                                                │
│   ┌─────────────────────────────────────────────────────────┐    │
│   │ 1 │ 类型标记(3位) │           数据值(60位)              │    │
│   └─────────────────────────────────────────────────────────┘    │
│     ↑                                                             │
│   标记位(表明这是Tagged Pointer)                                   │
│                                                                   │
└──────────────────────────────────────────────────────────────────┘

6.2 判断 Tagged Pointer

// 通过内存地址判断(仅供理解,实际开发中不需要关心)
func isTaggedPointer(_ obj: AnyObject) -> Bool {
    let pointer = Unmanaged.passUnretained(obj).toOpaque()
    let value = UInt(bitPattern: pointer)
    
    // 在 arm64 上,最高位为 1 表示 Tagged Pointer
    // 在 x86_64 上,最低位为 1 表示 Tagged Pointer
    #if arch(arm64)
    return (value >> 63) == 1
    #else
    return (value & 1) == 1
    #endif
}

6.3 性能优势

// Tagged Pointer 的优势演示
func performanceTest() {
    let iterations = 1_000_000
    
    // 小数字 - 使用 Tagged Pointer
    let start1 = CFAbsoluteTimeGetCurrent()
    for _ in 0..<iterations {
        let num = NSNumber(value: 42)  // Tagged Pointer
        _ = num.intValue
    }
    let time1 = CFAbsoluteTimeGetCurrent() - start1
    
    // 大数字 - 使用普通对象
    let start2 = CFAbsoluteTimeGetCurrent()
    for _ in 0..<iterations {
        let num = NSNumber(value: Int64.max)  // 普通对象
        _ = num.int64Value
    }
    let time2 = CFAbsoluteTimeGetCurrent() - start2
    
    print("Tagged Pointer: \(time1)s")  // 明显更快
    print("普通对象: \(time2)s")
}

七、实战:内存优化最佳实践

7.1 图片内存优化

class ImageLoader {
    // 使用 NSCache 自动管理内存
    private let cache = NSCache<NSString, UIImage>()
    
    init() {
        // 设置缓存限制
        cache.countLimit = 100
        cache.totalCostLimit = 50 * 1024 * 1024  // 50MB
        
        // 监听内存警告
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleMemoryWarning),
            name: UIApplication.didReceiveMemoryWarningNotification,
            object: nil
        )
    }
    
    // 下采样加载大图
    func loadDownsampledImage(at url: URL, targetSize: CGSize) -> UIImage? {
        let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
        
        guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, imageSourceOptions) else {
            return nil
        }
        
        let maxDimension = max(targetSize.width, targetSize.height) * UIScreen.main.scale
        let downsampledOptions = [
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceShouldCacheImmediately: true,
            kCGImageSourceCreateThumbnailWithTransform: true,
            kCGImageSourceThumbnailMaxPixelSize: maxDimension
        ] as CFDictionary
        
        guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampledOptions) else {
            return nil
        }
        
        return UIImage(cgImage: downsampledImage)
    }
    
    @objc private func handleMemoryWarning() {
        cache.removeAllObjects()
    }
}

7.2 大数据处理

class DataProcessor {
    // 分批处理大数组,避免内存峰值
    func processBatched<T>(_ array: [T], batchSize: Int = 1000, handler: ([T]) -> Void) {
        let totalCount = array.count
        var processedCount = 0
        
        while processedCount < totalCount {
            autoreleasepool {
                let endIndex = min(processedCount + batchSize, totalCount)
                let batch = Array(array[processedCount..<endIndex])
                handler(batch)
                processedCount = endIndex
            }
        }
    }
    
    // 使用流式读取大文件
    func processLargeFile(at url: URL, lineHandler: (String) -> Void) {
        guard let fileHandle = try? FileHandle(forReadingFrom: url) else { return }
        defer { try? fileHandle.close() }
        
        let bufferSize = 4096
        var buffer = Data()
        
        while autoreleasepool(invoking: {
            guard let chunk = try? fileHandle.read(upToCount: bufferSize), !chunk.isEmpty else {
                return false
            }
            
            buffer.append(chunk)
            
            while let range = buffer.range(of: Data("\n".utf8)) {
                let lineData = buffer.subdata(in: 0..<range.lowerBound)
                if let line = String(data: lineData, encoding: .utf8) {
                    lineHandler(line)
                }
                buffer.removeSubrange(0..<range.upperBound)
            }
            
            return true
        }) {}
        
        // 处理最后一行
        if let lastLine = String(data: buffer, encoding: .utf8), !lastLine.isEmpty {
            lineHandler(lastLine)
        }
    }
}

7.3 ViewController 内存管理

class BaseViewController: UIViewController {
    // 所有需要取消的任务
    private var cancellables = Set<AnyCancellable>()
    private var tasks = [Task<Void, Never>]()
    
    deinit {
        // 取消所有订阅
        cancellables.removeAll()
        
        // 取消所有 Task
        tasks.forEach { $0.cancel() }
        
        print("\(type(of: self)) deinit")
    }
    
    // 安全地添加通知观察者
    func observe(_ name: Notification.Name, handler: @escaping (Notification) -> Void) {
        NotificationCenter.default.publisher(for: name)
            .sink { [weak self] notification in
                guard self != nil else { return }
                handler(notification)
            }
            .store(in: &cancellables)
    }
    
    // 安全地执行异步任务
    func performTask(_ operation: @escaping () async -> Void) {
        let task = Task { [weak self] in
            guard self != nil else { return }
            await operation()
        }
        tasks.append(task)
    }
}

八、调试技巧

8.1 LLDB 命令

# 查看对象引用计数
(lldb) p CFGetRetainCount(obj as CFTypeRef)

# 查看对象的弱引用
(lldb) p _objc_rootRetainCount(obj)

# 查看所有内存分配
(lldb) memory history <address>

# 查看 Autorelease Pool 中的对象
(lldb) po [NSAutoreleasePool showPools]

# 查看对象的 isa 信息
(lldb) p/x (uintptr_t)object_getClass(obj)

8.2 环境变量

在 Scheme 的 Environment Variables 中添加:

MallocStackLogging = 1          # 记录内存分配堆栈
MallocStackLoggingNoCompact = 1 # 不压缩堆栈信息
OBJC_DEBUG_POOL_ALLOCATION = YES # 调试 Autorelease Pool
NSZombieEnabled = YES           # 检测野指针

8.3 自定义内存追踪

#if DEBUG
class MemoryTracker {
    static let shared = MemoryTracker()
    
    private var allocations: [String: Int] = [:]
    private let queue = DispatchQueue(label: "memory.tracker")
    
    func trackAlloc(_ className: String) {
        queue.async {
            self.allocations[className, default: 0] += 1
        }
    }
    
    func trackDealloc(_ className: String) {
        queue.async {
            self.allocations[className, default: 0] -= 1
        }
    }
    
    func report() {
        queue.async {
            print("=== Memory Report ===")
            for (className, count) in self.allocations where count > 0 {
                print("\(className): \(count) instances")
            }
            print("====================")
        }
    }
}

// 使用方式
class TrackedObject {
    init() {
        MemoryTracker.shared.trackAlloc(String(describing: Self.self))
    }
    
    deinit {
        MemoryTracker.shared.trackDealloc(String(describing: Self.self))
    }
}
#endif

总结

iOS 内存管理是一个深度话题,本文从以下几个方面进行了详细解析:

  1. 引用计数原理:从 MRC 到 ARC 的演进,以及底层 SideTable 的实现
  2. 四种引用类型:strong、weak、unowned 的区别和适用场景
  3. 循环引用:常见场景和解决方案
  4. Autorelease Pool:工作原理和使用时机
  5. Tagged Pointer:小对象优化机制
  6. 实战优化:图片处理、大数据处理等场景的最佳实践
  7. 调试技巧:常用的调试命令和工具

参考资料

Swift UI数据存储

作者 Haha_bj
2025年11月26日 11:34

一. @StateObject 数据存储机制

@StateObject 保存的数据存储在设备内存(RAM)中,是临时存储

import SwiftUI
internal import Combine
class BloodGlucoseStore: ObservableObject{
    @Published var count = 0 // 存储在内存中
    
}

struct JLHomeView: View {
    @StateObject private var store = BloodGlucoseStore()// 对象存在于内存中
    var body: some View {
        Text("记录数量:\(store.count)")
        Button("点击") {
            store.count += 1
        }
        
    }
}

数据生命周期

  • 创建时机:视图第一次被创建时
  • 保持时机:视图重新渲染时数据保持不变
  • 销毁时机:视图被销毁时数据丢失
struct ContentView: View {
    @State private var showHomeView = false
    
    var body: some View {
        VStack {
            Button("显示/隐藏 HomeView") {
                showHomeView.toggle()
            }
            
            if showHomeView {
                JLHomeView()  // 创建时:数据在内存中创建
            }              // 销毁时:数据从内存中清除
        }
    }
}

二. UserDefaults 存储机制

存储位置

  • 📁 应用沙盒中的 .plist 文件
  • 路径:/Library/Preferences/[Bundle-ID].plist

UserDefaults 数据安全性

✅ 不会丢失的情况
  • 应用更新:数据保持不变
  • 应用重启:数据依然存在
  • 设备重启:数据保持不变
  • iOS 系统更新:数据通常保持
❌ 会丢失的情况
  • 卸载应用:整个应用沙盒被删除
  • 恢复设备但不恢复备份:数据丢失
  • 手动清除应用数据:通过系统设置清除
class SettingStore: ObservableObject{
    
    @Published var isDarmMode: Bool{
        didSet{
            /// 保存数据
            UserDefaults.standard.set(isDarmMode, forKey: "isDarmMode")
            UserDefaults.standard.synchronize()
        }
    }
    
    init(){
        /// 读数数据
        isDarmMode = UserDefaults.standard.bool(forKey: "isDarmMode")
    }
    deinit{
        /// 删除数据
        UserDefaults.standard.removeObject(forKey: "isDarmMode")
        UserDefaults.standard.synchronize()
    }
}

三. @Published 属性包装器

核心作用

@Published 的主要作用是自动触发 UI 更新

class CounterStore: ObservableObject {
    var count = 0  // 普通属性
    
    func increment() {
        count += 1  // UI 不会更新!
    }
}

实际应用示例

class BloodGlucoseStore: ObservableObject {
    @Published var records: [BloodGlucoseRecord] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    @Published var selectedDate = Date()
    @Published var filterType: FilterType = .all
    
    // 计算属性也会响应 @Published 属性的变化
    var filteredRecords: [BloodGlucoseRecord] {
        switch filterType {
        case .all:
            return records
        case .today:
            return records.filter { Calendar.current.isDateInToday($0.date) }
        case .thisWeek:
            return records.filter { $0.date.isInCurrentWeek }
        }
    }
    
    func addRecord(_ record: BloodGlucoseRecord) {
        records.append(record)  // 触发 UI 更新
    }
    
    func setFilter(_ filter: FilterType) {
        filterType = filter  // 触发筛选更新
    }
}

高级用法

自定义 setter
class UserStore: ObservableObject {
    @Published var username: String = "" {
        didSet {
            validateUsername()
            saveToUserDefaults()
        }
    }
    
    @Published var isUsernameValid = false
    
    private func validateUsername() {
        isUsernameValid = username.count >= 3
    }
}

级联更新
class ShoppingCartStore: ObservableObject {
    @Published var items: [CartItem] = [] {
        didSet {
            updateTotalPrice()  // items 变化时自动更新总价
        }
    }
    
    @Published var totalPrice: Double = 0
    @Published var discountCode: String = "" {
        didSet {
            updateTotalPrice()  // 折扣码变化时也更新总价
        }
    }
    
    private func updateTotalPrice() {
        let subtotal = items.reduce(0) { $0 + $1.price * Double($1.quantity) }
        let discount = calculateDiscount(for: discountCode)
        totalPrice = subtotal - discount
    }
}

当我决定同时做 iOS 和 Android:独立开发者的真实双平台之路

作者 Fatbobman
2025年11月26日 22:12

这是一位独立开发者跨上双平台之路的完整记录:从 iOS 的舒适区,到 Android 的碎片化现实;从协作模式、交互差异,到商店后台、支付体系和中国安卓生态的真实挑战。产品在变,他的理解和心态也在变。或许能让仍只在苹果生态中的你看到另一条可能的路径。

SwiftUI 手势冲突:修复 Navigation 返回手势

作者 RickeyBoy
2025年11月25日 23:06

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

问题背景

在开发过程中遇到一个体验上的冲突问题,当用户在使用可横向翻页的视图(如 TabView 的 page 样式)时,第一页无法从屏幕边缘滑动返回上一页。返回手势总是被 TabView 的手势拦截,具体表现可以看下面这个 gif 图:

failure.gif

原因分析

为什么会这样?

  1. 手势竞争问题:
- Navigation Controller:提供边缘滑动返回手势
- TabView:拥有用于页面切换的横向拖动手势

2. 优先级冲突:

- 两个手势都识别横向滑动
- TabView 的手势先捕获触摸
- Navigation 手势永远没有机会响应

SwiftUI 的局限性

SwiftUI 没有内置的方式来协调这些手势,解决冲突,所以我们必须深入到 UIKit,自行解决冲突。

如何解决

关键点:在第一页时,我们需要两个手势同时激活,但响应不同的方向:

  • 向右滑动(从左边缘) → Navigation 返回手势
  • 向左滑动 → TabView 翻页

当然,这个要实现上述的逻辑,需要通过 UIKit 来进行手势冲突的逻辑处理。

解决方案

完整实现:NavigationSwipeBackModifier.swift

步骤 1:识别手势

获取到互相冲突的两个手势:

  • Navigation Gesture:位于 UINavigationController.interactivePopGestureRecognizer
  • Content Gesture:位于可滚动内容上(如 UIScrollView.panGestureRecognizer)
.introspect(.viewController, on: .iOS(.v16, .v17, .v18)) { viewController in
    guard let navigationController = viewController.navigationController,
          let interactivePopGesture = navigationController.interactivePopGestureRecognizer else {
        return
    }
    coordinator.configure(with: interactivePopGesture)
}
.introspect(.scrollView, on: .iOS(.v16, .v17, .v18)) { scrollView in
    coordinator.conflictingGesture = scrollView.panGestureRecognizer
}

步骤 2:创建 Coordinator

构建一个实现 UIGestureRecognizerDelegate 的 Coordinator,他的职责如下:

  • 存储两个手势
  • 通过 Delegate 回调管理它们的交互
  • 处理生命周期(设置和清理)
public final class NavigationSwipeBackCoordinator: NSObject, UIGestureRecognizerDelegate {
    /// Closure that determines whether swipe-back should be enabled
    public var shouldEnableSwipeBack: (() -> Bool)?

    /// The conflicting gesture that should work simultaneously
    public weak var conflictingGesture: UIPanGestureRecognizer?

    private weak var interactivePopGesture: UIGestureRecognizer?
    private weak var originalDelegate: UIGestureRecognizerDelegate?

    public func configure(with gesture: UIGestureRecognizer) {
        guard interactivePopGesture == nil else { return }
        interactivePopGesture = gesture
        originalDelegate = gesture.delegate
        gesture.delegate = self
    }
    // ... cleanup and delegate methods
}

步骤 3:启用同时识别 RecognizeSimultaneously

实现 gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:)

  • 当两个手势需要同时工作时返回 true
  • 允许两者检测触摸而不会互相拦截
public func gestureRecognizer(
    _: UIGestureRecognizer,
    shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
    // Only allow simultaneous recognition with the conflicting gesture we're managing
    return otherGestureRecognizer == conflictingGesture
}

步骤 4:添加条件逻辑

实现 gestureRecognizerShouldBegin(_:)

  • 检查当前状态(例如检查是否位于第一页)
  • 只在适当的时候允许 Navigation 手势
  • 在用户应该滚动内容时阻止返回手势
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
    guard let panGesture = gestureRecognizer as? UIPanGestureRecognizer else {
        return true
    }

    // Check swipe direction
    let translation = panGesture.translation(in: panGesture.view)
    let velocity = panGesture.velocity(in: panGesture.view)
    let isSwipingRight = translation.x > 0 || velocity.x > 0

    // Only allow back gesture for right swipes
    guard isSwipingRight else { return false }

    // Check app-specific condition (e.g., "am I on the first page?")
    return shouldEnableSwipeBack?() ?? false
}

步骤 5:管理生命周期

  • 设置:保存原始状态,安装自定义 Delegate
  • 清理:恢复原始状态以避免副作用
public func cleanup() {
    interactivePopGesture?.delegate = originalDelegate
    interactivePopGesture = nil
    originalDelegate = nil
    shouldEnableSwipeBack = nil
    conflictingGesture = nil
}

步骤 6:封装为 SwiftUI Modifier

创建可复用的 ViewModifier:

  • 封装所有 UIKit 复杂性
  • 提供简洁的 SwiftUI API
  • 响应式更新状态
public extension View {
    func enableNavigationSwipeBack(when condition: @escaping () -> Bool) -> some View {
        modifier(NavigationSwipeBackModifier(shouldEnable: condition))
    }
}
// Usage
.enableNavigationSwipeBack(when: { selectedIndex == 0 })

实现模式

  ┌─────────────────────────────────────┐
  │   SwiftUI View                      │
  │   .enableSwipeBack(when: condition) │
  └────────────┬────────────────────────┘
               │
               ▼
  ┌─────────────────────────────────────┐
  │   ViewModifier                      │
  │   - Manages lifecycle               │
  │   - Updates condition reactively    │
  └────────────┬────────────────────────┘
               │
               ▼
  ┌─────────────────────────────────────┐
  │   Gesture Coordinator               │
  │   - Implements delegate callbacks   │
  │   - Coordinates both gestures       │
  │   - Stores original state           │
  └─────────────────────────────────────┘

使用方法

在任何会阻止 Navigation 返回手势的横向滑动视图上,应用 enableNavigationSwipeBack modifier。

基本语法

.enableNavigationSwipeBack(when: { condition })

when 闭包用于判断何时应该启用返回手势。它在手势开始时实时计算,确保能响应最新的状态。

示例:分页 TabView

TabView(selection: $selection) {
    ForEach(items) { item in
        ItemView(item: item)
    }
}
.tabViewStyle(.page(indexDisplayMode: .never))
.enableNavigationSwipeBack(when: { selectedItemIndex == 0 })

注意:此方案需要 SwiftUIIntrospect 库来访问底层 UIKit 视图。

效果

当用户位于第一页时,自动允许边缘滑动返回手势

success.gif

SwiftUI快速入门指南-Modifier篇

作者 xiAo_Ju
2025年11月25日 20:33

背景

本文帮助有Swift基础的同学,快速入门SwiftUI,基于cursour整理

主要分为四个部分:

1. 什么是 Modifier?

Modifier 是用于修改视图外观和行为的方法。每个 modifier 都会返回一个新的视图。

Text("Hello")
    .font(.title)           // 修改字体
    .foregroundColor(.blue) // 修改颜色
    .padding()              // 添加内边距
    .background(.yellow)    // 添加背景

核心概念:

  • ✅ Modifier 不修改原视图,而是创建新视图
  • ✅ 支持链式调用
  • ✅ 顺序很重要!

2. Modifier 分类

A. 文本 Modifier
Text("SwiftUI Modifier")
    // 字体
    .font(.title)
    .font(.system(size: 24, weight: .bold, design: .rounded))
    .fontWeight(.semibold)
    
    // 颜色
    .foregroundColor(.blue)
    .foregroundStyle(.red)
    
    // 样式
    .italic()
    .bold()
    .underline()
    .strikethrough()
    .kerning(2)              // 字间距
    .tracking(3)             // 字符间距
    .baselineOffset(5)       // 基线偏移
    
    // 多行
    .lineLimit(3)
    .lineSpacing(8)
    .multilineTextAlignment(.center)
    .truncationMode(.tail)
B. 布局 Modifier
VStack {
    Text("布局示例")
}
// 内边距
.padding()
.padding(.horizontal, 20)
.padding(.top, 10)
.padding(EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20))

// 尺寸
.frame(width: 200, height: 100)
.frame(minWidth: 100, maxWidth: .infinity)
.frame(maxHeight: 300)

// 对齐
.frame(width: 300, height: 200, alignment: .topLeading)

// 偏移
.offset(x: 10, y: 20)

// 位置
.position(x: 100, y: 100)
C. 背景和边框 Modifier
Text("样式示例")
    // 背景
    .background(.blue)
    .background(Color.blue.opacity(0.3))
    .background(
        LinearGradient(
            colors: [.blue, .purple],
            startPoint: .leading,
            endPoint: .trailing
        )
    )
    
    // 边框
    .border(.red, width: 2)
    
    // 圆角边框
    .cornerRadius(10)
    .clipShape(RoundedRectangle(cornerRadius: 15))
    .clipShape(Circle())
    
    // 描边
    .overlay(
        RoundedRectangle(cornerRadius: 10)
            .stroke(.red, lineWidth: 2)
    )
D. 阴影和效果 Modifier
Text("效果示例")
    // 阴影
    .shadow(radius: 5)
    .shadow(color: .gray, radius: 10, x: 5, y: 5)
    
    // 模糊
    .blur(radius: 3)
    
    // 透明度
    .opacity(0.8)
    
    // 旋转
    .rotationEffect(.degrees(45))
    .rotation3DEffect(.degrees(45), axis: (x: 1, y: 0, z: 0))
    
    // 缩放
    .scaleEffect(1.5)
    .scaleEffect(x: 1.2, y: 0.8)
E. 交互 Modifier
Text("点击我")
    // 点击
    .onTapGesture {
        print("被点击了")
    }
    .onTapGesture(count: 2) {
        print("双击")
    }
    
    // 长按
    .onLongPressGesture {
        print("长按")
    }
    
    // 拖拽
    .gesture(
        DragGesture()
            .onChanged { value in
                print("拖拽中")
            }
    )
    
    // 禁用
    .disabled(true)
F. 生命周期 Modifier
Text("生命周期")
    // 出现
    .onAppear {
        print("视图出现")
    }
    
    // 消失
    .onDisappear {
        print("视图消失")
    }
    
    // 值变化
    .onChange(of: someValue) { oldValue, newValue in
        print("值改变了")
    }
    
    // 任务
    .task {
        await loadData()
    }

3. Modifier 顺序的重要性 ⚠️

这是最容易出错的地方!Modifier 的顺序会产生完全不同的结果。

// 示例 1: 先 padding 后 background
Text("Hello")
    .padding(20)        // 先添加内边距
    .background(.blue)  // 背景覆盖整个区域(包括 padding)

// 结果:蓝色背景包含文字和内边距

// 示例 2: 先 background 后 padding
Text("Hello")
    .background(.blue)  // 背景只覆盖文字
    .padding(20)        // 在背景外添加内边距

// 结果:蓝色背景只包含文字,外面有空白

// 边框和圆角
Text("示例")
    .padding()
    .background(.blue)
    .cornerRadius(10)    // ✅ 正确:圆角应用到背景
    .border(.red, width: 2)  // 边框在圆角外

Text("示例")
    .padding()
    .cornerRadius(10)    // ❌ 错误:圆角应用到文字(没效果)
    .background(.blue)
    .border(.red, width: 2)
    
// Frame 和 Background
Text("示例")
    .frame(width: 200, height: 100)
    .background(.blue)   // ✅ 蓝色填满整个 frame

Text("示例")
    .background(.blue)   
    .frame(width: 200, height: 100)  // ❌ 蓝色只在文字周围

4. 自定义 Modifier

方法一:使用 ViewModifier 协议
// 定义自定义 modifier
struct CardModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .background(.white)
            .cornerRadius(10)
            .shadow(color: .gray.opacity(0.4), radius: 5, x: 0, y: 2)
    }
}

// 扩展 View 以便于使用
extension View {
    func cardStyle() -> some View {
        self.modifier(CardModifier())
    }
}

// 使用
Text("卡片样式")
    .cardStyle()
方法二:直接扩展 View
extension View {
    func primaryButton() -> some View {
        self
            .font(.headline)
            .foregroundColor(.white)
            .padding()
            .frame(maxWidth: .infinity)
            .background(.blue)
            .cornerRadius(10)
    }
}

// 使用
Text("登录")
    .primaryButton()
带参数的自定义 Modifier
struct BorderModifier: ViewModifier {
    var color: Color
    var width: CGFloat
    var cornerRadius: CGFloat
    
    func body(content: Content) -> some View {
        content
            .padding()
            .overlay(
                RoundedRectangle(cornerRadius: cornerRadius)
                    .stroke(color, lineWidth: width)
            )
    }
}

extension View {
    func customBorder(
        color: Color = .blue,
        width: CGFloat = 2,
        cornerRadius: CGFloat = 8
    ) -> some View {
        self.modifier(BorderModifier(
            color: color,
            width: width,
            cornerRadius: cornerRadius
        ))
    }
}

// 使用
Text("自定义边框")
    .customBorder(color: .red, width: 3, cornerRadius: 15)

5. 条件 Modifier

// 方法一:使用 @ViewBuilder
extension View {
    @ViewBuilder
    func `if`<Transform: View>(
        _ condition: Bool,
        transform: (Self) -> Transform
    ) -> some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }
}

// 使用
Text("条件样式")
    .if(isHighlighted) { view in
        view
            .font(.largeTitle)
            .foregroundColor(.red)
    }
    
// 方法二:三元运算符
Text("示例")
    .foregroundColor(isActive ? .blue : .gray)
    .font(isLarge ? .title : .body)
    
// 方法三:使用 modifier
struct ConditionalModifier: ViewModifier {
    var condition: Bool
    
    func body(content: Content) -> some View {
        if condition {
            content
                .background(.yellow)
                .cornerRadius(10)
        } else {
            content
                .background(.gray)
        }
    }
}

// 使用
Text("条件")
    .modifier(ConditionalModifier(condition: isSpecial))

6. 组合 Modifier 实战示例

struct ProfileCard: View {
    @State private var isLiked = false
    
    var body: some View {
        VStack(spacing: 12) {
            // 头像
            Image(systemName: "person.circle.fill")
                .resizable()
                .scaledToFit()
                .frame(width: 80, height: 80)
                .foregroundColor(.blue)
                .clipShape(Circle())
                .overlay(
                    Circle()
                        .stroke(.gray, lineWidth: 2)
                )
                .shadow(radius: 5)
            
            // 名字
            Text("张三")
                .font(.title2)
                .fontWeight(.bold)
            
            // 描述
            Text("iOS 开发工程师")
                .font(.subheadline)
                .foregroundColor(.secondary)
            
            // 按钮
            Button(action: { isLiked.toggle() }) {
                HStack {
                    Image(systemName: isLiked ? "heart.fill" : "heart")
                    Text(isLiked ? "已关注" : "关注")
                }
                .font(.headline)
                .foregroundColor(isLiked ? .red : .white)
                .padding(.horizontal, 20)
                .padding(.vertical, 10)
                .background(isLiked ? .white : .blue)
                .cornerRadius(20)
                .overlay(
                    RoundedRectangle(cornerRadius: 20)
                        .stroke(isLiked ? .red : .blue, lineWidth: 2)
                )
            }
        }
        .padding(20)
        .background(.white)
        .cornerRadius(15)
        .shadow(color: .black.opacity(0.1), radius: 10, x: 0, y: 5)
        .padding()
    }
}

7. 常用 Modifier 组合模板

extension View {
    // 卡片样式
    func card() -> some View {
        self
            .padding()
            .background(.white)
            .cornerRadius(12)
            .shadow(color: .gray.opacity(0.3), radius: 8, x: 0, y: 4)
    }
    
    // 主按钮样式
    func primaryButtonStyle() -> some View {
        self
            .font(.headline)
            .foregroundColor(.white)
            .padding()
            .frame(maxWidth: .infinity)
            .background(
                LinearGradient(
                    colors: [.blue, .purple],
                    startPoint: .leading,
                    endPoint: .trailing
                )
            )
            .cornerRadius(10)
            .shadow(radius: 5)
    }
    
    // 输入框样式
    func textFieldStyle() -> some View {
        self
            .padding()
            .background(.gray.opacity(0.1))
            .cornerRadius(8)
            .overlay(
                RoundedRectangle(cornerRadius: 8)
                    .stroke(.gray.opacity(0.5), lineWidth: 1)
            )
    }
    
    // 标签样式
    func tag(color: Color = .blue) -> some View {
        self
            .font(.caption)
            .padding(.horizontal, 12)
            .padding(.vertical, 6)
            .background(color.opacity(0.2))
            .foregroundColor(color)
            .cornerRadius(12)
    }
}

// 使用示例
struct ContentView: View {
    @State private var email = ""
    
    var body: some View {
        VStack(spacing: 20) {
            // 卡片
            VStack {
                Text("用户信息")
                Text("详细内容")
            }
            .card()
            
            // 输入框
            TextField("邮箱", text: $email)
                .textFieldStyle()
            
            // 按钮
            Text("登录")
                .primaryButtonStyle()
            
            // 标签
            HStack {
                Text("热门").tag(color: .red)
                Text("新品").tag(color: .green)
                Text("推荐").tag(color: .blue)
            }
        }
        .padding()
    }
}

8. 高级 Modifier 技巧

A. 环境 Modifier

影响所有子视图:

VStack {
    Text("标题")
    Text("副标题")
    Text("内容")
}
.font(.title)          // 所有子视图都使用 title 字体
.foregroundColor(.blue) // 所有子视图都是蓝色
B. 几何读取器配合 Modifier
GeometryReader { geometry in
    Text("响应式")
        .frame(width: geometry.size.width * 0.8)
        .position(x: geometry.size.width / 2, y: geometry.size.height / 2)
}
C. 动画 Modifier
struct AnimatedView: View {
    @State private var isExpanded = false
    
    var body: some View {
        RoundedRectangle(cornerRadius: isExpanded ? 50 : 10)
            .fill(isExpanded ? .blue : .red)
            .frame(width: isExpanded ? 200 : 100, height: 100)
            .animation(.spring(response: 0.5, dampingFraction: 0.6), value: isExpanded)
            .onTapGesture {
                isExpanded.toggle()
            }
    }
}

9. Modifier 最佳实践

 推荐做法
// 1. 提取重复的 modifier 为自定义 modifier
extension View {
    func standardCard() -> some View {
        self
            .padding()
            .background(.white)
            .cornerRadius(10)
            .shadow(radius: 5)
    }
}

// 2. 注意顺序
Text("示例")
    .padding()
    .background(.blue)
    .cornerRadius(10)  // 正确顺序

// 3. 使用语义化命名
extension View {
    func errorStyle() -> some View {
        self.foregroundColor(.red).bold()
    }
    
    func successStyle() -> some View {
        self.foregroundColor(.green).bold()
    }
}

 避免做法
// 1. 避免过长的 modifier 链
Text("Bad")
    .font(.title).foregroundColor(.blue).padding().background(.yellow).cornerRadius(10).shadow(radius: 5).opacity(0.9)
    // 太长了!应该换行

// 2. 避免重复代码
Text("Button 1")
    .padding()
    .background(.blue)
    .cornerRadius(10)

Text("Button 2")
    .padding()
    .background(.blue)
    .cornerRadius(10)
// 应该提取为自定义 modifier

// 3. 避免错误的顺序
Text("Wrong")
    .cornerRadius(10)   // 错误:在 background 之前
    .background(.blue)

总结

Modifier 核心要点:

  • ✅ Modifier 创建新视图,不修改原视图
  • ✅ 顺序非常重要
  • ✅ 支持链式调用
  • ✅ 可以自定义和复用
  • ✅ 使用语义化命名
  • ✅ 注意性能(避免过度嵌套)

SwiftUI快速入门指南-关键字篇

作者 xiAo_Ju
2025年11月25日 20:32

背景

本文帮助有Swift基础的同学,快速入门SwiftUI,基于cursour整理

主要分为四个部分:

Some

some 表示"某个特定的类型,该类型遵循某个协议"。它的特点是:

  • 隐藏具体类型:调用者不知道具体是什么类型,只知道它遵循某个协议
  • 类型固定:返回的始终是同一个具体类型(编译器知道)
  • 类型推断:编译器会自动推断出具体类型

some vs any 核心区别

特性 some any
类型确定 编译时确定,固定不变 运行时可变
性能 快(静态派发) 慢(动态派发,有装箱开销)
类型一致性 必须始终返回同一类型 可以返回不同类型
引入版本 Swift 5.1 Swift 5.6
使用场景 返回类型、属性 需要类型灵活性时
// some - 固定的具体类型
func makeSomeView() -> some View {
    Text("Hello")  // 每次调用都返回 Text 类型
}

// any - 可以是任何符合协议的类型
func makeAnyView(condition: Bool) -> any View {
    if condition {
        return Text("Hello")   // 这次返回 Text
    } else {
        return Image("icon")   // 下次可能返回 Image
    }
}

关键字

属性包装器 用途 拥有数据 数据类型 典型场景
@State 当前View状态处理 ✅ 是 值类型 简单的 UI 状态
@Binding 父子View间状态传递 ❌ 否 任意 子视图修改父状态
@StateObject 当前View引用对象,对象的生命周期在当前View ✅ 是 引用类型 视图的 ViewModel
@ObservedObject 父子View间对象状态传递,对象在父View ❌ 否 引用类型 传入的对象
@EnvironmentObject 跨View间状态传递 ❌ 否 引用类型 全局共享数据
@Environment 系统环境 ❌ 否 系统提供 系统设置和服务

1. @State - 私有状态 用于管理视图内部的简单值类型状态。

struct CounterView: View {
    @State private var count = 0
    @State private var isOn = false
    @State private var name = ""
    
    var body: some View {
        VStack {
            Text("计数: \(count)")
            Button("增加") {
                count += 1  // 修改会触发视图刷新
            }
            
            Toggle("开关", isOn: $isOn)
            TextField("姓名", text: $name)
        }
    }
}

特点:

  • ✅ 用于值类型(Int, String, Bool, struct 等)
  • ✅ 视图拥有这个状态
  • ✅ 声明为 private
  • ✅ SwiftUI 管理其生命周期
  • ✅ 修改会自动刷新视图

2. @Binding - 双向绑定

创建对父视图状态的双向绑定。

struct ParentView: View {
    @State private var isPresented = false
    
    var body: some View {
        VStack {
            Button("显示") {
                isPresented = true
            }
            
            // 传递绑定
            ChildView(isPresented: $isPresented)
        }
    }
}

struct ChildView: View {
    @Binding var isPresented: Bool  // 绑定到父视图的状态
    
    var body: some View {
        Toggle("显示状态", isOn: $isPresented)
        // 修改会同步到父视图
    }
}

特点:

  • ✅ 创建双向连接
  • ✅ 子视图可以读写父视图的状态
  • ✅ 使用 $ 传递绑定
  • ✅ 不拥有数据

3. @StateObject - 引用类型的拥有者

用于创建和拥有 ObservableObject 实例

// 1. 创建可观察对象
class ViewModel: ObservableObject {
    @Published var items: [String] = []
    @Published var isLoading = false
    
    func loadData() {
        isLoading = true
        // 加载数据...
        items = ["Item 1", "Item 2"]
        isLoading = false
    }
}

// 2. 在视图中使用
struct ContentView: View {
    @StateObject private var viewModel = ViewModel()
    
    var body: some View {
        List(viewModel.items, id: \.self) { item in
            Text(item)
        }
        .onAppear {
            viewModel.loadData()
        }
    }
}

3. @ObservedObject - 引用类型的观察者

用于观察已存在的 ObservableObject(不拥有)。

class ViewModel: ObservableObject {
    @Published var count = 0
}

struct ParentView: View {
    @StateObject private var viewModel = ViewModel()  // 拥有
    
    var body: some View {
        ChildView(viewModel: viewModel)  // 传递
    }
}

struct ChildView: View {
    @ObservedObject var viewModel: ViewModel  // 观察(不拥有)
    
    var body: some View {
        VStack {
            Text("计数: \(viewModel.count)")
            Button("增加") {
                viewModel.count += 1
            }
        }
    }
}

特点:

  • ✅ 观察从外部传入的对象
  • ❌ 不拥有对象
  • ⚠️ 视图重建时可能导致对象重新初始化(如果使用不当)

5. @EnvironmentObject - 环境对象 在视图层级中共享对象,无需逐层传递。

class UserSettings: ObservableObject {
    @Published var username = "Guest"
    @Published var isDarkMode = false
}

@main
struct MyApp: App {
    @StateObject private var settings = UserSettings()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(settings)  // 注入
        }
    }
}

struct ContentView: View {
    @EnvironmentObject var settings: UserSettings  // 自动获取
    
    var body: some View {
        VStack {
            Text("用户: \(settings.username)")
            SettingsView()  // 子视图也能访问
        }
    }
}

struct SettingsView: View {
    @EnvironmentObject var settings: UserSettings  // 直接访问
    
    var body: some View {
        Toggle("深色模式", isOn: $settings.isDarkMode)
    }
}

特点:

  • ✅ 跨层级共享数据
  • ✅ 无需逐层传递
  • ⚠️ 如果未注入会崩溃
  • ✅ 适合全局状态(用户设置、主题等)

6. @Environment - 系统环境值

访问 SwiftUI 提供的系统环境值。

struct MyView: View {
    @Environment(\.colorScheme) var colorScheme  // 深色/浅色模式
    @Environment(\.dismiss) var dismiss  // 关闭动作
    @Environment(\.horizontalSizeClass) var sizeClass  // 尺寸类别
    
    var body: some View {
        VStack {
            Text("当前模式: \(colorScheme == .dark ? "深色" : "浅色")")
            
            Button("关闭") {
                dismiss()
            }
        }
    }
}

常用环境值:

  • .colorScheme - 颜色方案
  • .dismiss - 关闭当前视图
  • .horizontalSizeClass / .verticalSizeClass - 尺寸类别
  • .locale - 本地化
  • .accessibilityEnabled - 辅助功能

最佳实践

// 1. 简单值用 @State
@State private var count = 0

// 2. 创建对象用 @StateObject
@StateObject private var viewModel = ViewModel()

// 3. 传递对象用 @ObservedObject
@ObservedObject var viewModel: ViewModel

// 4. 传递绑定用 @Binding
@Binding var isPresented: Bool

// 5. 全局共享用 @EnvironmentObject
@EnvironmentObject var settings: AppSettings

iOS一个Fancy UI的Tricky实现

作者 xiAo_Ju
2025年11月25日 20:26

背景

最近接到了一个Fancy的动效UI,主要是为了在首屏放出更多有用信息,提升用户购买转化率

这也是我近几年遇到的一个相对复杂的UI效果了。一开始看到这个效果,其实心里是没有底能不能实现的。因为在我github star的1.4k+库中,就没有见过类似的效果,而且单从视频看下来,有物理上的滑动冲突。但是别无选择,最终还是通过各种demo实验,把效果实现了。下面就给大家介绍一下实现的方式tricky在哪里

设计效果

那么这个效果Fancy在哪里呢?我们来拆解一下:

  • 可以看到头部图片区域在上滑的时候有一个放大的效果,头部区域有高斯模糊和渐变效果
  • 主要信息区域有一个Title的展开Alpha渐变动画
  • 在列表上滑,在头部放大,Title展开的同时,列表还可能往下顶

头部图片放大效果实现

其实同步的放大效果,相对来说是比较简单的,就是一个上滑的偏移量变化,计算出上滑放大的效果

Screenshot 2023-09-24 at 15.41.04.png

上滑的进度 = 当前上滑距离 / 可以上滑距离

可以上滑距离 = P2 - P1

当前上滑距离 = contentOffsetY (系统UI控件可以获取)

头图高度 = min(最小高度 + (最大高度 - 最小高度) * 上滑进度, 最大高度)

最小高度 = 半屏时头图的高度,默认是200pt

最大高度 = 全屏时屏幕的宽度,因为头图的最大尺寸宽高比是1:1

聪明的同学会发现,上面的公式中,在满足 最小高度 + (最大高度 - 最小高度) * 上滑进度 < 最大高度 时

有可能 (最大高度 - 最小高度) * 上滑进度 > 可以上滑距离

这个点,其实也是我在看到这个效果时比较担心的一个点,因为这个时候手指在屏幕上往上推,但视图却在往下顶,是不跟手的状态。

好在真机体验没有明显的体感问题,所以也没有什么特殊处理

为什么这里需要用一个上滑的进度,而不用上滑的绝对值呢?其实我一开始用的是绝对值,但是在(最大高度 - 最小高度) * 上滑进度 > 可以上滑距离时,直接把剩余的高度暴力加上,就会出现一个严重的跳动效果。

文字展开动画效果实现

这部分也是整个效果最难的,那么他到底难在哪里?下面我给大家拆解一下

首先iOS的文字UI控件,是没法做到视频中逐行展开并且带有Alpha动画的。

那么系统的控件实现不了,有什么其他办法呢?脑海里疯狂回忆我star的1.4k+库里面搜寻类似效果,结果当然是无果 又是一顿Google搜索,iOS expandable UILabel animationiOS expandable UILabel...,换了各种关键字,结果都没有找到好的解决方案。

只能硬着头皮自己想。

首先我不考虑展开效果和Alpha动画的事情,先做到,从一行上滑时变成多行。

初始效果.gif

达到这个效果还是比较简单的,我们只需要把Title label的展示行数设置成无数行,然后高度强制设置成一行的高度,滑动的时候用类似头部放大效果的公式,即可达到该效果

到这里,我内心稍微放松了一下,想的是终于有一个可以保底交付的效果了,展开动效的要是做不了,就用这个交付吧。。。

我想啊想啊想,逐行展开,逐行展开。关键是先要逐行,逐行之后再做y坐标偏移动画就简单了。

那么我能不能把文字UI控件截图,然后逐行裁剪做动画呢?

管他的,先搞个demo试试

我擦,牛逼呀,这个方法可以诶。再来看看这个方法的原理

Screenshot 2023-09-24 at 17.08.59.png

  • 第一步把文字部分生成一张图片
  • 计算出有多少行文字
  • 将每一行文字裁切成一张图片

最终效果

done.gif

完美啊!

❌
❌