阅读视图
Capacitor + React 的 iOS 侧滑返回手势
Swift 6.2 列传(第十七篇):钟灵的“雷电蟒”与测试附件
App 暴毙现场直击:如何用 MetricKit 写一份完美的“验尸报告”
一款轻量、低侵入的 iOS 新手引导组件,虽然大清都亡了
PolarisGuideKit:轻量、低侵入的 iOS 新手引导组件(遮罩挖孔 + Buddy View + 插件化)
关键词:UIKit · 新手引导 · 低侵入 · 插件扩展
GitHub:github.com/noodles1024…
背景:我为什么做这个组件?
可能是新手引导这个功能太小,随便实现一下也能用,导致没有人愿意认真写一个iOS下的新手引导组件,搜遍整个github也找不到一个在现实项目中能直接拿来用的。如果只考虑某一个具体的新手引导界面,实现起来很容易(特别是现在在AI的加持下,UI仔都不需要了)。但在不同项目、不同场景下,经过和和产品经理&设计师的多次沟通中,我发现了做“新手引导/功能提示”时的一些令人头疼的问题:
- 需要高亮某个控件,但布局变化、屏幕旋转后挖孔(高亮)位置容易偏
- 指引说明(箭头/气泡/按钮)形态不固定,可能还伴随着音频播放等附加功能,复用困难
- 点击高亮区域时,难以做到不侵入原有点击业务逻辑
- 显示新手引导时难以在不改变原有逻辑的情况下阻止NavigationController的滑动返回
- UITableView/UICollectionView
reloadData后高亮经常失效
于是我做了 PolarisGuideKit:一个基于 UIKit 的轻量新手引导组件,主打低侵入 + 可扩展 + 动态高亮。
PolarisGuideKit 能解决什么?
| 能力 | 说明 | 带来的价值 |
|---|---|---|
| 高亮遮罩 | 遮罩挖孔高亮 focusView | 高亮区域自动跟随,内置高亮效果,可自定义 |
| Buddy View | 说明视图可自由定制 | 文案、箭头、按钮任意组合 |
| 步骤编排 | 多步骤引导流程 | 支持下一步、跳过、完成 |
| 动态 focusView | reloadData 后自动修正 | TableView/CollectionView场景稳定 |
| 插件化扩展 | Audio/埋点/持久化 | 可插拔、解耦 |
快速上手(3 分钟接入)
import UIKit
import PolarisGuideKit
final class MyViewController: UIViewController {
private var guide: GuideController?
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let step = GuideStep()
step.focusView = myButton
step.buddyView = MyBuddyView()
step.forwardsTouchEventsToFocusView = true
step.completer = ControlEventCompleter(control: myButton, event: .touchUpInside)
let controller = GuideController(hostView: view, steps: [step])
controller.onDismiss = { _, context in
print("引导结束,原因 = \(context.reason)")
}
_ = controller.show()
guide = controller
}
}
核心概念一图速览
- GuideController:流程编排器,负责 show/hide/切换步骤
- GuideStep:一步引导配置(focus、buddy、style、completer)
- FocusStyle:高亮形状(矩形/圆形/圆角/无高亮)
- GuideBuddyView:说明视图(可继承自定义)
- GuidePlugin:生命周期扩展(音频/埋点/持久化)
重点能力拆解
1) FocusStyle:高亮样式可拔插
内置样式包含:
-
DefaultFocusStyle(矩形) -
RoundedRectFocusStyle(圆角矩形) -
CircleFocusStyle(圆形) -
NoHighlightFocusStyle(全屏遮罩)
let step = GuideStep()
step.focusView = someCard
step.focusStyle = RoundedRectFocusStyle(
focusCornerRadius: .followFocusView(delta: 2),
focusAreaInsets: UIEdgeInsets(top: -6, left: -6, bottom: -6, right: -6)
)
2) 动态 FocusView:Table/CollectionView不卡壳
UITableView / UICollectionView 复用导致高亮错位?
使用 focusViewProvider 动态获取最新 cell:
let step = GuideStep()
step.focusViewProvider = { [weak self] in
guard let self else { return nil }
var cell = self.tableView.cellForRow(at: targetIndexPath)
if cell == nil {
self.tableView.layoutIfNeeded()
cell = self.tableView.cellForRow(at: targetIndexPath)
}
return cell
}
3) 触摸转发 + 自动完成
在不侵入原有业务逻辑的前提下,高亮按钮依然能触发业务逻辑,同时自动关闭引导:
let step = GuideStep()
step.focusView = myButton
step.forwardsTouchEventsToFocusView = true
step.completer = ControlEventCompleter(control: myButton, event: .touchUpInside)
✅ 设置forwardsTouchEventsToFocusView和completer保证了“引导不侵入原有业务逻辑”。
4) Buddy View:说明视图随便做
继承 GuideBuddyView,自定义 UI + 布局:
final class MyBuddyView: GuideBuddyView {
override func updateLayout(referenceLayoutGuide layoutGuide: UILayoutGuide, focusView: UIView) {
super.updateLayout(referenceLayoutGuide: layoutGuide, focusView: focusView)
// 根据 layoutGuide 布局你的文案 / 按钮 / 箭头
}
}
5) 插件系统:音频 / 埋点 / 持久化
内置 AudioGuidePlugin,可在显示引导时播放音频文件,且可在BuddyView中配合显示音频播放动画(可选功能):
let step = GuideStep()
step.focusView = myCard
step.addAttachment(GuideAudioAttachment(url: audioURL, volume: 0.8))
let controller = GuideController(
hostView: view,
steps: [step],
plugins: [AudioGuidePlugin()]
)
如果想要加埋点、标记“引导是否已显示”,可通过自定义
GuidePlugin实现。
Demo 示例一览
- 圆角矩形高亮 + 圆角模式切换
- 圆形高亮 + 半径缩放
- 多步骤引导 + 平滑转场
- 触摸转发 + 自动完成
- 点击外部关闭(dismissesOnOutsideTap)
- 音频 + Lottie 同步演示
- UITableView 动态高亮
架构 & 视图层级
flowchart TB
subgraph Core["核心组件"]
GuideController["GuideController<br/>(流程编排器)"]
GuideStep["GuideStep<br/>(步骤配置)"]
end
subgraph ViewHierarchy["视图层级"]
GuideContainerView["GuideContainerView<br/>(透明容器)"]
GuideOverlayView["GuideOverlayView<br/>(遮罩 + 触摸转发)"]
MaskOverlayView["MaskOverlayView<br/>(遮罩基类)"]
GuideBuddyView["GuideBuddyView<br/>(说明视图)"]
GuideShadowView["GuideShadowView<br/>(焦点追踪器)"]
end
subgraph Extensions["扩展机制"]
FocusStyle["FocusStyle<br/>(高亮形状)"]
GuideAutoCompleter["GuideAutoCompleter<br/>(完成触发器)"]
GuidePlugin["GuidePlugin<br/>(生命周期钩子)"]
GuideStepAttachment["GuideStepAttachment<br/>(插件数据)"]
end
GuideController -->|"管理"| GuideStep
GuideController -->|"创建并承载"| GuideContainerView
GuideController -->|"派发事件"| GuidePlugin
GuideContainerView -->|"包含"| GuideOverlayView
GuideContainerView -->|"包含"| GuideBuddyView
GuideOverlayView -.->|"继承"| MaskOverlayView
GuideOverlayView -->|"创建"| GuideShadowView
GuideOverlayView -->|"使用"| FocusStyle
GuideStep -->|"配置"| GuideBuddyView
GuideStep -->|"使用"| FocusStyle
GuideStep -->|"通过...触发"| GuideAutoCompleter
GuideStep -->|"携带"| GuideStepAttachment
安装
Swift Package Manager
- Xcode → File → Add Packages…
- 输入仓库地址:
https://github.com/noodles1024/PolarisGuideKit - 选择 PolarisGuideKit
CocoaPods
pod 'PolarisGuideKit'
import PolarisGuideKit
注意事项(踩坑清单)
-
focusView必须是hostView的子视图 - 多 Scene / 多 Window 建议显式传
hostView -
GuideAutoCompleter触发后会结束整个引导(建议用于最后一步) - 动画转场在复杂形状下可关闭动画:
animatesStepTransition = false
项目地址 & 交流
- GitHub:github.com/noodles1024…
- Issues / PR:欢迎一起完善
- 如果觉得有帮助,欢迎 Star ⭐️
AT 的人生未必比 MT 更好 -- 肘子的 Swift 周报 #118
深入理解 Swift Concurrency:从 async/await 到隔离域
1月10日用户隐私保护新规出炉,政策解读
2026年1月10日,国家互联网信息办公室发布了《互联网应用程序个人信息收集使用规定(征求意见稿)》,进一步优化了个人隐私保护法案内容。
政策原文:mp.weixin.qq.com/s/epF6mh-Oc…
一句话总结:这次的新政细化了隐私合规规则,堵住了一些规则漏洞,进一步保护用户隐私。对于大部分开展正规业务的开发者来说,无需特别的改动。后续可按渠道平台(华为、小米等)要求,做进一步调整。
内容概览:
1、明确禁止“无关场景调用相机/麦克风”,直击“偷听偷拍”乱象。——旧规:仅原则性要求“不得超范围收集”,但未明确哪些行为算违规。
2、位置权限分级管理:区分“实时定位”与“单次定位” ——旧规:仅笼统要求“最小必要”。
3、进一步强化了不允许获取用户全部相册权限,必须使用系统系框架Android SAF,只能获取用户授权后的部分照片。
4、操作系统厂商,需提供“精细化授权”选项,如 “仅本次允许”“仅今天允许”。
5、生物识别信息(人脸、指纹等)原则上只能本地存储,不能上传。除非法律允许或用户单独同意。
6、App运营者对第三方SDK负连带审核义务。用户向App提出对SDK的数据权利请求(如删除),App必须转达并督促SDK响应,不能再以“这是第三方SDK行为,与我无关”推责。
7、账号注销流程进一步简化:不得强制用户提供额外身份证明(如手持身份证、学历证明等)。堵住了一些App以“安全验证”为名设置注销门槛的做法。
下面是详细介绍
一、首次 明确禁止“无关场景调用相机/麦克风” —— 直击“偷听偷拍”乱象
- 旧规:仅原则性要求“不得超范围收集”,但未明确哪些行为算违规。
-
新规(第14条):
- 必须“用户主动选择使用拍照、语音、录音录像等功能时”才能调用相机/麦克风;
- 禁止在用户停止使用相关功能后继续调用;
- 禁止在无关场景(如浏览商品页、看新闻)调用音视频权限。
实质变化:这是首次以部门规章形式将“后台静默调用麦克风/摄像头”直接定性为违规。此前企业常以“预加载”“性能优化”为由辩解,今后不再成立。
二、位置权限分级管理:区分“实时定位”与“单次定位”
- 旧规:仅笼统要求“最小必要”。
-
新规(第14条):
- 实时定位类(导航、外卖) → 调用频率必须“限于业务最低频度”;
- 单次定位类(搜索、推荐、广告) → 仅允许调用一次,且需用户进入界面或主动刷新;
- 原则上禁止申请“后台持续获取位置”权限(除非法律另有规定或确需)。
实质变化:终结了“只要用户开了定位,App就可高频后台上报位置”的灰色操作。例如,电商App在首页展示附近门店,只能触发一次定位,不能持续追踪。
三、强制使用系统级存储访问框架(如Android SAF)
-
新规(第14条):
- 用户上传图片/文件时,若系统提供标准存储访问框架(如Android的Storage Access Framework),App 不得再索要“相册”“存储”全权限。
- 即使因文件编辑/备份获得存储权限,也不得访问用户未主动选择的其他文件。
实质变化:推动从“粗放式读取整个相册”转向“按需访问单个文件”,大幅降低隐私泄露面。这要求开发者重构文件上传逻辑。
四、操作系统需提供“精细化授权”选项(开发者需适配)
-
新规(第14条):
- 操作系统在弹窗征得用户同意时,应提供基于时间、频度、精度的授权选项(如“仅本次允许”“仅今天允许”“模糊位置”)。
实质变化:虽然责任在OS厂商,但开发者需确保App能兼容这些细粒度授权(例如用户选择“仅本次允许位置”,下次使用需重新申请)。否则功能将异常。
五、生物识别信息默认本地处理,禁止网络传输
-
新规(第15条):
- 人脸、指纹等生物特征信息,默认应在终端设备本地处理和存储;
-
不得通过网络传输至服务器,除非:
- 法律法规明确允许;或
- 用户单独书面同意(且目的充分必要)。
实质变化:许多App当前将人脸照片上传服务器进行比对(如刷脸登录),今后必须评估是否真有必要。若非必要,必须改为本地验证(如Face ID/指纹API)。
六、SDK责任穿透:App运营者对第三方SDK负连带审核义务
-
新规(第19条):
- App运营者必须审核嵌入的SDK,确保其行为符合公示规则;
- 用户向App提出对SDK的数据权利请求(如删除),App必须转达并督促SDK响应。
实质变化:不能再以“这是第三方SDK行为,与我无关”推责。开发者需建立SDK准入机制,并保留沟通记录。
七、账号注销流程进一步简化(禁止设置障碍)
-
新规(第18条):
- 注销后15个工作日内必须删除或匿名化数据;
- 不得强制用户提供额外身份证明(如手持身份证、学历证明等);
- 若使用统一账号体系(如微信/QQ登录),必须支持单App注销,不影响其他服务。
实质变化:堵住了一些App以“安全验证”为名设置注销门槛的做法。
Claude Code 四大核心技能使用指南
本文详细介绍 Superpowers、Feature-Dev、UI/UX Pro Max 和 Ralph Wiggum 四个强大技能的使用方法,帮助开发者充分发挥 Claude Code 的潜力。
目录
技能系统概述
Claude Code 的技能系统是一套可组合的专业工作流,它们在特定场景下自动激活,帮助开发者以系统化的方式完成复杂任务。
核心原则
如果你认为某个技能有哪怕 1% 的可能适用于当前任务,
你就必须调用它。这不是建议,而是强制要求。
技能优先级
当多个技能可能适用时,按以下顺序使用:
- 流程技能优先(brainstorming、debugging)- 决定如何处理任务
- 实现技能其次(frontend-design、mcp-builder)- 指导具体执行
Superpowers:完整开发工作流
什么是 Superpowers?
Superpowers 是一套完整的软件开发工作流,基于可组合的"技能"构建。它从你启动编程代理的那一刻开始工作,不是直接跳入编码,而是先退一步理解你真正想要做什么。
安装方式
Claude Code 用户:
# 注册 marketplace
/plugin marketplace add obra/superpowers-marketplace
# 安装插件
/plugin install superpowers@superpowers-marketplace
# 验证安装
/help
核心工作流
Superpowers 包含七个阶段的完整开发流程:
1. Brainstorming(头脑风暴)
在编写代码之前激活,通过问答细化粗略想法,探索替代方案,分段展示设计供验证。
关键原则:
- 一次只问一个问题
- 优先使用选择题
- 无情地应用 YAGNI(你不会需要它)
- 总是提出 2-3 种方案后再定案
流程:
理解项目状态 → 逐个提问细化想法 → 提出2-3种方案
→ 分段展示设计(每段200-300字)→ 验证后保存设计文档
2. Git Worktrees(Git工作树)
设计批准后激活,在新分支上创建隔离的工作空间,运行项目设置,验证干净的测试基线。
3. Writing Plans(编写计划)
将工作分解为小任务(每个2-5分钟),每个任务都有:
- 精确的文件路径
- 完整的代码
- 验证步骤
4. Subagent-Driven Development(子代理驱动开发)
这是 Superpowers 的核心机制:
每个任务分派新的子代理 → 两阶段审查:
1. 规格合规审查(代码是否符合规格)
2. 代码质量审查(代码是否写得好)
优势:
- 每个任务有新鲜的上下文(无污染)
- 自动审查检查点
- 子代理可以在工作前后提问
示例流程:
读取计划 → 提取所有任务 → 创建TodoWrite
任务1:
[分派实现子代理] → 子代理实现、测试、提交
[分派规格审查子代理] → 确认代码符合规格
[分派代码质量审查子代理] → 批准代码质量
[标记任务完成]
任务2: ...
所有任务完成后 → 最终代码审查 → 完成开发分支
5. Test-Driven Development(测试驱动开发)
Superpowers 强制执行 RED-GREEN-REFACTOR 循环:
写失败测试 → 观察失败 → 写最小代码 → 观察通过 → 提交
铁律:
没有先失败的测试,就没有生产代码
在测试之前写了代码?删除它。从头开始。
常见借口及真相:
| 借口 | 真相 |
|---|---|
| "太简单不需要测试" | 简单代码也会出错,测试只需30秒 |
| "我之后会测试" | 立即通过的测试什么也证明不了 |
| "删除X小时的工作太浪费" | 沉没成本谬误,保留未验证的代码才是技术债务 |
| "TDD太教条" | TDD才是务实的,"务实"的捷径=生产环境调试=更慢 |
6. Code Review(代码审查)
在任务之间激活,根据计划审查代码,按严重程度报告问题。关键问题会阻止进度。
7. Finishing Branch(完成分支)
任务完成时激活,验证测试,提供选项(合并/PR/保留/丢弃),清理工作树。
核心哲学
- 测试驱动开发 - 始终先写测试
- 系统化优于临时 - 流程优于猜测
- 降低复杂性 - 简单是首要目标
- 证据优于声明 - 在宣布成功前验证
Feature-Dev:功能开发指南
什么是 Feature-Dev?
Feature-Dev 是一个系统化的功能开发技能,帮助开发者深入理解代码库、识别并询问所有不明确的细节、设计优雅的架构,然后实现。
七个阶段
Phase 1: Discovery(发现)
目标: 理解需要构建什么
操作:
-
创建包含所有阶段的 todo 列表
-
如果功能不清晰,询问用户:
- 要解决什么问题?
- 功能应该做什么?
- 有什么约束或要求?
-
总结理解并与用户确认
Phase 2: Codebase Exploration(代码库探索)
目标: 在高层和底层理解相关现有代码和模式
操作:
-
并行启动 2-3 个 code-explorer 代理,每个代理:
- 全面追踪代码,专注于理解抽象、架构和控制流
- 针对代码库的不同方面
- 包含 5-10 个关键文件列表
示例代理提示:
- "找到与 [功能] 类似的功能并全面追踪其实现"
- "映射 [功能区域] 的架构和抽象"
- "分析 [现有功能/区域] 的当前实现"
- 代理返回后,阅读所有识别的文件以建立深入理解
- 呈现发现和模式的综合摘要
Phase 3: Clarifying Questions(澄清问题)
目标: 在设计前填补空白并解决所有歧义
这是最重要的阶段之一,不能跳过。
操作:
- 审查代码库发现和原始功能请求
- 识别未明确的方面:边缘情况、错误处理、集成点、范围边界、设计偏好、向后兼容性、性能需求
- 以清晰、有组织的列表向用户呈现所有问题
- 等待答案后再进行架构设计
Phase 4: Architecture Design(架构设计)
目标: 设计具有不同权衡的多种实现方案
操作:
-
并行启动 2-3 个 code-architect 代理,关注不同方面:
- 最小变更:最小变化,最大复用
- 清洁架构:可维护性,优雅抽象
- 务实平衡:速度 + 质量
-
审查所有方案,形成哪个最适合此特定任务的意见
-
向用户呈现:
- 每种方案的简要摘要
- 权衡比较
- 你的推荐及理由
- 具体实现差异
-
询问用户偏好哪种方案
Phase 5: Implementation(实现)
目标: 构建功能
未经用户批准不要开始
操作:
- 等待用户明确批准
- 阅读前几个阶段识别的所有相关文件
- 按选定架构实现
- 严格遵循代码库约定
- 编写干净、文档完善的代码
- 随着进展更新 todos
Phase 6: Quality Review(质量审查)
目标: 确保代码简单、DRY、优雅、易读且功能正确
操作:
-
并行启动 3 个 code-reviewer 代理,关注不同方面:
- 简单性/DRY/优雅
- 缺陷/功能正确性
- 项目约定/抽象
-
整合发现,识别你建议修复的最高严重性问题
-
向用户呈现发现并询问他们想怎么做(现在修复、稍后修复或按原样进行)
-
根据用户决定处理问题
Phase 7: Summary(总结)
目标: 记录完成的工作
操作:
-
将所有 todos 标记为完成
-
总结:
- 构建了什么
- 做出的关键决策
- 修改的文件
- 建议的下一步
UI/UX Pro Max:设计智能系统
什么是 UI/UX Pro Max?
UI/UX Pro Max 是一个可搜索的 UI 设计数据库,包含 50+ 种 UI 风格、21 种调色板、50 种字体配对、20 种图表类型、8 种技术栈的最佳实践。
前提条件
确保已安装 Python:
python3 --version || python --version
使用方法
当用户请求 UI/UX 工作(设计、构建、创建、实现、审查、修复、改进)时,按以下工作流程:
步骤 1:分析用户需求
从用户请求中提取关键信息:
- 产品类型:SaaS、电商、作品集、仪表盘、着陆页等
- 风格关键词:极简、活泼、专业、优雅、深色模式等
- 行业:医疗、金融科技、游戏、教育等
-
技术栈:React、Vue、Next.js,或默认使用
html-tailwind
步骤 2:搜索相关领域
使用 search.py 多次搜索以收集全面信息:
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<关键词>" --domain <领域> [-n <最大结果数>]
推荐搜索顺序:
| 顺序 | 领域 | 用途 |
|---|---|---|
| 1 | product | 获取产品类型的风格推荐 |
| 2 | style | 获取详细风格指南(颜色、效果、框架) |
| 3 | typography | 获取字体配对和 Google Fonts 导入 |
| 4 | color | 获取配色方案(主色、辅色、CTA、背景、文字、边框) |
| 5 | landing | 获取页面结构(如果是着陆页) |
| 6 | chart | 获取图表推荐(如果是仪表盘/分析) |
| 7 | ux | 获取最佳实践和反模式 |
| 8 | stack | 获取技术栈特定指南 |
步骤 3:可用技术栈
| 栈 | 关注点 |
|---|---|
| html-tailwind | Tailwind 工具类、响应式、无障碍(默认) |
| react | 状态、hooks、性能、模式 |
| nextjs | SSR、路由、图片、API 路由 |
| vue | Composition API、Pinia、Vue Router |
| svelte | Runes、stores、SvelteKit |
| swiftui | 视图、状态、导航、动画 |
| react-native | 组件、导航、列表 |
| flutter | Widgets、状态、布局、主题 |
示例工作流
用户请求: "做一个专业护肤服务的着陆页"
# 1. 搜索产品类型
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "beauty spa wellness service" --domain product
# 2. 搜索风格
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "elegant minimal soft" --domain style
# 3. 搜索字体
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "elegant luxury" --domain typography
# 4. 搜索配色
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "beauty spa wellness" --domain color
# 5. 搜索着陆页结构
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "hero-centric social-proof" --domain landing
# 6. 搜索 UX 指南
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "animation" --domain ux
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "accessibility" --domain ux
# 7. 搜索技术栈指南
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "layout responsive" --stack html-tailwind
专业 UI 通用规则
这些是经常被忽视但会让 UI 看起来不专业的问题:
图标和视觉元素
| 规则 | 正确做法 | 错误做法 |
|---|---|---|
| 不使用 emoji 图标 | 使用 SVG 图标(Heroicons、Lucide) | 使用 emoji 作为 UI 图标 |
| 稳定的悬停状态 | 使用颜色/透明度过渡 | 使用会移动布局的缩放变换 |
| 一致的图标尺寸 | 使用固定 viewBox(24x24)配合 w-6 h-6 | 随意混合不同图标尺寸 |
交互和光标
| 规则 | 正确做法 | 错误做法 |
|---|---|---|
| 光标指针 | 给所有可点击元素添加 cursor-pointer
|
交互元素保持默认光标 |
| 悬停反馈 | 提供视觉反馈(颜色、阴影、边框) | 元素交互时无任何指示 |
| 平滑过渡 | 使用 transition-colors duration-200
|
瞬间状态变化或太慢(>500ms) |
亮/暗模式对比度
| 规则 | 正确做法 | 错误做法 |
|---|---|---|
| 亮模式玻璃卡片 | 使用 bg-white/80 或更高透明度 |
使用 bg-white/10(太透明) |
| 亮模式文字对比 | 使用 #0F172A(slate-900)作为文字 |
使用 #94A3B8(slate-400)作为正文 |
| 边框可见性 | 亮模式使用 border-gray-200
|
使用 border-white/10(不可见) |
交付前检查清单
视觉质量
- 没有使用 emoji 作为图标
- 所有图标来自一致的图标集
- 品牌 logo 正确
- 悬停状态不会导致布局偏移
交互
- 所有可点击元素有
cursor-pointer - 悬停状态提供清晰的视觉反馈
- 过渡平滑(150-300ms)
- 键盘导航的焦点状态可见
亮/暗模式
- 亮模式文字有足够对比度(最少 4.5:1)
- 玻璃/透明元素在亮模式下可见
- 边框在两种模式下都可见
布局
- 浮动元素与边缘有适当间距
- 内容不会隐藏在固定导航栏后面
- 响应式适配 320px、768px、1024px、1440px
- 移动端无水平滚动
Ralph Wiggum:迭代循环开发
什么是 Ralph Wiggum?
Ralph Wiggum 是一种基于持续 AI 代理循环的开发方法。正如 Geoffrey Huntley 所描述的: "Ralph 是一个 Bash 循环" - 一个简单的 while true,重复给 AI 代理喂入提示文件,让它迭代改进工作直到完成。
这个技术以《辛普森一家》中的 Ralph Wiggum 命名,体现了不顾挫折持续迭代的哲学。
核心概念
这个插件使用 Stop hook 实现 Ralph,拦截 Claude 的退出尝试:
# 你只运行一次:
/ralph-loop "你的任务描述" --completion-promise "DONE"
# 然后 Claude Code 自动:
# 1. 处理任务
# 2. 尝试退出
# 3. Stop hook 阻止退出
# 4. Stop hook 喂入相同的提示
# 5. 重复直到完成
循环发生在你当前会话内 - 你不需要外部 bash 循环。
这创建了一个自引用反馈循环:
- 迭代之间提示不变
- Claude 之前的工作保留在文件中
- 每次迭代看到修改的文件和 git 历史
- Claude 通过读取文件中自己过去的工作自主改进
快速开始
/ralph-loop "构建一个 todos 的 REST API。要求:CRUD 操作、输入验证、测试。完成后输出 <promise>COMPLETE</promise>。" --max-iterations 50 --completion-promise "COMPLETE"
Claude 将:
- 迭代实现 API
- 运行测试并看到失败
- 根据测试输出修复 bug
- 迭代直到满足所有要求
- 完成后输出完成承诺
命令
/ralph-loop
在当前会话中启动 Ralph 循环。
/ralph-loop "<提示>" --max-iterations <n> --completion-promise "<文本>"
选项:
-
--max-iterations <n>- N 次迭代后停止(默认:无限) -
--completion-promise <text>- 表示完成的短语
/cancel-ralph
取消活动的 Ralph 循环。
/cancel-ralph
提示编写最佳实践
1. 清晰的完成标准
错误示例:
构建一个 todo API 并让它好用。
正确示例:
构建一个 todos 的 REST API。
完成条件:
- 所有 CRUD 端点工作正常
- 输入验证就位
- 测试通过(覆盖率 > 80%)
- 带 API 文档的 README
- 输出:<promise>COMPLETE</promise>
2. 增量目标
错误示例:
创建一个完整的电商平台。
正确示例:
阶段 1:用户认证(JWT、测试)
阶段 2:产品目录(列表/搜索、测试)
阶段 3:购物车(添加/删除、测试)
所有阶段完成后输出 <promise>COMPLETE</promise>。
3. 自我纠正
错误示例:
为功能 X 写代码。
正确示例:
使用 TDD 实现功能 X:
1. 写失败测试
2. 实现功能
3. 运行测试
4. 如果失败,调试并修复
5. 需要时重构
6. 重复直到全部通过
7. 输出:<promise>COMPLETE</promise>
4. 安全阀
始终使用 --max-iterations 作为安全网,防止在不可能的任务上无限循环:
# 推荐:始终设置合理的迭代限制
/ralph-loop "尝试实现功能 X" --max-iterations 20
哲学
Ralph 体现几个关键原则:
| 原则 | 说明 |
|---|---|
| 迭代 > 完美 | 不要追求第一次就完美。让循环细化工作。 |
| 失败是数据 | "确定性的失败"意味着失败是可预测的和有信息量的。 |
| 操作员技能很重要 | 成功取决于写好提示,不只是有好模型。 |
| 坚持获胜 | 持续尝试直到成功。循环自动处理重试逻辑。 |
适用场景
适合:
- 有明确成功标准的定义良好的任务
- 需要迭代和细化的任务(如让测试通过)
- 你可以走开的绿地项目
- 有自动验证的任务(测试、linter)
不适合:
- 需要人类判断或设计决策的任务
- 一次性操作
- 成功标准不清楚的任务
- 生产环境调试(使用针对性调试代替)
真实世界成果
- 在 Y Combinator 黑客马拉松测试中一夜成功生成 6 个仓库
- 一份 297 API 成本完成
- 用这种方法在 3 个月内创建了整个编程语言("cursed")
技能选择指南
场景对照表
| 场景 | 推荐技能 |
|---|---|
| 构建新功能 | Feature-Dev → Superpowers |
| UI/UX 设计实现 | UI/UX Pro Max |
| 长时间自主任务 | Ralph Wiggum |
| 完整项目开发 | Superpowers(完整工作流) |
| 修复 bug | Superpowers(systematic-debugging) |
| 代码审查 | Superpowers(code-reviewer) |
组合使用
这些技能可以组合使用:
- Feature-Dev + UI/UX Pro Max:开发带 UI 的新功能
- Ralph Wiggum + Superpowers:自主完成带 TDD 的长任务
- Superpowers + Feature-Dev:完整的企业级功能开发
总结
| 技能 | 核心价值 | 最佳场景 |
|---|---|---|
| Superpowers | 完整的 TDD 工作流 | 项目开发全流程 |
| Feature-Dev | 系统化功能开发 | 新功能实现 |
| UI/UX Pro Max | 设计智能数据库 | UI 设计和前端开发 |
| Ralph Wiggum | 自主迭代循环 | 长时间自动化任务 |
黄金法则
如果你认为某个技能有哪怕 1% 的可能适用,就必须使用它。
这不是可选的,这是强制的。你不能为绕过它找理由。
iOS实现 WKWebView 长截图的优雅方案
在 iOS 开发中,为 WKWebView 实现长截图功能是一个常见且棘手的需求。开发者通常会遇到以下几个痛点:
- 网页内容高度不确定
- 滚动区域难以完整截取
- 截图过程中的界面闪烁影响用户体验
本文将介绍一种高效、稳定的解决方案,通过分段渲染与图像拼接,完美捕获整个网页内容,并提供可直接集成的完整代码。
🎯 核心思路
我们的方案主要分为三个清晰的步骤:
- 布局调整:将 WebView 移至临时容器,为完整渲染做准备。
- 分段渲染:按屏幕高度分段捕获内容,生成多张切片图像。
- 图像拼接:将所有切片图像无缝拼接成一张完整的长图。
这种方法巧妙地绕过了直接截取
UIScrollView的局限性,同时通过遮罩视图,保证了用户界面的视觉稳定性,避免闪烁。
💻 完整实现代码
WKWebView分类中添加长截图方法
- WKWebView+Capture.h
#import <WebKit/WebKit.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface WKWebView (Capture)
/**
* 捕获 WKWebView 的完整内容并生成长截图
* @param completion 完成回调,返回拼接好的长图(失败则返回 nil)
*/
- (void)captureEntireWebViewWithCompletion:(void (^)(UIImage * _Nullable capturedImage))completion;
@end
NS_ASSUME_NONNULL_END
- WKWebView+Capture.m
#import "WKWebView+Capture.h"
@implementation WKWebView (Capture)
/**
* 捕获 WKWebView 的完整内容并生成长截图
* @param completion 完成回调,返回拼接好的长图(失败则返回 nil)
*/
- (void)captureEntireWebViewWithCompletion:(void (^)(UIImage *capturedImage))completion {
// ⚠️ 关键:确保在主线程执行
if (![NSThread isMainThread]) {
NSLog(@"错误:WebView 截图必须在主线程执行");
if (completion) completion(nil);
return;
}
// 步骤1: 检查父视图并保存原始状态
UIView *parentView = self.superview;
if (!parentView) {
if (completion) completion(nil);
return;
}
CGRect originalFrame = self.frame;
CGPoint originalContentOffset = self.scrollView.contentOffset;
// 步骤2: 创建遮罩视图,保持界面"静止"的视觉效果,可以额外添加loading
UIView *snapshotCoverView = [self snapshotViewAfterScreenUpdates:NO];
snapshotCoverView.frame = self.frame; // 确保遮罩视图位置与 WebView 完全一致
[parentView insertSubview:snapshotCoverView aboveSubview:self];
// 步骤3: 创建隐藏的临时窗口和容器
UIWindow *temporaryWindow = [[UIWindow alloc] initWithFrame:self.bounds];
temporaryWindow.windowLevel = UIWindowLevelNormal - 1000; // 置于底层
temporaryWindow.hidden = NO;
temporaryWindow.alpha = 0;
temporaryWindow.userInteractionEnabled = NO;
UIView *captureContainerView = [[UIView alloc] initWithFrame:self.bounds];
captureContainerView.clipsToBounds = YES;
// 将 WebView 移入临时容器
[self removeFromSuperview];
[captureContainerView addSubview:self];
[temporaryWindow addSubview:captureContainerView];
// 步骤4: 获取完整内容高度并调整布局
CGFloat fullContentHeight = self.scrollView.contentSize.height;
self.frame = CGRectMake(0, 0, originalFrame.size.width, fullContentHeight);
self.scrollView.contentOffset = CGPointZero;
__weak typeof(self) weakSelf = self;
// ⭐ 延迟执行,确保 WebView 内容布局与渲染完成
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
if (completion) completion(nil);
return;
}
// 步骤5: 分段截图核心逻辑
CGFloat pageHeight = captureContainerView.bounds.size.height; // 单屏高度
CGFloat totalHeight = fullContentHeight; // 总内容高度
NSMutableArray<UIImage *> *imageSlices = [NSMutableArray array];
CGFloat offsetY = 0;
while (offsetY < totalHeight) {
CGFloat remainingHeight = totalHeight - offsetY;
CGFloat sliceHeight = MIN(pageHeight, remainingHeight);
// 处理最后一段高度不足一屏的情况
if (remainingHeight < pageHeight) {
CGRect containerFrame = captureContainerView.frame;
containerFrame.size.height = remainingHeight;
captureContainerView.frame = containerFrame;
}
// 移动 WebView,将当前要截取的区域"暴露"出来
CGRect webViewFrame = strongSelf.frame;
webViewFrame.origin.y = -offsetY;
strongSelf.frame = webViewFrame;
// 渲染当前分段到图像上下文
UIGraphicsBeginImageContextWithOptions(
CGSizeMake(originalFrame.size.width, sliceHeight),
NO,
[UIScreen mainScreen].scale
);
CGContextRef context = UIGraphicsGetCurrentContext();
CGFloat scaleX = originalFrame.size.width / captureContainerView.bounds.size.width;
CGFloat scaleY = sliceHeight / captureContainerView.bounds.size.height;
CGContextScaleCTM(context, scaleX, scaleY);
[captureContainerView.layer renderInContext:context];
UIImage *sliceImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
if (sliceImage) {
[imageSlices addObject:sliceImage];
}
offsetY += sliceHeight; // 移动到下一段
}
UIImage *finalImage = nil;
// 步骤6: 图像拼接
if (imageSlices.count == 1) {
finalImage = imageSlices.firstObject;
} else if (imageSlices.count > 1) {
UIGraphicsBeginImageContextWithOptions(
CGSizeMake(originalFrame.size.width, totalHeight),
NO,
[UIScreen mainScreen].scale
);
CGFloat drawOffsetY = 0;
for (UIImage *slice in imageSlices) {
[slice drawInRect:CGRectMake(0,
drawOffsetY,
slice.size.width,
slice.size.height)];
drawOffsetY += slice.size.height;
}
finalImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
}
// 步骤7: 恢复原始状态
[strongSelf removeFromSuperview];
[captureContainerView removeFromSuperview];
temporaryWindow.hidden = YES;
strongSelf.frame = originalFrame;
strongSelf.scrollView.contentOffset = originalContentOffset;
[parentView insertSubview:strongSelf belowSubview:snapshotCoverView];
[snapshotCoverView removeFromSuperview];
// 步骤8: 在主线程回调最终结果
if (completion) {
completion(finalImage);
}
});
}
@end
📱 效果展示
🚀 使用方法
调用方式非常简单,只需一行代码。
// 在需要截图的地方调用
[webView captureEntireWebViewWithCompletion:^(UIImage *capturedImage) {
if (capturedImage) {
// ✅ 截图成功,处理结果
// 例如:保存到相册
UIImageWriteToSavedPhotosAlbum(capturedImage, nil, nil, nil);
// 或:上传、分享、预览等
} else {
// ❌ 截图失败
NSLog(@"截图失败");
}
}];
📝 总结
本文提供的方案通过以下关键技术,优雅地解决了 WKWebView 长截图的难题:
- 临时容器管理:隔离渲染环境,避免干扰主界面。
- 分段渲染:将长内容分解为多个可管理的屏幕片段。
- 状态恢复:完整保存并恢复 WebView 的原始状态,确保业务无感知。
如果你有更好的实现思路,或在实际应用中遇到了特殊场景,欢迎在评论区分享交流!
Swift 方法派发深度探究
“死了么”App荣登付费榜第一名!
AppStore卡审依旧存在,预计下周将逐渐恢复常态!
背景
圣诞节🎄虽然结束了,后劲儿依旧在。最直观的感受就是AppStore审核节奏还未恢复正常。依然存在审核时间较久或等待审核时间过长的问题。
举一个直观的例子🌰:
一座5层高的商场,每层都预备了洗手间🚾。正常情况下,足够满足整座商城客流量的需求。但是赶上了节假日高峰,并且只有3层洗手间可用。那么在常态客流量不变的情况也已经拥挤,更不要说节假日高峰期。
就第三方上架&更新趋势来看,AppStore审核节奏也将逐步正常。
非必要迭代
如果不是遇到重大线上问题或重大功能迭代,建议不更新或不上新包。避免正常产品遭遇卡审状态,导致难以定位问题或者审核员摆烂直接一手4.3a。
毕竟AppStore审核团队,刚刚经历了年关肯定积压了大量待审核的产品,多少也有些烦躁。(PS:单纯从心理角度来讲)
新包、新账号和新代码,“三新原则”基本上叠满了卡审buffer。【特指中国大陆的开发者,海外账号亲测影响不大。】
重大更新
对于产品有着节前活动或市场战略布局的产品,那么也不用担心。在AppStore依然存在便捷通道:即加急审核!
常规产品,不必担心,这是官方提供的合理渠道,确实保障开发者的紧急需求【AppStore中的急诊室】。
遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!
相关推荐
# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。
2026 年 Expo + React Native 项目接入微信分享完整指南
2026 年 Expo + React Native 项目接入微信分享完整指南
本文基于 Expo SDK 54 + React Native 0.81 + react-native-wechat-lib 1.1.27 的实战经验,详细记录了在 Expo 管理的 React Native 项目中接入微信分享功能的完整流程和踩坑记录。
前言
在 React Native 生态中,react-native-wechat-lib 是目前最常用的微信 SDK 封装库。但由于该库更新较慢,加上 Expo 的特殊性,接入过程中会遇到不少坑。本文将分享我们在生产项目中的完整接入方案。
技术栈
- Expo SDK: 54.0.30
- React Native: 0.81.5
- react-native-wechat-lib: 1.1.27
- 构建方式: EAS Build
整体流程
准备工作 → 安装依赖 → 创建 Expo 插件 → 配置 app.config.js →
编写 JS 服务层 → 服务器配置 → 微信开放平台配置 → 构建测试
第一步:准备工作
1.1 微信开放平台配置
- 登录 微信开放平台
- 创建移动应用,获取 AppID
- 配置 iOS 应用信息:
- Bundle ID:
com.yourapp - Universal Link:
https://yourdomain.com/open/
- Bundle ID:
1.2 Apple Developer 配置
- 获取 Team ID(格式如
A1B2C3D4E5) - 确认 Bundle ID 与微信开放平台一致
第二步:安装依赖
npm install react-native-wechat-lib@1.1.27
⚠️ 注意:在 Expo 管理的项目中,不需要手动执行
pod install,EAS Build 会自动处理。
第三步:创建 Expo Config Plugin
由于 Expo 管理原生代码,我们需要通过 Config Plugin 来配置微信 SDK 所需的原生设置。
创建 plugins/withWechat.js:
const { withInfoPlist, withAndroidManifest } = require("expo/config-plugins");
/**
* 微信 SDK Expo Config Plugin
* 自动配置 iOS 和 Android 的微信相关设置
*/
function withWechat(config, { appId, universalLink }) {
if (!appId) {
throw new Error("withWechat: appId is required");
}
// iOS 配置
config = withInfoPlist(config, (config) => {
// 添加微信 URL Scheme
const urlTypes = config.modResults.CFBundleURLTypes || [];
const wechatScheme = {
CFBundleURLSchemes: [appId],
CFBundleURLName: "wechat",
};
const hasWechatScheme = urlTypes.some(
(type) =>
type.CFBundleURLSchemes &&
type.CFBundleURLSchemes.includes(appId)
);
if (!hasWechatScheme) {
urlTypes.push(wechatScheme);
}
config.modResults.CFBundleURLTypes = urlTypes;
// 添加 LSApplicationQueriesSchemes
const queriesSchemes = config.modResults.LSApplicationQueriesSchemes || [];
const wechatSchemes = ["weixin", "weixinULAPI"];
wechatSchemes.forEach((scheme) => {
if (!queriesSchemes.includes(scheme)) {
queriesSchemes.push(scheme);
}
});
config.modResults.LSApplicationQueriesSchemes = queriesSchemes;
return config;
});
// Android 配置
config = withAndroidManifest(config, (config) => {
const mainApplication = config.modResults.manifest.application?.[0];
if (!mainApplication) return config;
const packageName = config.android?.package || "com.yourapp";
const activities = mainApplication.activity || [];
const wxActivityName = `${packageName}.wxapi.WXEntryActivity`;
const hasWxActivity = activities.some(
(activity) => activity.$?.["android:name"] === wxActivityName
);
if (!hasWxActivity) {
activities.push({
$: {
"android:name": wxActivityName,
"android:exported": "true",
"android:launchMode": "singleTask",
"android:taskAffinity": packageName,
"android:theme": "@android:style/Theme.Translucent.NoTitleBar",
},
});
}
mainApplication.activity = activities;
return config;
});
return config;
}
module.exports = withWechat;
第四步:配置 app.config.js
module.exports = {
expo: {
name: "你的应用名",
slug: "your-app",
version: "1.0.0",
extra: {
wechatAppId: "wx你的AppID", // 微信 AppID
},
ios: {
bundleIdentifier: "com.yourapp",
associatedDomains: [
"applinks:yourdomain.com",
"webcredentials:yourdomain.com",
],
infoPlist: {
LSApplicationQueriesSchemes: ["weixin", "weixinULAPI", "wechat"],
},
},
android: {
package: "com.yourapp",
},
plugins: [
[
"./plugins/withWechat",
{
appId: "wx你的AppID",
universalLink: "https://yourdomain.com/open/",
},
],
],
},
};
第五步:编写微信服务层
创建 src/services/wechatService.ts:
import { Platform, Alert } from "react-native";
import Constants from "expo-constants";
// 从 Expo 配置中获取微信 AppID
const WECHAT_APP_ID = Constants.expoConfig?.extra?.wechatAppId || "";
// 动态加载微信 SDK
let WeChat: any = null;
let sdkLoadAttempted = false;
const getWechatSDK = () => {
if (sdkLoadAttempted) return WeChat;
sdkLoadAttempted = true;
if (Platform.OS === "web") {
return null;
}
try {
const module = require("react-native-wechat-lib");
WeChat = module.default || module;
if (!WeChat || typeof WeChat.registerApp !== "function") {
WeChat = null;
}
return WeChat;
} catch (error) {
console.warn("微信 SDK 加载失败:", error);
return null;
}
};
class WechatService {
private isRegistered = false;
// 检查 SDK 是否可用
isAvailable(): boolean {
if (Platform.OS === "web") return false;
const sdk = getWechatSDK();
return sdk !== null && typeof sdk.registerApp === "function";
}
// 注册微信 SDK
async register(): Promise<boolean> {
if (this.isRegistered) return true;
const sdk = getWechatSDK();
if (!sdk) return false;
try {
const result = await sdk.registerApp(WECHAT_APP_ID);
this.isRegistered = result;
return result;
} catch (error) {
console.error("微信 SDK 注册失败:", error);
return false;
}
}
// 检查微信是否已安装
async isWechatInstalled(): Promise<boolean> {
const sdk = getWechatSDK();
if (!sdk) return false;
try {
return await sdk.isWXAppInstalled();
} catch (error) {
return false;
}
}
// 分享网页到微信
async shareWebpage(params: {
title: string;
description: string;
thumbImageUrl?: string;
webpageUrl: string;
scene?: "session" | "timeline" | "favorite";
}): Promise<{ success: boolean; message: string }> {
if (!this.isAvailable()) {
return {
success: false,
message: Platform.OS === "web"
? "Web 端暂不支持微信分享"
: "微信分享功能需要在正式构建版本中使用"
};
}
try {
const registered = await this.register();
if (!registered) {
return { success: false, message: "微信 SDK 初始化失败" };
}
const isInstalled = await this.isWechatInstalled();
if (!isInstalled) {
return { success: false, message: "请先安装微信" };
}
const sceneMap = {
session: 0, // 聊天界面
timeline: 1, // 朋友圈
favorite: 2, // 收藏
};
const sdk = getWechatSDK();
await sdk.shareWebpage({
title: params.title,
description: params.description,
thumbImageUrl: params.thumbImageUrl || "",
webpageUrl: params.webpageUrl,
scene: sceneMap[params.scene || "session"],
});
return { success: true, message: "分享成功" };
} catch (error: any) {
if (error?.errCode === -2) {
return { success: false, message: "已取消分享" };
}
return { success: false, message: error?.message || "分享失败" };
}
}
// 分享图片到微信
async shareImage(params: {
imageUrl?: string;
imageBase64?: string;
scene?: "session" | "timeline" | "favorite";
}): Promise<{ success: boolean; message: string }> {
if (!this.isAvailable()) {
return { success: false, message: "微信分享不可用" };
}
try {
await this.register();
const isInstalled = await this.isWechatInstalled();
if (!isInstalled) {
return { success: false, message: "请先安装微信" };
}
const sceneMap = { session: 0, timeline: 1, favorite: 2 };
const sdk = getWechatSDK();
await sdk.shareImage({
imageUrl: params.imageBase64 || params.imageUrl,
scene: sceneMap[params.scene || "session"],
});
return { success: true, message: "分享成功" };
} catch (error: any) {
if (error?.errCode === -2) {
return { success: false, message: "已取消分享" };
}
return { success: false, message: "分享失败" };
}
}
}
export const wechatService = new WechatService();
第六步:服务器配置 (Universal Link)
在你的服务器上创建 apple-app-site-association 文件。
文件路径
https://yourdomain.com/.well-known/apple-app-site-association
文件内容
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": ["TEAMID.com.yourapp"],
"components": [
{ "/": "/open/*" },
{ "/": "/topic/*" }
]
}
]
},
"webcredentials": {
"apps": ["TEAMID.com.yourapp"]
}
}
⚠️ 将
TEAMID替换为你的 Apple Team ID,com.yourapp替换为你的 Bundle ID。
服务器配置要求
- 必须通过 HTTPS 访问
- Content-Type 应为
application/json - 文件名不能有
.json后缀 - 不能有重定向
Nginx 配置示例
location /.well-known/apple-app-site-association {
default_type application/json;
}
第七步:在组件中使用
import React from "react";
import { Button, Alert } from "react-native";
import { wechatService } from "@/services/wechatService";
export function ShareButton() {
const handleShare = async () => {
const result = await wechatService.shareWebpage({
title: "分享标题",
description: "分享描述",
thumbImageUrl: "https://example.com/thumb.jpg",
webpageUrl: "https://example.com/share-page",
scene: "session", // 或 "timeline" 分享到朋友圈
});
if (result.success) {
Alert.alert("成功", "分享成功");
} else {
Alert.alert("提示", result.message);
}
};
return <Button title="分享到微信" onPress={handleShare} />;
}
第八步:构建和测试
使用 EAS Build
# 构建 iOS 生产版本
eas build -p ios --profile production
# 构建并自动提交到 TestFlight
eas build -p ios --profile production --auto-submit
测试注意事项
- Expo Go 不支持:微信 SDK 是原生模块,必须使用 EAS Build 构建的版本测试
- 重启手机:安装新版本后建议重启手机,让 iOS 刷新 Associated Domains 缓存
-
验证 Universal Link:访问
https://app-site-association.cdn-apple.com/a/v1/yourdomain.com确认 Apple 已缓存配置
常见问题排查
问题 1:分享时微信没有被唤起
可能原因:
- Universal Link 配置不一致(微信开放平台、App 代码、服务器三端必须完全一致)
-
apple-app-site-association文件内容错误或无法访问 - Apple 还未缓存你的配置
排查步骤:
- 确认三端域名完全一致(注意 www 和非 www 的区别)
- 直接访问
https://yourdomain.com/.well-known/apple-app-site-association确认可以下载 - 检查 Apple CDN 缓存:
https://app-site-association.cdn-apple.com/a/v1/yourdomain.com
问题 2:SDK 注册失败
可能原因:
- AppID 配置错误
- 在 Expo Go 中运行(不支持)
解决方案:
- 确认
app.config.js中的 AppID 与微信开放平台一致 - 使用 EAS Build 构建的版本测试
问题 3:提示"请先安装微信"
可能原因:
-
LSApplicationQueriesSchemes未正确配置
解决方案:
确认 app.config.js 中包含:
infoPlist: {
LSApplicationQueriesSchemes: ["weixin", "weixinULAPI", "wechat"],
}
调试技巧
在开发阶段,可以添加调试弹窗来追踪问题:
const DEBUG_MODE = true;
const debugAlert = (title: string, message: string) => {
if (DEBUG_MODE) {
Alert.alert(`[调试] ${title}`, message);
}
};
// 在关键步骤添加调试
debugAlert("开始分享", `AppID: ${WECHAT_APP_ID}`);
debugAlert("注册结果", `registered: ${registered}`);
debugAlert("微信安装检查", `isInstalled: ${isInstalled}`);
总结
在 Expo 项目中接入微信分享的关键点:
- 使用 Config Plugin 配置原生设置,而不是手动修改原生代码
- 三端配置一致 是成功的关键(微信开放平台、App、服务器)
- Universal Link 配置正确且可访问
- 必须使用 EAS Build 构建的版本测试,Expo Go 不支持原生模块
希望这篇文章能帮助你顺利接入微信分享功能!如有问题欢迎评论区交流。
参考资料:
Luban 2 Flutter:一行代码在 Flutter 开发中实现图片压缩功能
Luban 2 Flutter —— 高效简洁的 Flutter 图片压缩插件,像素级还原微信朋友圈压缩策略。
📑 目录
📖 项目描述
目前做 App 开发总绕不开图片这个元素。但是随着手机拍照分辨率的提升,图片的压缩成为一个很重要的问题。单纯对图片进行裁切,压缩已经有很多文章介绍。但是裁切成多少,压缩成多少却很难控制好,裁切过头图片太小,质量压缩过头则显示效果太差。
于是自然想到 App 巨头"微信"会是怎么处理,Luban(鲁班)就是通过在微信朋友圈发送近100张不同分辨率图片,对比原图与微信压缩后的图片逆向推算出来的压缩算法。
因为是逆向推算,效果还没法跟微信一模一样,但是已经很接近微信朋友圈压缩后的效果,具体看以下对比!
本库是 Luban 的 Flutter 版本,使用 TurboJPEG 进行高性能图片压缩,提供简洁易用的 API 和接近微信朋友圈的压缩效果。
📊 效果与对比
| 图片类型 | 原图(分辨率, 大小) | Luban(分辨率, 大小) | Wechat(分辨率, 大小) |
|---|---|---|---|
| 标准拍照 | 3024×4032, 5.10MB | 1440×1920, 305KB | 1440×1920, 303KB |
| 高清大图 | 4000×6000, 12.10MB | 1440×2160, 318KB | 1440×2160, 305KB |
| 2K 截图 | 1440×3200, 2.10MB | 1440×3200, 148KB | 1440×3200, 256KB |
| 超长记录 | 1242×22080, 6.10MB | 758×13490, 290KB | 744×13129, 256KB |
| 全景横图 | 12000×5000, 8.10MB | 1440×600, 126KB | 1440×600, 123KB |
| 设计原稿 | 6000×6000, 6.90MB | 1440×1440, 263KB | 1440×1440, 279KB |
🔬 核心算法特性
本库采用自适应统一图像压缩算法 (Adaptive Unified Image Compression),通过原图的分辨率特征,动态应用差异化策略,实现画质与体积的最优平衡。
智能分辨率决策
- 高清基准 (1440p):默认以 1440px 作为短边基准,确保在现代 2K/4K 屏幕上的视觉清晰度
- 全景墙策略:自动识别超大全景图(长边 >10800px),锁定长边为 1440px,保留完整视野
- 超大像素陷阱:对超过 4096万像素的超高像素图自动执行 1/4 降采样处理
- 长图内存保护:针对超长截图建立 10.24MP 像素上限,通过等比缩放防止 OOM
自适应比特率控制
- 极小图 (<0.5MP):几乎不进行有损压缩,防止压缩伪影
- 高频信息图 (0.5-1MP):提高编码质量,补偿分辨率损失
- 标准图片 (1-3MP):应用平衡系数,对标主流社交软件体验
- 超大图/长图 (>3MP):应用高压缩率,显著减少体积
健壮性保障
- 膨胀回退:压缩后体积大于原图时,自动透传原图,确保绝不"负优化"
- 输入防御:妥善处理极端分辨率输入(0、负数、1px 等),防止崩溃
📦 安装
在 pubspec.yaml 文件中添加依赖:
dependencies:
luban: ^2.0.1
然后运行:
flutter pub get
💻 使用
压缩单张图片
使用 File 对象
import 'dart:io';
import 'package:luban/luban.dart';
Future<void> compressImage() async {
final file = File('/path/to/image.jpg');
final result = await Luban.compress(file);
if (result.isSuccess) {
final compressionResult = result.value;
print('压缩完成');
print('原图大小: ${compressionResult.originalSizeKb} KB');
print('压缩后大小: ${compressionResult.compressedSizeKb} KB');
print('压缩率: ${(compressionResult.compressionRatio * 100).toStringAsFixed(1)}%');
print('输出文件: ${compressionResult.file.path}');
} else {
print('压缩失败: ${result.error}');
}
}
使用字符串路径
import 'package:luban/luban.dart';
Future<void> compressImage() async {
final result = await Luban.compressPath('/path/to/image.jpg');
result.fold(
(error) => print('压缩失败: $error'),
(compressionResult) {
print('压缩完成,大小: ${compressionResult.compressedSizeKb} KB');
print('输出文件: ${compressionResult.file.path}');
},
);
}
指定输出文件
import 'dart:io';
import 'package:luban/luban.dart';
Future<void> compressImage() async {
final inputFile = File('/path/to/image.jpg');
final outputFile = File('/path/to/output/compressed.jpg');
final result = await Luban.compressToFile(inputFile, outputFile);
if (result.isSuccess) {
final compressionResult = result.value;
print('压缩完成,文件已保存到: ${compressionResult.file.path}');
}
}
指定输出目录
import 'dart:io';
import 'package:luban/luban.dart';
Future<void> compressImage() async {
final inputFile = File('/path/to/image.jpg');
final outputDir = Directory('/path/to/output');
final result = await Luban.compress(inputFile, outputDir: outputDir);
if (result.isSuccess) {
final compressionResult = result.value;
print('压缩完成,文件已保存到: ${compressionResult.file.path}');
}
}
批量压缩图片
批量压缩返回 Result<BatchCompressionResult>,需要先检查成功或失败状态,然后访问 BatchCompressionResult 获取所有图片的压缩结果。
使用文件列表
import 'dart:io';
import 'package:luban/luban.dart';
Future<void> compressBatchImages() async {
final files = [
File('/path/to/image1.jpg'),
File('/path/to/image2.jpg'),
File('/path/to/image3.jpg'),
];
final result = await Luban.compressBatch(files);
if (result.isSuccess) {
final batchResult = result.value;
print('批量压缩完成');
print('总数: ${batchResult.total}');
print('成功: ${batchResult.successCount}');
print('失败: ${batchResult.failureCount}');
for (final item in batchResult.items) {
if (item.isSuccess) {
final compressionResult = item.result.value;
print('${item.originalPath}: ${compressionResult.compressedSizeKb} KB');
} else {
print('${item.originalPath}: 压缩失败 - ${item.result.error}');
}
}
} else {
print('批量压缩失败: ${result.error}');
}
}
使用路径列表
import 'package:luban/luban.dart';
Future<void> compressBatchImages() async {
final paths = [
'/path/to/image1.jpg',
'/path/to/image2.jpg',
'/path/to/image3.jpg',
];
final result = await Luban.compressBatchPaths(paths);
result.fold(
(error) => print('批量压缩失败: $error'),
(batchResult) {
print('批量压缩完成,成功 ${batchResult.successCount}/${batchResult.total} 张');
for (final compressionResult in batchResult.successfulResults) {
print('${compressionResult.file.path}: ${compressionResult.compressedSizeKb} KB');
}
},
);
}
批量压缩并指定输出目录
import 'dart:io';
import 'package:luban/luban.dart';
Future<void> compressBatchImages() async {
final files = [
File('/path/to/image1.jpg'),
File('/path/to/image2.jpg'),
];
final outputDir = Directory('/path/to/output');
final result = await Luban.compressBatch(files, outputDir: outputDir);
if (result.isSuccess) {
final batchResult = result.value;
print('批量压缩完成,成功 ${batchResult.successCount} 张');
for (final compressionResult in batchResult.successfulResults) {
print('压缩文件: ${compressionResult.file.path}');
}
} else {
print('批量压缩失败: ${result.error}');
}
}