阅读视图

发现新文章,点击刷新页面。

Frida Hook 流程

一、整体流程总览(先建立全局认知) Frida Hook = 三件事同时成立 ① 手机上运行 frida-server ② 电脑上安装 frida / frida-tools ③ 电脑 → 通过 US

一款轻量、低侵入的 iOS 新手引导组件,虽然大清都亡了

PolarisGuideKit:轻量、低侵入的 iOS 新手引导组件(遮罩挖孔 + Buddy View + 插件化)

关键词:UIKit · 新手引导 · 低侵入 · 插件扩展

GitHub:github.com/noodles1024…

demo_cn_tiny.webp


背景:我为什么做这个组件?

可能是新手引导这个功能太小,随便实现一下也能用,导致没有人愿意认真写一个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

view_hierarchy.png


安装

Swift Package Manager

  1. Xcode → File → Add Packages…
  2. 输入仓库地址:https://github.com/noodles1024/PolarisGuideKit
  3. 选择 PolarisGuideKit

CocoaPods

pod 'PolarisGuideKit'
import PolarisGuideKit

注意事项(踩坑清单)

  • focusView 必须是 hostView 的子视图
  • 多 Scene / 多 Window 建议显式传 hostView
  • GuideAutoCompleter 触发后会结束整个引导(建议用于最后一步)
  • 动画转场在复杂形状下可关闭动画:animatesStepTransition = false

项目地址 & 交流

iOS日志系统设计

软件系统的运行过程本质上是不可见的。绝大多数行为发生在内存与线程之中。在本机调试阶段,我们可以借助断点、内存分析、控制台等手段直接观察系统状态;而一旦进入生产环境,这些能力几乎全部

1月10日用户隐私保护新规出炉,政策解读

2026年1月10日,国家互联网信息办公室发布了《互联网应用程序个人信息收集使用规定(征求意见稿)》,进一步优化了个人隐私保护法案内容。

政策原文:mp.weixin.qq.com/s/epF6mh-Oc…

原文.png

一句话总结:这次的新政细化了隐私合规规则,堵住了一些规则漏洞,进一步保护用户隐私。对于大部分开展正规业务的开发者来说,无需特别的改动。后续可按渠道平台(华为、小米等)要求,做进一步调整

内容概览
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 的潜力。


目录

  1. 技能系统概述
  2. Superpowers:完整开发工作流
  3. Feature-Dev:功能开发指南
  4. UI/UX Pro Max:设计智能系统
  5. Ralph Wiggum:迭代循环开发
  6. 技能选择指南
  7. 总结

技能系统概述

Claude Code 的技能系统是一套可组合的专业工作流,它们在特定场景下自动激活,帮助开发者以系统化的方式完成复杂任务。

核心原则

如果你认为某个技能有哪怕 1% 的可能适用于当前任务,
你就必须调用它。这不是建议,而是强制要求。

技能优先级

