feat: add WeChat sharing and invite flow
This commit is contained in:
89
suixinkan/Core/WeChat/WeChatManager.swift
Normal file
89
suixinkan/Core/WeChat/WeChatManager.swift
Normal file
@ -0,0 +1,89 @@
|
||||
//
|
||||
// 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 must use https and end with /")
|
||||
return
|
||||
}
|
||||
|
||||
isRegistered = WXApi.registerApp(WeChatConfig.appID, universalLink: WeChatConfig.universalLink)
|
||||
}
|
||||
|
||||
/// 处理 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 sendShareRequest(_ request: SendMessageToWXReq, completion: ((WeChatShareResult) -> Void)? = nil) {
|
||||
guard WXApi.isWXAppInstalled() else {
|
||||
completion?(.notInstalled)
|
||||
return
|
||||
}
|
||||
guard WXApi.isWXAppSupport() else {
|
||||
completion?(.unsupported)
|
||||
return
|
||||
}
|
||||
|
||||
if let completion {
|
||||
shareCompletion = completion
|
||||
}
|
||||
|
||||
WXApi.send(request) { [weak self] success in
|
||||
guard !success else { return }
|
||||
Task { @MainActor in
|
||||
self?.finishShare(with: .sendFailed(-1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
WeChatManager.shared.finishShare(with: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
73
suixinkan/Core/WeChat/WeChatShareModels.swift
Normal file
73
suixinkan/Core/WeChat/WeChatShareModels.swift
Normal file
@ -0,0 +1,73 @@
|
||||
//
|
||||
// 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:
|
||||
return "微信好友"
|
||||
case .timeline:
|
||||
return "朋友圈"
|
||||
case .favorite:
|
||||
return "收藏"
|
||||
}
|
||||
}
|
||||
|
||||
/// 映射到微信 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:
|
||||
return .success
|
||||
case -2:
|
||||
return .cancelled
|
||||
default:
|
||||
return .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)
|
||||
}
|
||||
}
|
||||
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.82
|
||||
var data = resized.jpegData(compressionQuality: quality)
|
||||
|
||||
while let current = data, current.count > maxBytes, quality > 0.12 {
|
||||
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 sourceSize = image.size
|
||||
guard sourceSize.width > 0, sourceSize.height > 0 else { return image }
|
||||
|
||||
let maxSide = max(sourceSize.width, sourceSize.height)
|
||||
guard maxSide > maxPixel else { return image }
|
||||
|
||||
let scale = maxPixel / maxSide
|
||||
let targetSize = CGSize(width: sourceSize.width * scale, height: sourceSize.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