尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

iOS高仿支付宝实战:从架构到UI还原的完整指南

iOS高仿支付宝实战:从架构到UI还原的完整指南 简介这是一份完整的高仿支付宝iOS工程源码包基于GitHub开源项目整理适合具备Objective-C基础、希望深入研究复杂App界面架构与交互动效的开发者。压缩包共293个文件整体仅343KB涵盖Objective-C的.m/.h源码、JSON配置文件、XIB界面布局、plist属性列表以及85张PNG图片素材各类型文件对应视图控制、数据解析、界面模板与图标资源模块边界清楚便于按需查阅。项目已实现支付宝Home页图标长按拖动排序、删除及持久化存储集成二维码/条形码扫描、服务、财富和余额宝页面其中余额宝收益数字采用动态滚动展示同时包含手势解锁界面、其他详情页等待完善模块可作为继续迭代的起点。目前已有3177人浏览学习既可当作仿写真实App的练手项目也能为课程设计或面试作品提供完整可运行的参考实现。1. 为什么选支付宝当练手项目拆解高仿背后的学习价值早几年我就想找一个能把 iOS 开发里各个知识点串起来的项目练手找来找去最后锁定了高仿支付宝这个方向。说实话网上一搜这个关键词能出来一大堆 Demo但多数都是拿几个 Tab 页面拼一拼就算完事。我自己的体会是支付宝这个 App 的界面复杂度、交互密度和信息层级在整个移动互联网产品里都是数一数二的你如果真能把它高仿到七八成像那 iOS 开发里的大半边天你基本都摸过了。先说清楚边界我做这个东西的定位是学习型 Demo目标是还原支付宝的 UI 结构、页面跳转逻辑、核心交互流程和本地数据展示用于练手和面试作品展示。它不接真实支付、不碰真实账户体系也绝对不涉及任何对支付宝品牌的冒名或误导性使用。做出来的东西是一份代码作品不是一个可以上架的仿冒产品。这个性质你一定要想明白否则后面所有技术选型都会跑偏。为什么支付宝适合当练习对象你拆开看就知道它有复杂到极致的首页宫格区、有典型的黄金八宫格图文入口、有扫码和收付款这种高频交互、有账单和明细这类典型的列表页、有需要 H5 和原生混编的运营页、还有瀑布式信息流和搜索页。一个 App 里几乎囊括了 iOS 开发的所有基础组件使用场景UITableView、UICollectionView、UINavigationController、UITabBarController、UIStackView、WKWebView、CoreData 或 SQLite、本地缓存、深链跳转。换句话说把这个项目啃下来你等于把 iOS 开发的常用兵器都过了一遍。这篇文章我就把自己从零到一实现ios-高仿支付宝这个项目的过程、架构决策和踩过的坑全部整理出来适合有基础 iOS 开发经验、想通过一个完整项目提升架构能力和 UI 还原能力的朋友。你要是刚开始学 iOS也可以对照着一步步抄作业但建议先把 UIKit 基础过一遍再来看。2. 架构设计与工程组织一个 Demo 也要有体面2.1 分层思路MVC 为主MVVM 做局部增强很多初学者拿到这个项目第一件事就是往 Main.storyboard 里拖控件。我劝你直接放弃这个念头。支付宝这种量级的界面用 storyboard 做高仿基本是给自己挖坑光首页一个页面的控件层级就够你拖到怀疑人生。我的做法是整个工程全部用代码布局配合 Masonry 或 SnapKit 做 Auto Layout这样每个页面的结构都清清楚楚review 代码的时候也方便。架构上我选了最稳妥的 MVC 分层然后在首页和账单列表这两个页面引入 MVVM 的思路做局部增强。为什么不全上 MVVM答案很简单这个项目的本质是 UI 还原和交互复刻业务逻辑并不复杂强行上 RxSwift 或 Combine 反而增加学习负担。但首页那种数据来源多样宫格入口、公告、Banner、推荐位的页面用 ViewModel 把数据组装逻辑从 Controller 里抽出来确实能减少 Massive View Controller 的问题。工程的目录结构我按功能模块划分而不是按技术类型划分AlipayDemo/ ├── AppDelegate/ ├── Modules/ │ ├── Home/ │ ├── Money/ │ ├── Bills/ │ ├── Profile/ │ └── Common/ ├── Core/ │ ├── Network/ │ ├── Cache/ │ └── Router/ ├── Resources/ └── SupportingFiles/这个结构与支付宝按业务线组织的思路是一致的。你以后接手任何大型项目都会发现团队更倾向于按业务模块来划目录而不是把所有的 View 丢进一个 Views 文件夹、所有的 Controller 丢进另一个 Controllers 文件夹。从一开始就按模块划后续加功能、删功能都干净利落。2.2 页面路由用 URL 驱动的思想统一跳转逻辑支付宝这类 App 一个显著特征就是页面跳转路径极其复杂从首页点公告要跳 H5、点账单要跳列表、点扫码要跳相机页、收款码页面还要支持从后台推送直接拉起。如果你每个跳转都写pushViewController代码会迅速腐化。我这里前期就用了一个轻量级 Router 来解决页面间通信和跳转问题。Router 的核心思路是页面即 URL。每一页注册一个路径比如Router.register(alipay://home) { params in let vc HomeViewController() return vc } Router.register(alipay://bills/detail) { params in let vc BillDetailViewController(billID: params[billID]) return vc }调用方只需要Router.open(alipay://bills/detail?billID10086)完全不用 import 目标页面的类。好处有三个一是模块之间的耦合度大幅降低二是后续如果要接深链Universal Links或者 App Clips 的拉起逻辑Router 可以直接复用三是测试的时候可以直接通过 URL 跳转到任意指定页面配合 XCUITest 做自动化测试简直不要太方便。2.3 为什么控制器需要瘦身从首页代码量说起我自己写首页第一版时HomeViewController 里堆了大约 800 行代码代理方法、数据组装、下拉刷新、红点逻辑全在一起改一个功能要翻半天。后来我做了两件事一是把首页拆成三个子组件——顶部搜索与扫一扫区域、金刚区宫格、信息流列表分别封装成独立的 UIView 子类二是把数据组装逻辑抽到 HomeViewModel 里。拆完之后效果立竿见影HomeViewController 只剩下大约 200 行核心只做子组件的拼接和事件转发。这里要特别说一下事件转发这是很多人在拆组件时容易踩的坑。子组件要处理点击事件不能直接把 Target-Action 或闭包写死在子组件内部再去引用其他页面正确做法是定义 delegate 或 closure 回调由 Controller 统一处理跳转。比如金刚区的每一个宫格按钮点击之后回调onTapGridItem(item: GridItemModel)再由 Controller 决定是 push 还是 present这样子组件就可以完全复用。3. UI 还原的核心难点从布局细节到交互手感3.1 首页宫格区UIStackView 让布局代码减少一半支付宝首页最上方那个扫一扫、付钱、收钱、出行四个大金刚图标然后是手机充值、信用卡还款、花呗这类服务入口这个区域是高仿的第一个硬骨头。它本质是一个固定行数、自适应列数的宫格布局。很多人在网上找 UICollectionView 的流水布局方案来做但我觉得用 UIStackView 是最快的方案而且代码量最少。思路是这样的外层一个纵向 UIStackView每个 subview 是一行横向 UIStackView每个横向 stack 里放若干个 gridItem 视图。每个 gridItem 内部又是一个纵向 UIStackView上面 UIImageView、下面 UILabel。private func createGridRow(items: [GridItemModel]) - UIView { let rowStack UIStackView() rowStack.axis .horizontal rowStack.distribution .fillEqually rowStack.spacing 0 for item in items { let itemView GridItemView(model: item) itemView.onTap { [weak self] model in self?.handleGridTap(model) } rowStack.addArrangedSubview(itemView) } return rowStack }为什么说 UIStackView 适合这种场景因为它天生处理了等分宽度和间距这两个最啰嗦的约束。你不需要给每个 item 单独写 leading、trailing、width 约束只要设置 distribution 和 spacing系统自动帮你排。这在 iOS 9 之后就是官方推荐的布局方案热词里的ios oc uistackview也说明大家确实在关注这个。但这里有一个细节必须注意UIStackView 的 spacing 只能设置统一间距而支付宝的宫格是图标之间距大、图标与文字之间距小这种复杂嵌套。我的处理是每个 GridItemView 内部自己负责图标和文字之间的布局外部 rowStack 只管行内各 item 的等分。嵌套使用 UIStackView 的时候一定要记得给内部 stack 设置isLayoutMarginsRelativeArrangement或在 item 内部用约束控制内边距否则你会发现在不同尺寸的模拟器上宫格间距表现不一致。3.2 底部 TabBar 与自定义中间按钮别用现成的 UITabBarController支付宝底部的 TabBar 只有四个入口首页、理财、口碑、我的而且这四个入口的图标和间距非常紧凑。直接用系统 UITabBarController 会出现一个尴尬的问题系统的 UITabBar 并不好做选中态图标切换 数字角标 未读红点这种组合效果。所以我的方案是自定义一个 TabBar 容器。自定义 TabBar 的架构是用一个根 ContainerViewController 持有四个子 Controller自建一个 TabBarView 放在底部用 Auto Layout 把 TabBarView 的高度约束在 49pt不含安全区加上安全区底部高度。点击 TabBar 按钮时切换当前显示的子 Controllerclass MainContainerViewController: UIViewController { private let tabBarView MainTabBarView() private var currentChild: UIViewController? override func viewDidLoad() { super.viewDidLoad() // 添加子控制器、布局、初始化 tabBarView } func switchTo(index: Int) { // 移除当前 child添加新的 child // 同步 tabBarView 的选中态 } }这里容易踩的坑是如果你直接在 Container 里addChild/removeFromParent子页面的viewWillAppear等生命周期方法可能不会被正确调用。解决方式是使用transition(from:to:duration:options:animations:completion:)方法或者在切换之后手动调用生命周期方法。我自己实测下来最简单的做法是给 Container 加一个当前 child 的引用在切换时依次调用oldChild.willMove(toParent: nil)、oldChild.view.removeFromSuperview()、oldChild.removeFromParent()再newChild.willMove(toParent: self)、addChild(newChild)、view.addSubview(newChild.view)、newChild.didMove(toParent: self)。顺序不能乱乱了就等着看生命周期异常吧。3.3 富文本与长文本分页账单详情页的排版方案搜索热词里有一条ios ui文字分页排版demo这个确实是我做账单详情页时遇到的难题。账单页有一个详情描述区域需要展示一段较长的文字支付宝的排版是首屏只显示前几行超过部分折叠点击展开才全部展示。这个需求如果用 UILabel 的 numberOfLines 来做只是截断不是分页。我实现折叠效果时用的方案是先设置 UILabel 的 numberOfLines 3然后用sizeThatFits计算完整文本的高度再和 3 行的高度对比如果超出就显示展开button。展开后把 numberOfLines 设为 0重新布局。关键代码如下let fullHeight label.sizeThatFits(CGSize(width: maxWidth, height: .greatestFiniteMagnitude)).height let threeLineHeight label.font.lineHeight * 3 let isTruncated fullHeight threeLineHeight 1 button.isHidden !isTruncated label.numberOfLines isExpanded ? 0 : 3如果你的需求是真正的分页比如每页固定展示 N 行、左右翻页那就需要用到UITextViewNSLayoutManager或者自定义页码排版。我当时为了模拟账单按月分页的效果用了一个 UIScrollView 横向分页容器每一页一个 UILabel每页固定展示 5 条账单记录。这种方式操作起来很直观但要注意处理页面切换时滚动位置的恢复否则用户从详情页返回再进入时滚动位置会丢失。解决方法是记录当前页码在viewWillAppear里scrollView.setContentOffset恢复位置。3.4 弹层与 Popup 在 iOS 上的显示异常我在做付款成功和账单筛选这两个弹层时遇到了类似热词里van-popup 两个在ios显示异常的情况。当时我有两个弹窗组件一个是底部弹出的安全键盘提示一个是居中的加载动画。在部分 iOS 版本上两个弹窗叠加时会出现层级错乱后弹出的反而显示在下面。查了一圈根因是 iOS 的 keyWindow 和 UIWindowScene 在 iOS 13 之后发生了变化。很多第三方弹窗库用的是UIApplication.shared.keyWindow来获取 window但这个方法在 iOS 13 之后已经废弃返回的可能是 nil 或者错误的 window导致弹窗加到了错误层级。正确做法是使用UIApplication.shared.connectedScenes找到活动的UIWindowScene再取 keyWindow或者直接在当前控制器的 view 上添加弹窗视图。我最后的稳妥方案是做一个全局的 OverlayManager它持有一个专门的 UIWindow 实例所有弹窗都加到这一个 window 上通过windowLevel控制层级。这样不管页面怎么 push、present弹窗永远在最上层省去了一堆层级判断的麻烦。4. 数据层与本地化方案不依赖服务器也能活起来4.1 数据模型设计把假数据做成真结构高仿项目最常见的问题就是数据写死、页面写死看起来像 PPT。我的建议是把数据模型完整地建出来哪怕数据是本地 Mock 的。比如账单模块我建了BillModel、BillSectionModel、BillDetailModel三个模型分别对应单笔账单、按月份分组的账单列表、账单详情。每个模型都包含 ID、Title、Amount、Category、Date、Status 等字段。struct BillModel { let billID: String let title: String let amount: Double let category: BillCategory let date: Date let status: BillStatus } enum BillCategory: String { case food, transport, shopping, entertainment, finance }Mock 数据用 Json 文件放在 Bundle 里启动时读取并解码成模型数组。为什么用 JSON 而不是直接代码里写死因为 JSON 更接近真实项目的工作流后端返回 JSON、App 解码成 Model。你从第一天起就按这个套路来后面接入真实接口只是替换数据源的问题UI 代码完全不用改。4.2 持久化选型UserDefaults、文件、还是 SQLite这个项目里有几个数据需要持久化登录状态、首页配置信息、账单搜索历史。我的选择是登录状态用 UserDefaults本身设计就是存轻量键值搜索历史用文件存储一个 JSON 数组写入沙盒 Library 目录账单数据如果有大量增删改查再考虑 SQLite。这里说一个纯前端的替代方案如果你不想写任何原生的持久化代码也有不少人选择把 Mock 数据做成 JSON 文件直接打进包每次启动从 Bundle 读取。这种方式对 Demo 来说完全够用唯一的缺点是用户修改的数据不会保留。我建议至少把搜索历史做成真持久化这样你在面试演示的时候可以很自信地说我这里做了本地持久化而不是被一问就露怯。4.3 Web 组件混编WKWebView 加载 H5 页面支付宝的很多运营位和二级页面都是 H5高仿也得把这个混编机制做出来。iOS 上承载 H5 的组件是 WKWebView它取代了早期 UIWebView。我封装了一个WebContainerViewController可以接收一个 URL 字符串内部创建 WKWebView 加载。坑点有两个一是 WKWebView 的 cookie 不与系统 Safari 共享如果 H5 需要登录态你要通过WKWebsiteDataStore注入 cookie 或者用 JS bridge 传递 token。Demo 阶段无所谓但要知道这个机制。二是 iOS 14 之后的 App 如果要从 H5 跳回原生页面需要配置WKURLSchemeHandler或者通过 JS 注入协议我用的方案是注册一个自定义 schemeH5 里跳alipaydemo://billDetail?idxxxWKWebView的 decidePolicyFor 拦截到之后交给 Router 处理。这个机制的底层逻辑跟热词里ios浏览器唤起安装app是一致的本质都是通过 scheme 做 App 唤起。5. 踩坑实录iOS 开发里绕不过去的那些问题5.1 跨页面传值丢失一套必须避开的时序坑开发中遇到过wx.navigatebackminiprogram ios 拿不到extradata类似的问题——从 A 页跳 B 页B 页返回 A 页时带不回数据。原生场景的对应问题是用闭包或 delegate 回传数据时self 已经被释放导致回调不生效。我的建议是如果页面间需要回传数据优先用 delegate 或者闭包并且闭包里用[weak self]防止循环引用。如果是非常复杂、跨越多个层级的传值考虑引入一个轻量级的消息总线或者直接使用 NotificationCenter。但 NotificationCenter 有个缺点它不区分发起者收到通知的页面可能来自任意源。所以在 Demo 里我推荐一个更简单的方式把需要传值的目标数据放进一个单例 Session然后 target 页面在 viewWillAppear 时主动去读。5.2 模拟器与真机的差异那些模拟器没问题、真机就翻车的瞬间热词里有ios设备模拟说明很多人关注模拟器。但我必须说一句模拟器永远不能替代真机。我做这个项目时最典型的一次翻车是字体渲染模拟器上中文显示正常到了真机上某些系统字体比如苹方的渲染行高与模拟器不同导致 UILabel 撑出多余的高度底部 TabBar 被顶到安全区之外露出大片空白。这类问题怎么防一是所有约束不要写死数值比如 label 高度 40pt而是用这种不等式约束给系统渲染留出余地。二是关键页面必须做真机适配特别是 iPhone SE小屏和 iPhone Pro Max大屏这两端都要看。三是使用系统字体时用UIFont.preferredFont(forTextStyle:)配合 Dynamic Type这样系统调整字体大小时你的布局不会崩。5.3 屏幕适配分屏与多尺寸的布局策略热词里有ios分屏这个在 iPad 上很常见。虽然高仿支付宝主要针对 iPhone 的屏幕尺寸但如果你在 iPad 上跑这个项目会暴露一个布局问题如果用固定宽度约束做宫格iPad 上会变得很宽行数错乱。我最后的做法是给宫格区域设置一个最大宽度并且用NSLayoutConstraint的constant根据屏幕宽度动态计算每行 item 数。let numberOfItemsPerRow UIDevice.current.userInterfaceIdiom .pad ? 6 : 4这个看似粗糙的判断在 Demo 里非常实用。当然如果你要做到像支付宝那样在不同屏幕尺寸上自适应换行那就得用 UICollectionView 的流式布局了每次布局时根据宽度计算列数。对 UIStackView 方案来说一个折中方案是把每行固定的 item 数量做成配置项启动时根据屏幕宽度动态决定。这个我也做了实测下来在分屏场景下表现基本能接受。5.4 启动图与 LaunchScreen 的版本适配还有一个很低级但容易忘的坑如果你的项目没有配置 LaunchScreen 文件App 只能在兼容模式下运行无法使用完整的屏幕尺寸。做高仿项目时需要特别注意启动图和启动屏的尺寸配置否则在 iPhone 15 这类新机型上App 会被自动放大界面模糊。当时我这个工程是直接从旧项目拷过来的默认用的 LaunchImage后来在真机上发现页面被拉伸。换成 LaunchScreen.storyboard 之后一切正常。建议所有新工程都直接用 LaunchScreen.storyboard 方式彻底告别逐尺寸适配。6. 自动化测试与抓包验证让 Demo 真正能跑能验6.1 用 XCUITest 做一套回归冒烟测试热词里有ios自动化这块确实值得做。高仿项目的页面非常多每次改一个 UI 细节都可能把另一个页面弄坏。手工回归一遍所有页面太痛苦我的做法是用 XCUITest 写一套冒烟测试覆盖关键路径启动 App → 首页加载完成 → 点击扫一扫 → 相机页出现 → 返回 → 点击账单 → 账单列表出现 → 点击某一条账单 → 详情页出现 → 返回。func testHomeToBillDetailFlow() { let app XCUIApplication() app.launch() let homeTab app.tabBars.buttons[首页] XCTAssertTrue(homeTab.exists) homeTab.tap() app.buttons[账单].tap() let firstCell app.cells.firstMatch XCTAssertTrue(firstCell.waitForExistence(timeout: 5)) firstCell.tap() XCTAssertTrue(app.staticTexts[账单详情].exists) }这套测试跑下来大约 30 秒每次改完代码跑一遍能非常快地发现页面崩没崩、关键按钮能不能点。XCUITest 的定位测试有时候不稳如果按钮找不到建议先看看 accessibility label 是否设置。支付宝这种强 UI 项目给关键控件设置 accessibilityIdentifier 是很好的习惯既方便测试又能提高可访问性。6.2 用抓包工具验证数据流热词里有fiddler手机抓包ios教程这个对应的是网络调试场景。我在这个项目里虽然主要用本地 Mock 数据但为了模拟真实的网络加载链路也加了一个简单的 NetworkManager用 URLSession 发起请求数据源指向本地 Bundle 的 JSON 文件。为了验证请求逻辑正确我配合 Charles 做了抓包确认每次请求确实发出去了、返回的数据也确实是预期的内容。真机抓包的步骤其实很简单电脑上装好 Charles开启 SSL Proxying手机 Wi-Fi 代理指向电脑 IP端口 8888然后在手机上访问chls.pro/ssl安装 Charles 的根证书信任证书后就能解开 HTTPS 流量。这个操作在 iOS 开发调试里非常常用做任何网络请求相关的调试都离不开。6.3 模拟弱网与异常情况高仿项目的一层进阶是模拟弱网。支付宝首页会有一个 Loading 状态、网络超时会有重试页、图片加载失败会有一个占位图。如果你把这些状态都做出来面试时展示出来的成熟度是完全不一样的。Xcode 自带的 Network Link Conditioner 可以模拟不同网络环境比如设置成 3G、高延迟、丢包率 10%然后观察你的页面表现。我实测下来很多同学做的 Demo 在弱网环境下一片空白或者闪退就是因为没有处理加载和失败状态。把这些状态补上你的项目就从能看变成了能用。7. 从高仿到进阶App Clips 与上架准备的延伸思考7.1 从仿到做App Clips 能给你什么启发热词里有ios app clips开发这其实是个很好的进阶方向。支付宝的扫码即用体验跟 App Clips 的无需安装、扫码即用理念非常像。你在做高仿项目时积累的组件化能力——Router 路由、页面即 URL、本地数据快速加载——恰好是 App Clips 开发的核心基础。如果你的高仿项目已经把功能模块拆得很干净可以尝试用 App Clips 做一个扫码后直接展示某账单详情的轻量体验这比从零开始学 App Clips 要轻松得多而且面试时这是一个非常亮眼的加分项。7.2 开发者证书与上架模拟提前熟悉签名机制很多人做完了项目想装到真机上结果卡在签名这一关。这个项目虽然不打算上架但真机调试是必须的。你需要一个 Apple ID 登录 Xcode在 Signing Capabilities 里选择 Team 为 Personal TeamXcode 会自动帮你生成开发证书。如果是公司项目或者你需要给非越狱设备做分发就需要申请开发者账号并生成.p12证书文件。热词里的ios开发者app证书更新和ios证书p12免费生成说的就是这个流程。这里提醒一个常见坑免费的 Personal Team 签名的 App 只有 7 天有效期到期后需要重新签名才能继续运行。如果你像我一样用这个 Demo 做长期维护建议还是花点钱买个个人开发者账号一年 99 美元省去反复签名的麻烦。而且只有付费开发者账号才能开启推送、App Clips 和 TestFlight 内的多人测试。如果你计划把作品发到 TestFlight 给朋友体验这一步绕不过去。7.3 下一步往哪走做完这个高仿项目之后我最大的变化是看任何 App 的第一反应都不再是这个界面好看而是下意识去拆解它的页面层级、数据流和组件复用方式。我建议你做完之后也尝试做一个完全自己的项目——哪怕只是一个记账工具或者一个小工具类 App把从高仿里练出来的架构能力和 UI 还原能力用自己的想法重新组合一遍。只有走到这一步这个项目才算真正消化完了。最后分享一个我自己的小习惯每次在模拟器里跑通一个新页面我都截图保存下来和真机上的目标 App 做对比把差异写进项目的 README 文件里。这样等过几个月再回头看你能清清楚楚看到自己哪些地方做到了八成、哪些地方还差得远。这种差距清单比任何学习笔记都有用。本文还有配套的精品资源点击获取
返回列表