接入微信分享 SDK 并配置 Universal Link。
集成 WechatOpenSDK、www.zhifly.cn/suixinkan/ 通用链接与邀请页分享能力,附带 AASA 部署文件与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
59
suixinkan/Core/WeChat/WeChat.md
Normal file
59
suixinkan/Core/WeChat/WeChat.md
Normal file
@ -0,0 +1,59 @@
|
||||
# WeChat 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
WeChat 模块封装微信 Open SDK 的注册、Universal Link 回跳与分享能力。当前仅接入分享(文本、网页、图片),不调用微信支付 API。
|
||||
|
||||
## 配置
|
||||
|
||||
- `WeChatConfig`:AppID、Universal Link、AASA 部署参考信息
|
||||
- `Info.plist`:URL Scheme(AppID)、`LSApplicationQueriesSchemes`
|
||||
- `suixinkan.entitlements`:`applinks:www.zhifly.cn`
|
||||
|
||||
### Universal Link
|
||||
|
||||
- 地址:`https://www.zhifly.cn/suixinkan/`(必须以 `/` 结尾)
|
||||
- 运维需在 `https://www.zhifly.cn/.well-known/apple-app-site-association` 部署 AASA 文件
|
||||
- 示例见 [`docs/apple-app-site-association.example.json`](../../docs/apple-app-site-association.example.json)
|
||||
|
||||
### 微信开放平台
|
||||
|
||||
移动应用开发信息需填写:
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| iOS Bundle ID | `com.yuanzhixiang.suixinkan` |
|
||||
| Universal Links | `https://www.zhifly.cn/suixinkan/` |
|
||||
|
||||
## 核心类型
|
||||
|
||||
| 类型 | 职责 |
|
||||
|------|------|
|
||||
| `WeChatManager` | SDK 注册、URL/UL 回调、`WXApiDelegate` 分享结果 |
|
||||
| `WeChatShareService` | 对外分享 API:`shareText` / `shareWebPage` / `shareImage` |
|
||||
| `WeChatShareSheet` | SwiftUI 分享面板(好友/朋友圈/系统兜底) |
|
||||
| `WeChatThumbImageProcessor` | 缩略图压缩至 32KB 以内 |
|
||||
|
||||
## 回调链路
|
||||
|
||||
1. 启动:`AppDelegate.didFinishLaunching` → `WeChatManager.registerIfNeeded()`
|
||||
2. URL Scheme:`AppDelegate.openURL` + `suixinkanApp.onOpenURL`
|
||||
3. Universal Link:`AppDelegate.continueUserActivity` + `suixinkanApp.onContinueUserActivity`
|
||||
|
||||
## 业务接入
|
||||
|
||||
邀请页 `PhotographerInviteView` 通过 `WeChatShareSheet` 分享邀请链接。
|
||||
|
||||
后续举报、排队二维码等入口可复用 `WeChatShareService`。
|
||||
|
||||
## 真机验收
|
||||
|
||||
1. Safari 访问 `https://www.zhifly.cn/suixinkan/test`,下拉应出现打开 App 入口
|
||||
2. 邀请页点击「分享」→ 微信好友/朋友圈,首次可能出现「正在连接」属正常
|
||||
3. 未安装微信时走系统分享兜底
|
||||
|
||||
## 上线前
|
||||
|
||||
- 替换 `WeChatConfig.appID` 与 Info.plist URL Scheme
|
||||
- 确认开放平台 Universal Link 与代码一致
|
||||
- 确认 AASA 文件可公网 HTTPS 访问
|
||||
119
suixinkan/Core/WeChat/WeChatManager.swift
Normal file
119
suixinkan/Core/WeChat/WeChatManager.swift
Normal file
@ -0,0 +1,119 @@
|
||||
//
|
||||
// WeChatManager.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WechatOpenSDK
|
||||
|
||||
/// 微信 SDK 生命周期与回调管理,负责注册、URL/Universal Link 回跳及分享结果分发。
|
||||
@MainActor
|
||||
final class WeChatManager: NSObject {
|
||||
static let shared = WeChatManager()
|
||||
|
||||
/// 最近一次分享结果,供 UI 监听。
|
||||
private(set) var lastShareResult: WeChatShareResult?
|
||||
|
||||
private var shareCompletion: ((WeChatShareResult) -> Void)?
|
||||
private var isRegistered = false
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// 向微信注册 AppID 与 Universal Link,应用启动时调用。
|
||||
func registerIfNeeded() {
|
||||
guard !isRegistered else { return }
|
||||
guard WeChatConfig.validateUniversalLink(WeChatConfig.universalLink) else {
|
||||
assertionFailure("WeChat Universal Link 必须以 https 开头并以 / 结尾")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
WXApi.startLog(by: WXLogLevel.detail) { log in
|
||||
print("WeChatSDK: \(log)")
|
||||
}
|
||||
#endif
|
||||
|
||||
let success = WXApi.registerApp(WeChatConfig.appID, universalLink: WeChatConfig.universalLink)
|
||||
isRegistered = success
|
||||
|
||||
#if DEBUG
|
||||
if success {
|
||||
checkUniversalLinkReady()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 处理 URL Scheme 回调。
|
||||
@discardableResult
|
||||
func handleOpenURL(_ url: URL) -> Bool {
|
||||
WXApi.handleOpen(url, delegate: self)
|
||||
}
|
||||
|
||||
/// 处理 Universal Link 回调。
|
||||
@discardableResult
|
||||
func handleContinueUserActivity(_ userActivity: NSUserActivity) -> Bool {
|
||||
WXApi.handleOpenUniversalLink(userActivity, delegate: self)
|
||||
}
|
||||
|
||||
/// 注册单次分享完成回调。
|
||||
func setShareCompletion(_ completion: ((WeChatShareResult) -> Void)?) {
|
||||
shareCompletion = completion
|
||||
}
|
||||
|
||||
/// 发送分享请求到微信。
|
||||
func sendShareRequest(_ request: SendMessageToWXReq, completion: ((WeChatShareResult) -> Void)? = nil) {
|
||||
guard WXApi.isWXAppInstalled() else {
|
||||
let result = WeChatShareResult.notInstalled
|
||||
completion?(result)
|
||||
return
|
||||
}
|
||||
guard WXApi.isWXAppSupport() else {
|
||||
let result = WeChatShareResult.unsupported
|
||||
completion?(result)
|
||||
return
|
||||
}
|
||||
|
||||
if let completion {
|
||||
shareCompletion = completion
|
||||
}
|
||||
|
||||
WXApi.send(request) { success in
|
||||
if !success {
|
||||
Task { @MainActor in
|
||||
self.finishShare(with: .sendFailed(-1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 调试 Universal Link 配置,仅 DEBUG 环境调用。
|
||||
private func checkUniversalLinkReady() {
|
||||
WXApi.checkUniversalLinkReady { step, result in
|
||||
print("WeChat UL check step=\(step.rawValue) success=\(result.success) info=\(result.errorInfo ?? "") suggestion=\(result.suggestion ?? "")")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func finishShare(with result: WeChatShareResult) {
|
||||
lastShareResult = result
|
||||
shareCompletion?(result)
|
||||
shareCompletion = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension WeChatManager: WXApiDelegate {
|
||||
nonisolated func onReq(_ req: BaseReq) {
|
||||
// 分享模块暂不处理来自微信的主动请求。
|
||||
}
|
||||
|
||||
nonisolated func onResp(_ resp: BaseResp) {
|
||||
guard let shareResp = resp as? SendMessageToWXResp else { return }
|
||||
let result = WeChatShareResult.fromWXErrorCode(shareResp.errCode)
|
||||
Task { @MainActor in
|
||||
self.finishShare(with: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
70
suixinkan/Core/WeChat/WeChatShareModels.swift
Normal file
70
suixinkan/Core/WeChat/WeChatShareModels.swift
Normal file
@ -0,0 +1,70 @@
|
||||
//
|
||||
// WeChatShareModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 微信分享目标场景。
|
||||
enum WeChatShareScene: Int, CaseIterable, Sendable {
|
||||
/// 微信好友会话。
|
||||
case session = 0
|
||||
/// 朋友圈。
|
||||
case timeline = 1
|
||||
/// 微信收藏。
|
||||
case favorite = 2
|
||||
|
||||
/// 展示用标题。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .session: "微信好友"
|
||||
case .timeline: "朋友圈"
|
||||
case .favorite: "收藏"
|
||||
}
|
||||
}
|
||||
|
||||
/// 映射到微信 SDK 的 scene 常量。
|
||||
var wxSceneValue: Int32 {
|
||||
Int32(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// 微信分享请求结果。
|
||||
enum WeChatShareResult: Equatable, Sendable {
|
||||
case success
|
||||
case cancelled
|
||||
case notInstalled
|
||||
case unsupported
|
||||
case invalidPayload(String)
|
||||
case sendFailed(Int)
|
||||
case unknown(Int)
|
||||
|
||||
/// 根据微信 SDK 错误码解析分享结果。
|
||||
static func fromWXErrorCode(_ code: Int32) -> WeChatShareResult {
|
||||
switch code {
|
||||
case 0:
|
||||
.success
|
||||
case -2:
|
||||
.cancelled
|
||||
default:
|
||||
.unknown(Int(code))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 网页分享载荷,供业务层与分享服务之间传递。
|
||||
struct WeChatWebPageSharePayload: Equatable, Sendable {
|
||||
let title: String
|
||||
let description: String
|
||||
let url: String
|
||||
}
|
||||
|
||||
/// 文本分享载荷。
|
||||
struct WeChatTextSharePayload: Equatable, Sendable {
|
||||
let text: String
|
||||
}
|
||||
|
||||
/// 图片分享载荷。
|
||||
struct WeChatImageSharePayload: Equatable, Sendable {
|
||||
let imageData: Data
|
||||
}
|
||||
115
suixinkan/Core/WeChat/WeChatShareService.swift
Normal file
115
suixinkan/Core/WeChat/WeChatShareService.swift
Normal file
@ -0,0 +1,115 @@
|
||||
//
|
||||
// WeChatShareService.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import WechatOpenSDK
|
||||
|
||||
/// 微信分享服务,封装文本、网页、图片分享能力(不含支付)。
|
||||
enum WeChatShareService {
|
||||
/// 是否已安装微信且支持 Open SDK。
|
||||
static var isWeChatAvailable: Bool {
|
||||
WXApi.isWXAppInstalled() && WXApi.isWXAppSupport()
|
||||
}
|
||||
|
||||
/// 分享纯文本到微信。
|
||||
@MainActor
|
||||
static func shareText(
|
||||
_ payload: WeChatTextSharePayload,
|
||||
scene: WeChatShareScene,
|
||||
completion: ((WeChatShareResult) -> Void)? = nil
|
||||
) {
|
||||
let trimmed = payload.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
completion?(.invalidPayload("分享文本不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
let request = SendMessageToWXReq()
|
||||
request.bText = true
|
||||
request.text = trimmed
|
||||
request.scene = scene.wxSceneValue
|
||||
WeChatManager.shared.sendShareRequest(request, completion: completion)
|
||||
}
|
||||
|
||||
/// 分享网页链接到微信。
|
||||
@MainActor
|
||||
static func shareWebPage(
|
||||
_ payload: WeChatWebPageSharePayload,
|
||||
scene: WeChatShareScene,
|
||||
thumbImage: UIImage? = nil,
|
||||
completion: ((WeChatShareResult) -> Void)? = nil
|
||||
) {
|
||||
let title = payload.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let url = payload.url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty else {
|
||||
completion?(.invalidPayload("分享标题不能为空"))
|
||||
return
|
||||
}
|
||||
guard !url.isEmpty else {
|
||||
completion?(.invalidPayload("分享链接不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
let webpageObject = WXWebpageObject()
|
||||
webpageObject.webpageUrl = url
|
||||
|
||||
let message = WXMediaMessage()
|
||||
message.title = title
|
||||
message.description = payload.description
|
||||
message.mediaObject = webpageObject
|
||||
if let thumbImage, let thumbData = WeChatThumbImageProcessor.makeThumbData(from: thumbImage) {
|
||||
message.thumbData = thumbData
|
||||
}
|
||||
|
||||
let request = SendMessageToWXReq()
|
||||
request.bText = false
|
||||
request.message = message
|
||||
request.scene = scene.wxSceneValue
|
||||
WeChatManager.shared.sendShareRequest(request, completion: completion)
|
||||
}
|
||||
|
||||
/// 分享图片到微信。
|
||||
@MainActor
|
||||
static func shareImage(
|
||||
_ payload: WeChatImageSharePayload,
|
||||
scene: WeChatShareScene,
|
||||
thumbImage: UIImage? = nil,
|
||||
completion: ((WeChatShareResult) -> Void)? = nil
|
||||
) {
|
||||
guard !payload.imageData.isEmpty else {
|
||||
completion?(.invalidPayload("分享图片不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
let imageObject = WXImageObject()
|
||||
imageObject.imageData = payload.imageData
|
||||
|
||||
let message = WXMediaMessage()
|
||||
message.mediaObject = imageObject
|
||||
if let thumbImage, let thumbData = WeChatThumbImageProcessor.makeThumbData(from: thumbImage) {
|
||||
message.thumbData = thumbData
|
||||
} else if let image = UIImage(data: payload.imageData),
|
||||
let thumbData = WeChatThumbImageProcessor.makeThumbData(from: image) {
|
||||
message.thumbData = thumbData
|
||||
}
|
||||
|
||||
let request = SendMessageToWXReq()
|
||||
request.bText = false
|
||||
request.message = message
|
||||
request.scene = scene.wxSceneValue
|
||||
WeChatManager.shared.sendShareRequest(request, completion: completion)
|
||||
}
|
||||
|
||||
/// 获取 App 图标,用作分享缩略图兜底。
|
||||
static func defaultThumbImage() -> UIImage? {
|
||||
guard let icons = Bundle.main.infoDictionary?["CFBundleIcons"] as? [String: Any],
|
||||
let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
|
||||
let iconFiles = primary["CFBundleIconFiles"] as? [String],
|
||||
let iconName = iconFiles.last else {
|
||||
return nil
|
||||
}
|
||||
return UIImage(named: iconName)
|
||||
}
|
||||
}
|
||||
105
suixinkan/Core/WeChat/WeChatShareSheet.swift
Normal file
105
suixinkan/Core/WeChat/WeChatShareSheet.swift
Normal file
@ -0,0 +1,105 @@
|
||||
//
|
||||
// WeChatShareSheet.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// 微信分享选项面板,支持好友/朋友圈及系统分享兜底。
|
||||
struct WeChatShareSheet: View {
|
||||
let title: String
|
||||
let description: String
|
||||
let url: String
|
||||
let thumbImage: UIImage?
|
||||
let onResult: (String) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isPresentingSystemShare = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if WeChatShareService.isWeChatAvailable {
|
||||
shareButton("微信好友", systemImage: "message.fill") {
|
||||
share(scene: .session)
|
||||
}
|
||||
shareButton("朋友圈", systemImage: "person.2.fill") {
|
||||
share(scene: .timeline)
|
||||
}
|
||||
}
|
||||
shareButton("更多分享", systemImage: "square.and.arrow.up") {
|
||||
isPresentingSystemShare = true
|
||||
}
|
||||
}
|
||||
.navigationTitle("分享")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isPresentingSystemShare) {
|
||||
let text = shareText
|
||||
ActivityShareView(activityItems: [text]) {
|
||||
onResult("已打开系统分享")
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shareText: String {
|
||||
let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedURL.isEmpty else { return title }
|
||||
return "\(title)\n\(trimmedURL)"
|
||||
}
|
||||
|
||||
private func shareButton(_ label: String, systemImage: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Label(label, systemImage: systemImage)
|
||||
}
|
||||
}
|
||||
|
||||
private func share(scene: WeChatShareScene) {
|
||||
let payload = WeChatWebPageSharePayload(
|
||||
title: title,
|
||||
description: description,
|
||||
url: url
|
||||
)
|
||||
let thumb = thumbImage ?? WeChatShareService.defaultThumbImage()
|
||||
WeChatShareService.shareWebPage(payload, scene: scene, thumbImage: thumb) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
onResult("已分享到\(scene.title)")
|
||||
case .cancelled:
|
||||
onResult("已取消分享")
|
||||
case .notInstalled:
|
||||
isPresentingSystemShare = true
|
||||
case .unsupported:
|
||||
onResult("当前微信版本不支持分享")
|
||||
case .invalidPayload(let message):
|
||||
onResult(message)
|
||||
case .sendFailed, .unknown:
|
||||
onResult("分享失败,请稍后重试")
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统分享面板封装。
|
||||
private struct ActivityShareView: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
let onComplete: () -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
||||
controller.completionWithItemsHandler = { _, _, _, _ in
|
||||
onComplete()
|
||||
}
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
47
suixinkan/Core/WeChat/WeChatThumbImageProcessor.swift
Normal file
47
suixinkan/Core/WeChat/WeChatThumbImageProcessor.swift
Normal file
@ -0,0 +1,47 @@
|
||||
//
|
||||
// WeChatThumbImageProcessor.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 微信分享缩略图处理器,将图片压缩到微信限制(32KB)以内。
|
||||
enum WeChatThumbImageProcessor {
|
||||
/// 微信缩略图最大字节数。
|
||||
static let maxThumbBytes = 32 * 1024
|
||||
|
||||
/// 将图片压缩为符合微信要求的缩略图数据。
|
||||
static func makeThumbData(from image: UIImage, maxBytes: Int = maxThumbBytes) -> Data? {
|
||||
guard maxBytes > 0 else { return nil }
|
||||
|
||||
let resized = resize(image, maxPixel: 120)
|
||||
var quality: CGFloat = 0.8
|
||||
var data = resized.jpegData(compressionQuality: quality)
|
||||
|
||||
while let current = data, current.count > maxBytes, quality > 0.1 {
|
||||
quality -= 0.1
|
||||
data = resized.jpegData(compressionQuality: quality)
|
||||
}
|
||||
|
||||
guard let result = data, result.count <= maxBytes else { return nil }
|
||||
return result
|
||||
}
|
||||
|
||||
/// 等比缩放图片,使最长边不超过指定像素。
|
||||
static func resize(_ image: UIImage, maxPixel: CGFloat) -> UIImage {
|
||||
let size = image.size
|
||||
guard size.width > 0, size.height > 0 else { return image }
|
||||
|
||||
let maxSide = max(size.width, size.height)
|
||||
guard maxSide > maxPixel else { return image }
|
||||
|
||||
let scale = maxPixel / maxSide
|
||||
let targetSize = CGSize(width: size.width * scale, height: size.height * scale)
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = 1
|
||||
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
|
||||
return renderer.image { _ in
|
||||
image.draw(in: CGRect(origin: .zero, size: targetSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user