Swift实战:从零实现探探卡片交互与UIKit动画手势

Swift实战:从零实现探探卡片交互与UIKit动画手势 不想在一堆命令行小工具和网页demo里打转又想真正体验iOS开发全流程的话拿探探这类卡片社交App当练手项目是我这几年带新人时反复验证过的高效路径。它不像电商项目那样堆一堆页面也不像工具类App那样逻辑单一而是把UI布局、手势交互、动画、数据模型、网络请求、内存管理这些iOS开发的核心模块全部串在了一起而且做完之后成就感非常直观——毕竟“左右滑卡片”这个交互你身边每个人都能一眼看懂。这篇内容适合两类人一类是刚学完Swift基础语法正愁不知道怎么把结构体、闭包、可选型用到真项目里的人另一类是写过一些简单页面想往进阶方向走、但又不想一上来就啃大而全的开源项目的人。我会按“技术选型 - Swift语言知识点落位 - 卡片核心功能实现 - 工程化落地与问题排查”这条主线把从零到能运行的探探风格App完整拆一遍。顺便说一句我默认你用Xcode 14及以上版本、iOS 15运行环境Swift版本5.7这些都是目前最通用的组合。1. 项目整体设计与技术选型思路1.1 为什么选卡片社交App做实战先回答一个最实际的问题市面上练手项目那么多为什么非要挑探探这种卡片交互为主的App核心原因在于它的交互集中在单个页面。你不像做购物App那样需要维护一大堆页面跳转和TabBar之间的业务逻辑而是可以把精力全部压在几个关键的交互节点上卡片堆叠、左右滑飞出、动画回弹、匹配弹窗。这几个点正好覆盖了iOS开发中最容易出彩、也最容易翻车的部分——手势处理和动画。另外从视觉模型上看一个卡片本质上就是一个数据模型加一个视图。每张卡片背后对应的是用户信息包括头像、昵称、年龄、个性签名。这种映射关系在Swift里非常自然你用struct定义Person模型用UIView封装CardView再用一个数组管理当前屏幕上所有的卡片状态。1.2 技术栈选择UIKit为主SwiftUI边界说明这里我要明确一个观点虽然SwiftUI现在热度很高但探探这种重自定义卡片动画的项目我建议以UIKit为主线来搭建。原因很简单。UIKit在手势协调、动画时序、视图层级控制上非常成熟几乎你能想到的交互形态都有对应的API。比如卡片飞出屏幕后你要保证剩下的卡片平滑地“补位”上来UIKit的UIViewAnimator和基于CGAffineTransform的形变处理是经历过大量线上验证的。而SwiftUI目前做这种跨视图的复杂手势联动代码会相对绕。不是说SwiftUI不能做而是对初学者来说UIKit的学习资料更多、坑更少更容易在这个阶段建立信心。你在学完这套UIKit版之后再去看SwiftUI的对应实现会发现很多概念是相通的比如状态驱动视图更新、GestureDetector处理手势等。1.3 整体目录结构和学习节奏安排这里直接给你我搭项目的目录层级每个模块单独分组后续想替换成网络数据也方便TantanDemo/ Models/ // 数据模型Person、LikeRecord等 Views/ // 自定义视图CardView、MatchPopView等 ViewModels/ // 卡片逻辑CardViewModel Services/ // 网络请求/本地数据源封装 Helpers/ // 扩展工具类UIViewExtensions等 Resources/ // 图片、JSON等资源文件学习节奏上如果每天能抽出2小时我建议按三条主线的顺序来第一周把Swift基础语法和UI搭建串起来完成静态卡片界面第二周处理手势识别和动画让卡片能滑出去且能回弹第三周补齐数据层、匹配逻辑和内存优化顺手把工程化规范捋一遍。这样安排的好处是每一周都有可见产出不容易中途放弃。2. Swift核心知识点与项目的对应关系2.1 入门阶段从语法点到界面元素很多初学者学到闭包、可选型的时候会迷茫觉得这是用来考试的。实际上在探探项目里这些语法点无处不在。拿可选型Optional举例。网络加载头像图片时你无法百分之百保证URL一定有效。如果图片地址解析失败你应该给CardView一个默认的占位图而不是让程序崩溃。Swift的可选型绑定在这里就是标准解法func loadImage(with urlString: String?) { // urlString 可能是 nil需要解包 guard let urlString urlString, let url URL(string: urlString) else { self.imageView.image UIImage(named: placeholder) return } // 这里 url 是确定的 URL 类型 }再看闭包Closure。闭包在Swift里本质上就是一段可以传递的代码块。在探探项目里当用户点击喜欢按钮时CardView需要告诉外层控制器“这个操作发生了”这个通信方式用闭包就非常自然final class CardView: UIView { var onTapLike: ((Person) - Void)? objc private func likeButtonTapped() { guard let person person else { return } onTapLike?(person) } }闭包捕获了外部参数内部触发时往外抛数据这在后续做匹配逻辑时是一个很干净的事件流。相比代理Delegate协议闭包在简单的单点事件回调上更简洁这是很多入门项目里会用到的重要区分。2.2 进阶阶段协议、泛型、内存管理当你把第一版能滑动的卡片做完之后就该往工程化方向靠了。这里我会强制你去理解三个进阶语法协议、泛型和内存管理。协议在Swift里就像一份能力清单。比如你希望所有自定义视图都能执行统一的出场动画可以定义一个AnimationProtocol协议让CardView和MatchPopView都遵守它protocol AnimatableView { func showAnimation() func hideAnimation(completion: (() - Void)?) }这样在控制器里可以用统一的方式处理不同类型视图的动画入口。它是面向协议编程的起点对你后续阅读第三方库源码非常有帮助。泛型则用来解决“多类型复用”的问题。比如你写一个线程安全的数组包装器或者在网络层封装一个通用的JSON解码方法泛型可以让你只写一次、多处复用extension Data { func decodeModelT: Decodable(_ type: T.Type) - T? { try? JSONDecoder().decode(T.self, from: self) } }这种代码短期内看不出收益但它会让你的工具箱逐渐丰富起来。内存管理是这个阶段最容易忽略、也最值得深挖的点。我刚带项目的朋友十有八九在闭包里写出过循环引用。举一个典型的探探场景卡片飞出后你要执行一个动画完成回调回调里要刷新数据源。如果你在控制器里用普通方式捕获self控制器和闭包互相持有这个控制器就永远无法释放了。解法很简单加一个[weak self]UIView.animate(withDuration: 0.25) { [weak self] in self?.cardView.transform targetTransform }别小看这一行代码内存问题的排查成本远高于写这行代码的成本。后面我会专门再讲一次实战中怎么验证控制器到底释放没有。2.3 语言特性与项目需求的匹配策略很多人学Swift容易陷入一个误区就是想把所有语言特性都用一遍结果项目反而变得很拧巴。我的建议是每个特性按需使用按项目阶段引入。比如枚举Enum在探探项目里非常适合做用户匹配状态的定义enum MatchStatus { case idle // 未操作 case liked // 已喜欢 case passed // 已跳过 case matched // 互相喜欢 }状态用枚举集中管理比到处传Bool值要清晰得多遇到switch类型检查还能保证所有情况都被处理。再比如属性观察器willSet/didSet可以用来同步更新卡片索引和计数。但像some关键字、Result类型这类相对进阶的特性在当前项目里用到即可没必要为了用而用。等做到网络层封装时Result类型的优势会自然浮出来。3. 探探核心功能实操从卡片UI到动画手势3.1 搭建卡片数据模型与本地数据源在写任何界面之前先把数据模型确定下来。这里我用一个简单的Person结构体包含自定义解码字段方便后面接真实的JSON接口。struct Person: Decodable { let id: Int let name: String let age: Int let bio: String let avatarURL: String? enum CodingKeys: String, CodingKey { case id, name, age, bio case avatarURL avatar_url } }看这里的关键Decodable协议让我们的模型可以直接通过JSONDecoder解码。CodingKeys用来映射字段名因为服务端返回的通常是avatar_url而下划线风格在Swift里不推荐作为属性名所以在这里做一个转换。然后是数据源。开发阶段没有后端很正常我建议用一个本地JSON文件MockData.json来模拟服务端返回直接放到Bundle里[ { id: 1, name: 林小满, age: 24, bio: 咖啡因驱动的设计师, avatar_url: https://example.com/avatar1.png }, { id: 2, name: 陈二狗, age: 27, bio: 夜跑十公里鸡胸肉管饱, avatar_url: https://example.com/avatar2.png } ]再写一个LoadService来统一管理数据读取和后续网络替换。这里体现了一个工程习惯先让数据来源可替换再去做业务逻辑。final class LoadService { static func loadPersons() - [Person] { guard let url Bundle.main.url(forResource: MockData, withExtension: json), let data try? Data(contentsOf: url) else { return [] } return (try? JSONDecoder().decode([Person].self, from: data)) ?? [] } }这个文件因为只是本地读取不使用并发直接同步返回问题也不大。等到接真实接口时你需要把它改成异步的这是后话。3.2 用AutoLayout构建可复用的CardView卡片视图是整个App的门面布局上核心就三个区域顶部的大尺寸头像图片、左下角的名字和年龄、以及底部的个性签名文字。我用UIView作为容器内部放一个UIImageView和两个UILabel整体通过AutoLayout约束来实现。头像ImageView的约束比较特殊要覆盖整个卡片并且在左下角做一层渐变遮罩让文字更清楚。遮罩可以用CAGradientLayer设定colors为透明和半透明黑色location在0.6到1.0之间。这个细节是探探卡片质感的关键没有渐变遮罩文字会扎在图片上特别难看。final class CardView: UIView { private let imageView UIImageView() private let nameLabel UILabel() private let bioLabel UILabel() private let gradientLayer CAGradientLayer() override init(frame: CGRect) { super.init(frame: frame) setupUI() } private func setupUI() { layer.cornerRadius 16 layer.masksToBounds true imageView.contentMode .scaleAspectFill imageView.translatesAutoresizingMaskIntoConstraints false nameLabel.translatesAutoresizingMaskIntoConstraints false bioLabel.translatesAutoresizingMaskIntoConstraints false addSubview(imageView) addSubview(nameLabel) addSubview(bioLabel) NSLayoutConstraint.activate([ imageView.topAnchor.constraint(equalTo: topAnchor), imageView.leadingAnchor.constraint(equalTo: leadingAnchor), imageView.trailingAnchor.constraint(equalTo: trailingAnchor), imageView.bottomAnchor.constraint(equalTo: bottomAnchor), nameLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), nameLabel.bottomAnchor.constraint(equalTo: bioLabel.topAnchor, constant: -4), bioLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), bioLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), bioLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16) ]) } func configure(with person: Person) { nameLabel.text \(person.name), \(person.age) bioLabel.text person.bio // 图片加载后续替换为异步 if let urlString person.avatarURL, let url URL(string: urlString) { imageView.sd_setImage(with: url, placeholderImage: UIImage(named: placeholder)) } } }注意手动断开自动布局时要设translatesAutoresizingMaskIntoConstraints为false这是新手必踩的坑不设的话你加的约束根本不会生效因为UIKit默认生成的Autoresizing约束会和你的约束冲突。3.3 卡片堆叠与图层管理探探界面的核心效果是当前页展示的最上层卡片是完整的后面还会隐约叠加1到2张卡片造成“还有其他选择”的视觉暗示。实现方式不复杂在ViewController里维护一个CardView数组每次展示时从数组末尾取出三张分别设置不同的transform和alpha形成层叠效果private func layoutStackedCards() { // 倒序遍历后面的卡片视觉上更靠后 for (index, card) in displayedCards.enumerated() { let stackIndex displayedCards.count - 1 - index switch stackIndex { case 0: card.transform .identity card.alpha 1.0 case 1: card.transform CGAffineTransform(scaleX: 0.95, y: 0.95) .translatedBy(x: 0, y: -8) card.alpha 0.8 default: card.transform CGAffineTransform(scaleX: 0.9, y: 0.9) .translatedBy(x: 0, y: -16) card.alpha 0.6 } } }layer层级的顺序也很关键。我记得第一次写的时候把后面的卡片插入到了前面导致视觉上完全反了。正确做法是用insertSubview(_:at:)按倒序插入最上层的卡片在最后。或者用addSubview后再通过bringSubviewToFront调整但这会引入不必要的调用倒序插入最省事。3.4 拖拽手势位移换算、旋转角度和飞出判定这是整个项目最有技术含量、也最好玩的部分。先聊原理你要监听UIPanGestureRecognizer的每次位移变化通过CGAffineTransform让顶层卡片跟随手指移动同时根据横向位移距离旋转一个角度。旋转角度的计算跟手指速度有关这里直接给一套用起来比较舒服的参数。卡片的中心点相对父视图中心点的偏移量dx除以卡片宽度的0.5倍得到一个比例再乘以最大旋转角度我设的是12到15度推荐12度private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { guard let topCard displayedCards.last else { return } let translation gesture.translation(in: view) let centerX view.bounds.midX switch gesture.state { case .changed: let moveX translation.x let moveY translation.y let proportionalFactor moveX / centerX let maxRotation: CGFloat .pi / 15 // 12度 topCard.center CGPoint(x: view.center.x moveX, y: view.center.y moveY) topCard.transform CGAffineTransform(rotationAngle: proportionalFactor * maxRotation) default: break } }做完手势跟随之后要解决的核心判断是什么时候算“可以飞出”什么时候算“回弹复位”。探探的规则是横向拖动距离超过卡片宽度的一半或者松手时手指速度足够大比如x轴速度大于等于1000pt/s就飞出屏幕否则回弹。前者是位置阈值后者是速度阈值两者只要满足一个就触发。飞出动画的实现要根据当前位移方向和最终的离屏位置来算。如果用户向右滑卡片飞出屏幕后的最终中心点x坐标应该是屏幕宽度加上卡片宽度的一半。这里我直接给一套通用的结束动画private func finishPanGesture(_ gesture: UIPanGestureRecognizer, card: CardView) { let velocity gesture.velocity(in: view) let translation gesture.translation(in: view) let shouldDismiss abs(translation.x) view.bounds.width * 0.5 || abs(velocity.x) 1000 let direction: CGFloat translation.x 0 ? 1 : -1 if shouldDismiss { let targetX direction * (view.bounds.width * 1.5) UIView.animate(withDuration: 0.3, animations: { card.frame.origin.x targetX card.alpha 0 }) { _ in card.removeFromSuperview() self.didDismissCard(direction: direction) } } else { UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0, options: []) { card.transform .identity card.center self.view.center } } }这里有个细节值得注意usingSpringWithDamping设0.7左右会有比较舒服的回弹感太快太慢都会让人觉得很假。你可以拿模拟器多试几个值找到自己喜欢的阻尼感。3.5 喜欢/不喜欢按钮联动与匹配逻辑当用户点击底部的“喜欢”或者“跳过”按钮时本质上要模拟一次和手势滑出相同的动画过程。最简单的做法是手动调用finishPanGesture对应逻辑但你需要在动画里加上按钮自身的缩放反馈否则会显得干巴巴的。这一步我通常用一个单独的handleAction方法根据传入的Direction枚举设置卡片飞出方向玩偶动画时长可以更短一些比如0.25秒因为按钮触发时没有真实的拖拽距离速度感全靠动画曲线来补偿。匹配逻辑这块因为项目没有后端只做前端模拟。以一个假的匹配率数组作为数据源判断逻辑很简单点击喜欢时如果对应的喜欢记录里对方也喜欢了你就判定为匹配弹窗展示“你们互相喜欢”。这部分的代码关键在于把匹配状态和上次滑出的卡片id关联起来我是用一个字典记录所有被喜欢的卡片id再对比本地Mock的matchedPersons数组func handleLikeAction() { guard let topCard displayedCards.last, let person topCard.person else { return } didLikePersonIDs.insert(person.id) flyOutCard(direction: .right) // 模拟匹配结果 let matchedIDs Set([1, 3, 5]) if matchedIDs.contains(person.id) { DispatchQueue.main.asyncAfter(deadline: .now() 0.3) { [weak self] in self?.showMatchPopView(with: person) } } }弹窗的逻辑很简单但它是个很典型的“视图层级管理”场景你需要在所有卡片之上弹出一个半透明遮罩再在上面放一个自定义弹窗视图弹窗里包含头像和进入聊天按钮。由于这个视图层级覆盖了所有卡片我会把它放在一个容器视图中并且用tag值做层级标记避免和卡片视图的添加顺序冲突。3.6 图片异步加载与缓存处理头像图片加载看起来是个小事但其实是最容易出现卡帧和内存问题的环节。如果你在主线程用Data(contentsOf:)同步加载网络图片UI会直接卡死。正确做法是使用URLSession异步请求并配合内存缓存。在iOS开发中大多数人会直接用SDWebImage或者Kingfisher这类成熟的第三方库这是实际工程的常态一点也不丢人。真实场景里不会有人自己造轮子去处理磁盘缓存、NSURLProtocol拦截这些底层逻辑除非你所在团队有降依赖的特殊要求。import Kingfisher func loadAvatar(with urlString: String?) { guard let urlString urlString, let url URL(string: urlString) else { imageView.image UIImage(named: placeholder) return } imageView.kf.setImage(with: url, placeholder: UIImage(named: placeholder)) }单纯引入库不是重点重点是你得理解它为什么能提升体验。Kingfisher内部包含三级缓存内存缓存、磁盘缓存、网络加载所以同一张图片在第二次展示时几乎瞬时可达。这个思路在面试时也很常被问到“你是如何优化图片加载的”是一个高频题。4. 工程化落地ViewModel、页面适配与数据流4.1 ViewModel怎么组织才不乱当你把探探Demo做到这个程度代码基本已经有几百行了。如果全部塞在ViewController里后期维护非常痛苦。这时候我们应该引入MVVM把“从数据源取卡片”和“刷新卡片列表”的工作挪到ViewModel中去。我推荐的CardViewModel逻辑final class CardViewModel { private(set) var persons: [Person] [] private var currentIndex 0 func loadData(completion: (() - Void)? nil) { persons LoadService.loadPersons() completion?() } func nextCard() - Person? { guard currentIndex persons.count else { return nil } defer { currentIndex 1 } return persons[currentIndex] } func reset() { currentIndex 0 } }控制器只负责把nextCard()拿到的Person配置到CardView上数据源的增减、index的步进都不需要关心。这个设计也方便以后接网络请求时把loadData改成异步版本只需要改ViewModel内部实现不影响UI层。这里你得注意ViewController对所有卡片的管理是“显示前先调用nextCard移除时再调用nextCard”如果顺序没搞对会出现索引越界或者卡片重复的问题。我习惯把卡片生成逻辑单独抽出一个configureCard方法每次生成前统一从ViewModel取人。4.2 AutoLayout适配与屏幕旋转探探的卡片在竖屏状态下体验最佳我建议直接锁定竖屏关闭屏幕旋转支持。但即使锁定方向不同屏幕尺寸的适配仍然要做。卡片的比例是决定UI美观度的关键。我使用的比例是卡片宽度等于屏幕宽度的0.9倍卡片高度等于宽度的1.25倍。不要把所有约束写成固定值下面这种写法更安全NSLayoutConstraint.activate([ cardView.centerXAnchor.constraint(equalTo: view.centerXAnchor), cardView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -20), cardView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.9), cardView.heightAnchor.constraint(equalTo: cardView.widthAnchor, multiplier: 1.25) ])用multiplier来做等比例约束而不是硬编码宽度这样在iPhone SE和iPhone 15 Pro Max上都不会变形。按钮区域的约束同理底部操作区用SafeArea的bottomAnchor不要用固定像素去推算否则带HomeIndicator的新机型会出问题。4.3 数据状态一致性与卡片刷新机制卡片刷新机制是探探App最容易出bug的地方。想象一个场景用户连续快速滑动多张卡片如果每一次滑动结束都触发一次数据源刷新可能出现前一次动画还没执行完、后一张卡片已经被移除的竞态问题。我会定义一个刷新阈值比如当前显示卡片数量少于3张时才调用一次“补充卡片”逻辑。移除卡片和添加卡片必须放在同一个串行队列里通过UIView动画的completion回调来保证顺序。private func didDismissCard(direction: CGFloat) { guard !displayedCards.isEmpty else { return } displayedCards.removeLast() if displayedCards.count 3 { appendNewCards(count: 3 - displayedCards.count) } }只要严格坚持“先移除再补充”卡片更新的崩溃率和错乱率就能降下来。这个顺序我亲测是很多新手容易写反的一旦写反轻则卡片闪现重则整个卡片堆叠全部乱掉。4.4 Swift并发与接口替换路线图模拟数据只是权宜之计迟早要换真接口。换网络层的完整方式涉及Swift的并发编程这里我先给一个过渡方案后续接真实后端时把LoadService.loadPersons替换成异步请求即可。在进入async/await之前Swift旧项目基本都是通过URLSession的dataTask加完成回调来拿数据enum NetworkError: Error { case badURL case invalidResponse } func fetchPersons(completion: escaping (Result[Person], Error) - Void) { guard let url URL(string: https://api.example.com/persons) else { completion(.failure(NetworkError.badURL)) return } URLSession.shared.dataTask(with: url) { data, _, error in if let error error { completion(.failure(error)) return } guard let data data else { completion(.failure(NetworkError.invalidResponse)) return } do { let persons try JSONDecoder().decode([Person].self, from: data) completion(.success(persons)) } catch { completion(.failure(error)) } }.resume() }这个阶段熟练掌握Result枚举的用法就够了。等Swift 5.7之后你可以逐渐换成async/await写法代码会更线性但这不属于入门阶段必要内容。5. 常见问题与排查技巧实录5.1 闭包循环引用导致控制器无法释放这是探探项目里最容易踩的内存坑。现象就是你反复进入、退出这个页面内存一直在往上涨即使退出页面ViewController并不会被释放。排查方法简单直接在ViewController里写一个deinit方法打开Debug导航栏观察是否打印。deinit { print(\(Self.self) deinit) }如果你往上一页返回后控制台没有打印这行说明肯定有循环引用。最常见的注意力集中在两种场景第一种是动画闭包。比如UIView.animate的completion里直接用了self系统动画闭包在持有self如果self同时持有这个动画对象就构成循环。把self改成[weak self]一般就能解决。第二种是手势回调里持有的闭包。你用onTapLike这样的闭包属性时如果ViewController给闭包赋值了[weak self]它就没有问题但如果你忘了写闭包会捕获self而self又持有CardViewCardView又持有这个闭包就形成了引用环。所以凡是在闭包中需要访问self成员的时候先问自己一句这个闭包会被谁持有回答之后再做决定。5.2 卡片飞出后残留或瞬间闪回有朋友做出来的效果是卡片滑出去之后下层卡片没有平滑补位上移反而像是瞬间跳到了上位。多半原因是你在removeFromSuperview之前就重置了下层卡片的transform或者动画没有放到同一个执行队列里。我的经验是补位动画不需要额外处理只要飞出动画的completion里先移除旧卡片再调用layoutStackedCards重新布局剩余卡片用UIView.animate做一个简单的帧动画就能实现平滑补位。这里还有一个容易被忽略的点补位前下层卡片在飞出过程中可能已经因为transform缩小过需要先用layoutIfNeeded让AutoLayout帮你算好新的frame避免在旧frame上做动画。5.3 旋转角度和位移方向不一致有时候你手指往右拖卡片也平移向右了但卡片旋转的方向却是逆时针看起来非常别扭。这个问题的根源通常是你拿中心点增量算比例时用translation.x去除以了一个固定的view.bounds.width而忘记除以卡片宽度或者旋转角度的正负方向本身写反了。建议调试方法在手势回调的第一行加一个print看看translation.x的正负是否和旋转角度的正负一致。向右拖时translation.x为正rotationAngle为正意味着卡片向右上倾斜这是比较自然的交互。如果反了把rotationAngle改成负号即可。5.4 常见问题速查表为了方便你排查我把这一路上比较典型的问题汇总成了表格按现象、原因、解法三列整理现象原因解决方案头像不显示URL字符串为nil或图片地址失效使用placeholder限制avatarURL为可选类型卡片拖动迟钝主线程同步加载图片改用异步加载缓存返回页面时内存不降闭包循环引用动画/回调闭包中加[weak self]飞出后下层卡片闪跳重置transform时序不对先移除旧卡片再动画布局剩余卡片按钮点击无反馈按钮事件没有绑定到action检查addTarget方法模拟器上图片模糊使用了过小尺寸的图片资源用支持2x/3x的图片资源5.5 调试技巧Xcode视图层级调试与LLDB最后的压轴技巧必须拿出Xcode自带的两个调试工具视图层级调试器和LLDB。视图层级调试器是排查AutoLayout和视图覆盖问题的大杀器。你运行App后点击Xcode工具栏里的View Hierarchy按钮就能看到所有视图的3D展开图谁压在谁上面、谁超出了边界一目了然。有一次我排查卡片上所有按钮点击不到的问题用视图层级调试器才发现是有一个透明遮罩view挡在了按钮上面把isUserInteractionEnabled改成true就解决了。LLDB主要用于运行时动态调试。举例来说你在断点处输入po view.subviews就能快速查看view的所有子视图。输入po person则能看到Person结构体的所有字段值。在排查视图层级问题时这比反复print高效得多。另外在调试动画时你可以通过模拟器的Debug Slow Animations来放慢动画速度能逐帧观察卡片飞出过程找到动画卡顿的关键点。写在最后的一些体会项目写到“能正常滑动卡片并弹出匹配结果”的时候其实Swift入门阶段就已经走完大半了。这个过程中你掌握的AutoLayout布局、UIPanGestureRecognizer手势、CGAffineTransform形变、UIView动画、异步图片加载、闭包内存管理会在后续几乎所有iOS项目里反复用到。我给想继续深入的朋友一个建议做完探探之后可以试着把卡片交互改成别的业务形态比如改成“题库刷题卡片”、“商品种草卡片”去感受同一个交互模块在不同业务中的复用边界。改业务场景的过程中你会发现其实很多底层能力是通用的这才是这次实战最重要的收获。另外想强调一点遇到问题时多去看看Apple官方文档里的UIView和UIPanGestureRecognizer部分虽然初看很枯燥但很多UI上的坑官方文档其实都写得很清楚只是容易被忽略。调试工具加官方文档比到处搜索零碎答案要省时间得多。