当多个技能可能适用时,按以下顺序使用:

  1. 流程技能优先(brainstorming、debugging)- 决定如何处理任务
  2. 实现技能其次(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(发现)

目标: 理解需要构建什么

操作:

  1. 创建包含所有阶段的 todo 列表

  2. 如果功能不清晰,询问用户:

    • 要解决什么问题?
    • 功能应该做什么?
    • 有什么约束或要求?
  3. 总结理解并与用户确认

Phase 2: Codebase Exploration(代码库探索)

目标: 在高层和底层理解相关现有代码和模式

操作:

  1. 并行启动 2-3 个 code-explorer 代理,每个代理:

    • 全面追踪代码,专注于理解抽象、架构和控制流
    • 针对代码库的不同方面
    • 包含 5-10 个关键文件列表

示例代理提示:

  • "找到与 [功能] 类似的功能并全面追踪其实现"
  • "映射 [功能区域] 的架构和抽象"
  • "分析 [现有功能/区域] 的当前实现"
  1. 代理返回后,阅读所有识别的文件以建立深入理解
  2. 呈现发现和模式的综合摘要

Phase 3: Clarifying Questions(澄清问题)

目标: 在设计前填补空白并解决所有歧义

这是最重要的阶段之一,不能跳过。

操作:

  1. 审查代码库发现和原始功能请求
  2. 识别未明确的方面:边缘情况、错误处理、集成点、范围边界、设计偏好、向后兼容性、性能需求
  3. 以清晰、有组织的列表向用户呈现所有问题
  4. 等待答案后再进行架构设计

Phase 4: Architecture Design(架构设计)

目标: 设计具有不同权衡的多种实现方案

操作:

  1. 并行启动 2-3 个 code-architect 代理,关注不同方面:

    • 最小变更:最小变化,最大复用
    • 清洁架构:可维护性,优雅抽象
    • 务实平衡:速度 + 质量
  2. 审查所有方案,形成哪个最适合此特定任务的意见

  3. 向用户呈现:

    • 每种方案的简要摘要
    • 权衡比较
    • 你的推荐及理由
    • 具体实现差异
  4. 询问用户偏好哪种方案

Phase 5: Implementation(实现)

目标: 构建功能

未经用户批准不要开始

操作:

  1. 等待用户明确批准
  2. 阅读前几个阶段识别的所有相关文件
  3. 按选定架构实现
  4. 严格遵循代码库约定
  5. 编写干净、文档完善的代码
  6. 随着进展更新 todos

Phase 6: Quality Review(质量审查)

目标: 确保代码简单、DRY、优雅、易读且功能正确

操作:

  1. 并行启动 3 个 code-reviewer 代理,关注不同方面:

    • 简单性/DRY/优雅
    • 缺陷/功能正确性
    • 项目约定/抽象
  2. 整合发现,识别你建议修复的最高严重性问题

  3. 向用户呈现发现并询问他们想怎么做(现在修复、稍后修复或按原样进行)

  4. 根据用户决定处理问题

Phase 7: Summary(总结)

目标: 记录完成的工作

操作:

  1. 将所有 todos 标记为完成

  2. 总结:

    • 构建了什么
    • 做出的关键决策
    • 修改的文件
    • 建议的下一步

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 个仓库
  • 一份 50k合同以50k 合同以 297 API 成本完成
  • 用这种方法在 3 个月内创建了整个编程语言("cursed")

技能选择指南

场景对照表

场景 推荐技能
构建新功能 Feature-Dev → Superpowers
UI/UX 设计实现 UI/UX Pro Max
长时间自主任务 Ralph Wiggum
完整项目开发 Superpowers(完整工作流)
修复 bug Superpowers(systematic-debugging)
代码审查 Superpowers(code-reviewer)

组合使用

这些技能可以组合使用:

  1. Feature-Dev + UI/UX Pro Max:开发带 UI 的新功能
  2. Ralph Wiggum + Superpowers:自主完成带 TDD 的长任务
  3. Superpowers + Feature-Dev:完整的企业级功能开发

总结

技能 核心价值 最佳场景
Superpowers 完整的 TDD 工作流 项目开发全流程
Feature-Dev 系统化功能开发 新功能实现
UI/UX Pro Max 设计智能数据库 UI 设计和前端开发
Ralph Wiggum 自主迭代循环 长时间自动化任务

黄金法则

如果你认为某个技能有哪怕 1% 的可能适用,就必须使用它。
这不是可选的,这是强制的。你不能为绕过它找理由。

iOS实现 WKWebView 长截图的优雅方案

在 iOS 开发中,为 WKWebView 实现长截图功能是一个常见且棘手的需求。开发者通常会遇到以下几个痛点:

  • 网页内容高度不确定
  • 滚动区域难以完整截取
  • 截图过程中的界面闪烁影响用户体验

本文将介绍一种高效、稳定的解决方案,通过分段渲染图像拼接,完美捕获整个网页内容,并提供可直接集成的完整代码。


🎯 核心思路

我们的方案主要分为三个清晰的步骤:

  1. 布局调整:将 WebView 移至临时容器,为完整渲染做准备。
  2. 分段渲染:按屏幕高度分段捕获内容,生成多张切片图像。
  3. 图像拼接:将所有切片图像无缝拼接成一张完整的长图。

这种方法巧妙地绕过了直接截取 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 方法派发深度探究

引言:一个危险的实验 想象一下,你正在调试一个复杂的 iOS 应用,想要在不修改源码的情况下监控所有 UIViewController 的 viewDidAppear 调用;还有如果要支持热修复,该如

“死了么”App荣登付费榜第一名!

背景 2026年初的App Store付费榜,突然杀出一匹“黑马”——一款名为 「“死了么 - 官方正版”」 的产品,以8元付费下载的模式,毫无征兆地登顶付费榜单榜首,成为今年首个现象级爆款。对于常年

AppStore卡审依旧存在,预计下周将逐渐恢复常态!

背景

圣诞节🎄虽然结束了,后劲儿依旧在。最直观的感受就是AppStore审核节奏还未恢复正常。依然存在审核时间较久或等待审核时间过长的问题。

举一个直观的例子🌰:

一座5层高的商场,每层都预备了洗手间🚾。正常情况下,足够满足整座商城客流量的需求。但是赶上了节假日高峰,并且只有3层洗手间可用。那么在常态客流量不变的情况也已经拥挤,更不要说节假日高峰期。

就第三方上架&更新趋势来看,AppStore审核节奏也将逐步正常。

非必要迭代

如果不是遇到重大线上问题或重大功能迭代,建议不更新或不上新包。避免正常产品遭遇卡审状态,导致难以定位问题或者审核员摆烂直接一手4.3a。

毕竟AppStore审核团队,刚刚经历了年关肯定积压了大量待审核的产品,多少也有些烦躁。(PS:单纯从心理角度来讲

新包、新账号和新代码,“三新原则”基本上叠满了卡审buffer。【特指中国大陆的开发者,海外账号亲测影响不大。】

重大更新

对于产品有着节前活动或市场战略布局的产品,那么也不用担心。在AppStore依然存在便捷通道:即加急审核!

常规产品,不必担心,这是官方提供的合理渠道,确实保障开发者的紧急需求【AppStore中的急诊室】。

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

相关推荐

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

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

# 如何主动提防苹果3.2f的进攻,自查防御手册(代码篇)

# 如何主动提防苹果3.2f的进攻,自查防御手册(ASO篇)

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

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

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

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

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

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 微信开放平台配置

  1. 登录 微信开放平台
  2. 创建移动应用,获取 AppID
  3. 配置 iOS 应用信息:
    • Bundle ID: com.yourapp
    • Universal Link: https://yourdomain.com/open/

1.2 Apple Developer 配置

  1. 获取 Team ID(格式如 A1B2C3D4E5
  2. 确认 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。

服务器配置要求

  1. 必须通过 HTTPS 访问
  2. Content-Type 应为 application/json
  3. 文件名不能有 .json 后缀
  4. 不能有重定向

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

测试注意事项

  1. Expo Go 不支持:微信 SDK 是原生模块,必须使用 EAS Build 构建的版本测试
  2. 重启手机:安装新版本后建议重启手机,让 iOS 刷新 Associated Domains 缓存
  3. 验证 Universal Link:访问 https://app-site-association.cdn-apple.com/a/v1/yourdomain.com 确认 Apple 已缓存配置

常见问题排查

问题 1:分享时微信没有被唤起

可能原因:

  • Universal Link 配置不一致(微信开放平台、App 代码、服务器三端必须完全一致)
  • apple-app-site-association 文件内容错误或无法访问
  • Apple 还未缓存你的配置

排查步骤:

  1. 确认三端域名完全一致(注意 www 和非 www 的区别)
  2. 直接访问 https://yourdomain.com/.well-known/apple-app-site-association 确认可以下载
  3. 检查 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 项目中接入微信分享的关键点:

  1. 使用 Config Plugin 配置原生设置,而不是手动修改原生代码
  2. 三端配置一致 是成功的关键(微信开放平台、App、服务器)
  3. Universal Link 配置正确且可访问
  4. 必须使用 EAS Build 构建的版本测试,Expo Go 不支持原生模块

希望这篇文章能帮助你顺利接入微信分享功能!如有问题欢迎评论区交流。


参考资料:

Luban 2 Flutter:一行代码在 Flutter 开发中实现图片压缩功能

Luban 2 Flutter —— 高效简洁的 Flutter 图片压缩插件,像素级还原微信朋友圈压缩策略。

📑 目录

📖 项目描述

开源地址:Gitee | Github

目前做 App 开发总绕不开图片这个元素。但是随着手机拍照分辨率的提升,图片的压缩成为一个很重要的问题。单纯对图片进行裁切,压缩已经有很多文章介绍。但是裁切成多少,压缩成多少却很难控制好,裁切过头图片太小,质量压缩过头则显示效果太差。

于是自然想到 App 巨头"微信"会是怎么处理,Luban(鲁班)就是通过在微信朋友圈发送近100张不同分辨率图片,对比原图与微信压缩后的图片逆向推算出来的压缩算法。

因为是逆向推算,效果还没法跟微信一模一样,但是已经很接近微信朋友圈压缩后的效果,具体看以下对比!

本库是 LubanFlutter 版本,使用 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}');
  }
}
❌