普通视图

发现新文章,点击刷新页面。
昨天以前掘金 iOS

一个 iOS 埋点 SDK 从 0 到 1,再到真实项目接入打磨

2026年4月23日 18:56

我最近把一套已经在线上跑通的项目内埋点代码,抽成了一套可复用的 iOS SDK。原本我以为难点只是“把代码搬出去”,后来才发现,真正难的是哪些能力该留在 SDK、埋点上报发送请求到底要不要sdk来接管、日志怎么做才方便进行测试验证、文档和版本号怎么跟上。这篇文章分享的是这套 SDK 被真实接入反馈一步步打磨出来的过程。


最开始我以为,做这套埋点 SDK,就是把项目里那套已经跑通的代码抽出来就可以了,后来发现没那么简单。

真正麻烦的地方,是代码抽出去之后才出现的:

  1. 哪些能力应该放进SDK
  2. 哪些逻辑必须留在业务项目
  3. SDK 要不要负责埋点上报发送请求
  4. 日志到底是给开发看,还是给测试和产品验收使用
  5. 文档和版本号没跟上时,同事会不会直接集成失败

因为我后来确定了一件事:把埋点代码抽成 SDK,难点从来不是“把代码搬出去”,而是让它在接入、调试、验证、埋点上报这些环节里都真的可用。

一、为什么我会做这个 SDK

起因其实很简单,我手上有一个已经在线上跑通的 iOS APP 项目,里面已经有一套比较完整的埋点能力:

  1. 自动采集公共事件属性
  2. 自动补一组固定用户属性
  3. 统一时间格式
  4. 固定首次安装时间和安装时区
  5. 构建事件请求
  6. 构建用户属性请求
  7. 埋点上报发送请求
  8. 失败后自动重试
  9. 打调试日志

于是,领导就给我提出一个需求:让我封装一个埋点sdk,把固定用户属性和公共事件属性都封装在sdk中,让sdk内部自动获取这些属性值,iOS同事在其他项目中进行埋点上报的时候,就不需要再单独写一套固定用户属性和公共事件属性上报的代码了,直接使用sdk的能力就可以了。

所以,这次的目标很明确,是把这些已经被项目验证过的能力,封装成一个其他 App 项目也能对接的 SDK。

二、原来那套代码为什么不适合直接复用

我最开始手上拥有的,是一套项目里我写好的埋点管理代码。

这种埋点管理类在业务项目里很常见,一开始也确实好用,因为它把所有事都接住了:

  1. 事件名
  2. 公共属性
  3. 用户属性
  4. 时间格式
  5. 请求参数构建
  6. 请求发送
  7. 失败重试
  8. 日志它

以上8点它全部都管。截一小段原来的调用入口,就能看出这种写法的特点:

func track(_ eventName: SC_MQ09EventName,
           properties: [String: Any] = [:],
           timestamp: Date? = nil) {
    let resolvedTimestamp = timestamp ?? Date()
    var payload = buildEventPayload(
        eventName: eventName,
        properties: properties,
        timestamp: resolvedTimestamp
    )

    guard JSONSerialization.isValidJSONObject(payload) else {
        SuperCoderNetLog("[MQ09] invalid payload for \(eventName.rawValue): \(payload)")
        return
    }

    do {
        let data = try JSONSerialization.data(withJSONObject: payload, options: [])
        routeEventPayload(
            payload: payload,
            payloadData: data,
            allowRetryStore: true,
            eventName: eventName.rawValue
        )
    } catch {
        SuperCoderNetLog("[MQ09] encode failed: \(error.localizedDescription)")
    }
}

这段代码本身没有错,问题在于,它已经同时在做几件事:决定事件时间、构建请求参数、校验 JSON、准备失败重试需要的数据、再把埋点上报请求发送出去。

在一个项目里,这样写能很快推进,但一旦你想复用到别的项目,就会发现它太像“项目现场代码”,而不是一层可以被其他 App 直接依赖的通用能力。

这种写法在“单项目快速推进”阶段没问题,但一旦你想跨项目复用,它马上就会暴露两个大问题。

第一,职责太杂。

它既有通用能力,又有业务语义。比如某些页面事件、某些业务字段、某些页面触发时机,这些本来只属于当前项目,但也被混进了同一层埋点管理代码。

第二,边界不清。

你很难回答一个问题:

到底哪些是“埋点 SDK 应该负责的”,哪些只是“当前这个业务项目碰巧这么写了”。

这也是我后来感受最强的一点:

项目里能跑通,不代表它已经具备跨项目复用条件。

三、我怎么划 SDK 和业务项目的边界

真正开始封装 SDK 之后,我先做的不是写代码,而是先把边界想清楚。

我想清楚了以下3点:

1. 必须放进 SDK 的,是稳定的基础能力

比如这些:

  1. 公共事件属性采集
  2. 固定用户属性采集
  3. 时间格式统一
  4. 安装时间与安装时区
  5. 事件请求参数构建
  6. 用户属性请求参数构建
  7. 可选的埋点上报发送能力
  8. 失败重试
  9. 日志输出

这些东西不依赖某个具体页面,也不属于某个特定业务,很适合收进 SDK。

2. 必须留在业务项目里的,是具体业务逻辑

比如:

  1. 某个页面的事件名
  2. 某个业务字段怎么算
  3. 哪个时机触发埋点
  4. 哪组字段是这个业务独有的

这部分如果硬塞进 SDK,SDK 很快就会变成“这个项目专用库”,复用价值就没了。

3. 埋点上报发送能力必须做成可选

我同事跟我说,一般SDK能够发送请求上报埋点,他们都会选择直接用SDK上报,但我在做的过程中,还是觉得做成可选吧,因为不一定所有项目都愿意把网络请求交给 SDK。

有的项目想要的是:

  1. SDK 帮我构建参数
  2. 我自己发请求

有的项目则希望:

  1. SDK 帮我构建参数
  2. SDK 直接把请求也发了

所以我最后没有把发送写死,而是保留了两条路:

  1. 标准 SDK 接法:直接 track / setUserProperties
  2. 直接发送完整请求参数:项目先把所有参数组合好,再交给 SDK 发

标准接法的入口最后被压得很薄:

public func track(
    eventName: String,
    properties: [String: Any] = [:],
    timestamp: Date? = nil,
    eventType: String = "track"
) {
    let rawParams = ZZHAnalyticsJSONSanitizer.dictionary(properties)
    let payload = makeEventPayload(
        eventName: eventName,
        properties: rawParams,
        timestamp: timestamp,
        eventType: eventType
    )
    sendPayloadIfPossible(
        payload,
        endpointType: .event,
        startLogContext: .event(eventName: eventName, params: rawParams)
    )
}

业务方只需要告诉 SDK:我要发哪个事件,带哪些业务参数,至于公共字段怎么补、时间怎么格式化、请求怎么发、日志怎么打,都留在 SDK 里面处理。

这个决定看起来只是 API 设计,实际上后来直接影响了后面埋点日志输出功能的业务逻辑。

我也没有直接把旧的上报方式整条推翻,这轮更稳的做法其实是:

  1. 先让 SDK 在原来已经在跑的那条上报路径旁边,并行对照一段时间
  2. 先看 SDK 组合出来的参数,和项目里原来那套上报逻辑是不是一致
  3. 确认没问题以后,再正式切到只走 SDK 这一条上报路径

这件事现在回头看也特别值得记下来。

因为复杂项目里,真正危险的不是代码抽得慢,而是你一上来就直接替换项目里正在使用的那条上报路径,这样一旦 SDK 和旧逻辑有没对齐的地方,往往要到真实验收时才会暴露出来。

四、真正让这个 SDK 变难的,不是封装,而是真实项目接入后的反馈

如果这套 SDK 只停留在“我本地能跑”,其实没什么特别值得写的。

真正让它有工程价值的,是iOS同事在他的项目中接入时遇到的SDK出现的各种问题。

1. distinct_idaccount_id 应该怎么传

一开始最容易掉进去的坑,是想把这两个用户标识字段设计得很“完整”,但后来对着真实项目接进去时,我才发现这不只是字段怎么传的问题。

distinct_id 这条比较清楚:

  1. distinct_id == device_id
  2. 这个值必须由接入方自己提供
  3. SDK 不再内部默认生成

真正麻烦的是 account_id

一开始我把它理解成“有就带,没有也不影响上报”,代码里也确实是这么处理的。

但真实接入时,很快就暴露出一个更具体的问题:

很多 App 都是先初始化 SDK,后面才从 Adjust SDK 异步拿到 adid

也就是说,问题不只是“account_id 要不要传”,而是:

SDK 初始化完成的时候,account_id 很可能还拿不到。

后来产品把规则也确认得更明确了:

  1. distinct_id 必须有
  2. account_id 的值就是外部拿到的 adjustid
  3. account_id 不是初始化时必须就有,但外部拿到 adjustid 后要立刻传给 SDK
  4. 传进去以后,后续标准事件上报和用户属性上报都要自动传参 account_id
  5. account_id 还要作为用户属性,再主动补报一次 user_setOnce

一开始我以为这是字段传值规则的问题,后来接入时才发现,真正麻烦的是 SDK 已经初始化好了,adjustid 却还没回来。

所以后面真正的修改方式,不是继续讨论 account_id 到底算“可选”还是“不可选”,而是把 SDK 补成初始化完成后也能继续更新 account_id

最后 SDK 对外多补充了一个明确方法:

Adjust.adid { adid in
    guard let adid, adid.isEmpty == false else { return }
    ZZHAnalyticsSDK.shared.setAccountID(adid)
}

也就是说,这件事最后真正定下来的,不是一句“account_id 可选”,而是:

  1. distinct_id 一开始就由项目传进来
  2. account_id 等 Adjust 返回后,再立刻传给 SDK
  3. 标准上报路径后续自动传参 account_id
  4. SDK 主动补一次 user_setOnce 用户属性 account_id

这比我一开始那种“先把规则写简单一点再说”的理解,要更接近真实项目接入。

2. 用户属性更新方式为什么要改成枚举

一开始用户属性更新方式可以传字符串,这在设计上很灵活,但真实接入时很容易变成:

  1. 表面看起来统一
  2. 实际每个项目都可能传不同字符串
  3. 最后 SDK 很难保证大家传的是同一套规则

所以后来我只保留了两种明确的写法:

  1. user_set
  2. user_setOnce

这个改动看起来不大,但它背后的意思是:

SDK 不是只负责“让你能传”,还要尽量避免接入方传错、传乱。

3. 失败重试为什么不能只停在当前进程内

一开始 SDK 只有自动重试 2 次。

这对临时网络失败来说够用,但接入方很快会问一个问题:

如果这次重试两次都失败,下次 App 重启以后怎么办?

这个问题一出来,我就知道,这不再只是“重试几次”的问题,而是“第一次生成的请求内容要不要保存下来”的问题。

因为一旦你要支持 App 重启后继续重发,就意味着:

  1. 这次请求的 bodyData 不能丢
  2. 不能下次再重新组合一遍参数
  3. 否则字段和时间可能会和第一次不一致

所以后来这一块的核心原则就变成:

重试永远基于第一次生成的请求内容,而不是重新构建请求参数。

这也是我觉得很值得写出来的一条工程经验。

很多人会默认“失败了再组合一次参数”,但埋点这种东西,真这么做,最后发出去的内容就可能和第一次不一样了。

而且这件事后面我越看越觉得不能偷懒,因为变化的根本不只是接口的参数 time

如果你让 SDK 失败后重新组一次参数,可能一起变掉的还有:

  1. 当时的网络状态
  2. 当时的权限状态
  3. 当时的安装相关字段
  4. 那次请求真正想表达的时间点

所以后来我对这条原则的理解就更明确了:

埋点请求一旦生成,就应该尽量把它当成那一刻的快照。

4. ta_app_install 事件上报的时间后来为什么还要单独修改

这也是产品验收时发现的一个问题。

一开始我会默认觉得:

  1. 普通事件上报接口传参 time 用当前时间
  2. 这是很自然的做法

这对绝大多数事件都没问题。

ta_app_install 不一样。

因为产品验收时看的不只是传参的 time,还会一起看:

  1. #install_time
  2. install_ts_bj
  3. install_ts_utc
  4. install_ts_time

这几个时间字段本质上都应该指向同一个安装时间点。

当前项目之所以一直没出问题,是因为业务层本来就主动把安装时间传给了这条事件。

但 SDK 标准接法如果只是:

ZZHAnalyticsSDK.shared.track(eventName: "ta_app_install")

旧逻辑会直接把发起这次上报时的当前时间,写进这个事件的 time 字段。

这样一来,这个事件里的 time,和安装相关字段就不是同一个时间来源了。

这个问题特别能说明一件事:

把代码封装成 SDK,只是第一步,等产品真的开始逐个字段检查时,你才会知道还有哪些地方没处理对。

后来的修法也很克制:

  1. 普通事件还是继续用当前时间
  2. 只有 ta_app_install 且外部没传 timestamp 时,才默认改用 SDK 保存的安装时间

这件事让我后面更确定,SDK 真正难的不是“第一版怎么设计”,而是:

当产品拿着真实字段来验的时候,你能不能只改那一个真正有问题的地方,而不是顺手把整套时间逻辑都推倒。

五、埋点日志系统是怎么一步一步优化和完善的

如果说前面这些问题还在解决“SDK 能不能用”,那埋点日志系统解决的是另一件事:

SDK 能不能帮助测试和产品快速确认埋点参数有没有传对。

1. 最开始的日志,其实只对 SDK 开发者有用

最开始 SDK 里的日志更像网络请求调试日志:

  1. 打印发送过程
  2. 打印状态码
  3. 打印成功失败

但这类日志有个问题:

做 SDK 的人能看懂,测试拿去检查埋点参数就很难受。

因为测试真正关心的不是网络请求内部过程,而是:

  1. 这次到底发到哪个 URL
  2. 请求头是什么
  3. 请求参数是什么
  4. 服务端响应了什么
  5. 到底成功还是失败

所以后来日志被拆成了两类:

  1. 发起日志
  2. 结果日志

代码里也尽量保持这个拆分方式:

public func send(snapshot: ZZHAnalyticsRequestSnapshot,
                 completion: @escaping (Bool) -> Void) {
    var request = URLRequest(url: snapshot.url)
    request.httpMethod = "POST"
    request.httpBody = snapshot.bodyData

    #if DEBUG
    ZZHAnalyticsDebugStartLog(Self.startLog(for: snapshot), snapshot: snapshot)
    #endif

    URLSession.shared.dataTask(with: request) { data, response, error in
        if let error {
            #if DEBUG
            ZZHAnalyticsDebugLog(Self.failureLog(for: snapshot, error: error))
            #endif
            completion(false)
            return
        }

        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            #if DEBUG
            ZZHAnalyticsDebugLog(
                Self.responseLog(for: snapshot, response: response, data: data, success: false)
            )
            #endif
            completion(false)
            return
        }

        #if DEBUG
        ZZHAnalyticsDebugLog(
            Self.responseLog(for: snapshot, response: httpResponse, data: data, success: true)
        )
        #endif
        completion(true)
    }.resume()
}

这段代码的重点,不是“加了几行日志”,而是把日志按使用场景拆开:请求发出去之前,先打印一条发起日志,让人能看到这次准备发什么;请求回来之后,再打印一条结果日志,让人能看到这次到底有没有成功。

2. 发起日志解决“准备发什么”的问题

发起日志最终打印的是:

  1. 时间
  2. 事件名或者用户属性更新方式
  3. URL
  4. Headers
  5. Params

它解决的问题很明确:

它能够让开发者、产品、测试清晰地知道埋点是否有正确发起上报。

3. 结果日志解决“最终发得对不对”的问题

结果日志则继续保留:

  1. URL
  2. Headers
  3. Params
  4. StatusCode
  5. Response
  6. Success

它解决的是另一层问题:

埋点上报请求最终到底成功没有,服务端返回了什么。

4. 为什么后来还要加埋点日志系统的代理方法

做到这里,我以为已经完成任务了。

后来同事那边又提了一个很真实的诉求:

他们项目里本来就有一个悬浮日志窗口,测试和产品会直接在 App 里看埋点日志,如果 SDK 内部只把日志打到 Xcode 控制台,对他们来说是远远不够的。

这时我才意识到,日志不只是“打印出来”,还得“送出去”。

于是后来又补了一层日志代理:

  1. SDK 在 Xcode 打什么
  2. 代理方法就原样返回什么
  3. 接入方拿到以后,直接塞进自己的日志窗口

最终对外暴露的协议是这样的:

public protocol ZZHAnalyticsLogDelegate: AnyObject {
    func analyticsSDK(
        _ sdk: ZZHAnalyticsSDK,
        didReceiveEventStartLog message: String,
        eventName: String,
        params: [String: Any]
    )

    func analyticsSDK(
        _ sdk: ZZHAnalyticsSDK,
        didReceiveUserPropertyStartLog message: String,
        updateType: String,
        params: [String: Any]
    )

    func analyticsSDK(_ sdk: ZZHAnalyticsSDK,
                      didReceiveEventResultLog message: String)

    func analyticsSDK(_ sdk: ZZHAnalyticsSDK,
                      didReceiveUserPropertyResultLog message: String)
}

这个代理方法不复杂,但它把两件事拆清楚了:一边是 SDK 原样日志 message,另一边是业务自己可能想再打印一条简洁日志用的 eventName / updateType / params

这一层能力看起来很小,但它把日志从“开发调试工具”变成了“测试和产品也能直接用的快速确认埋点参数有没有传对的工具”。

做到这一步,我自己的总结是:

很多 SDK 日志的问题,是日志只对 SDK 开发者有用,对测试和产品没有用。

六、同一个 params,在不同接法下代表的内容不一样

这是我在真实项目接入时遇到的一个具体问题。

一开始我把发起日志代理设计成:

  1. message:完整日志原文
  2. params:给接入方自己打印一条简洁发起日志

看起来很合理,但接入后我发现,同样叫 params,在不同接法下代表的内容并不一样。

1. 标准 SDK 接法

如果你走的是:

track(eventName:properties:)
setUserProperties(...)

params 很好理解,就是业务方最开始传进来的参数。

2. 直接发送完整请求参数

如果你走的是:

sendEventPayloadSnapshot(payload)
sendUserPropertyPayloadSnapshot(payload)

那 SDK 拿到的已经是完整的请求参数了。

这时 SDK 内部根本没法再判断:

  1. 哪些是页面最开始传进来的业务参数
  2. 哪些是 SDK 自动获取的公共、固定字段

所以这时发起日志里的 params,默认只能是请求参数里现成的 properties

而当前我这个项目,真实主路径其实更接近这一种。

也就是说,当前 SuperCoder / MQ09 不是一开始就完全走标准 track / setUserProperties,而是更多时候先在项目里把完整请求参数组装好,再交给 SDK 发送上报请求。

这也是为什么我后面会专门把这一点写进接入文档里。因为如果不把“当前项目接法”和“标准 SDK 接法”拆开讲,同事看到日志里的 params,会很容易误以为 SDK 自己把业务参数改掉了。

这个区别后来我专门写进了使用文档中,以免iOS同事理解有误。

3. 固定用户属性自动补发又是一个特例

后面又出现了第三种情况。

SDK 会自动补发一组固定用户属性,比如:

  1. country
  2. install_ts_bj
  3. install_ts_utc
  4. install_ts_time
  5. install_ts_time_timezone

这组属性不是业务方手动传的,但测试又希望在发起日志里直接看到它们。

所以最后我又单独给它做了一个特例:

  1. 普通发起日志:params 继续代表外部原始入参
  2. 固定用户属性自动补发:params 特例代表 SDK 这次自动补发的固定字段

这件事说明了一个问题:

同一个参数名,看起来一样,但在不同使用方式下,代表的内容可能不一样。

这也是 SDK 设计里很容易忽略、但真实接入时很容易暴露的问题。

七、文档、tag、Pod 接入,为什么也是 SDK 工程的一部分

如果只看代码,这套 SDK 其实已经“能用了”。

但我觉得,真正决定它能不能被其他同事真正的在项目中使用,不只是代码。

还有以下这些东西:

  1. 使用文档写得是不是够直白
  2. 示例代码是不是和真实接法一致
  3. 版本号和 tag 有没有同步
  4. 其他同事复制文档接入时,会不会直接编译报错

我这轮就真实踩到了几个这样的坑:

  1. 使用文档写了新能力,但 tag 还是旧版本,同事一装就报错
  2. 使用文档里某些词太偏内部表达,比如“快照模式”,接入同事不好理解
  3. 日志代理示例里,类型写法稍微不直白,同事就可能写成错误的双层类型名

这些问题不容小觑,是 SDK 工程化本身的一部分。

因为对接入方来说,他们真正关心的是:

  1. 我怎么接
  2. 我怎么调
  3. 我怎么验
  4. 我装下来的这个版本,到底是不是文档里写的那个版本

所以,SDK 不是“代码抽出来”就结束了,它还要能被别人低成本接上。

而且到后面我还发现,低成本接上这件事,其实也分两层。

第一层是:

  1. README 写清楚
  2. tag 发对
  3. Pod 依赖能装上

第二层是:

  1. 同事能不能只写一个包名
  2. pod install 的时候会不会还要处理私有仓库认证

这轮我其实只把第一层基本收住了。

也就是说,SDK 代码和接入文档已经比一开始成熟很多了,但分发体验还没有完全走到最理想的形态。现在依然更接近“私有 git + tag”的方式,而不是那种更标准、更省心的私有 Specs 仓接法。

这也让我后面更确定一件事:

SDK 的工程化,不只是代码和 README,还包括分发基础设施到底有没有跟上。

八、这轮工作最后让我确定的几条原则

最后,总结 6 个我这次做 SDK 后真正踩出来的经验。

1. 项目里能跑通的代码,不一定适合直接做成 SDK

很多时候,项目里的代码只是刚好满足当前业务,想让其他项目也能用,还需要重新拆清楚:哪些放进 SDK,哪些留在项目里。

2. 发送能力最好可选,不要默认 SDK 接管一切

不是所有项目都希望 SDK 直接发请求。让 SDK 同时支持“构建参数”和“直接发送”,接入成本会小很多。

3. 重试一定要基于第一次生成的请求内容

埋点怕的不是失败,而是失败后重新组参数,导致最后发出去的内容和第一次不一样。只要涉及重试,就尽量保存第一次生成的请求内容。

4. 日志要方便测试和产品检查参数,而不只是方便 SDK 开发者调试

对 SDK 开发者好用,不代表对测试好用。日志里能不能一眼看到 URL、参数、响应和成功、失败,才决定这套日志有没有价值。

5. 文档、版本号和 Pod 接入方式,也要一起维护

文档不准、tag 不同步、示例代码不对,都会让接入方直接踩坑。SDK 想让别人顺利接入,就不能只管代码。

6. SDK 是靠真实接入反馈一点点打磨出来的

我这轮最大的感受就是这个,很多一开始觉得“设计得挺好”的地方,最后都是在真实项目接入、同事反馈和测试检查参数时,才暴露出问题。

一个埋点 SDK 真正的完成度,不取决于它能不能发请求,而取决于它能不能被其他项目低成本接入、被测试高效验参、被版本稳定发布。

这可能也是我这轮工作里,最值得留下来的那部分。

别被系统绑架:SwiftUI List 替换背后的底层逻辑

2026年4月22日 19:11

在这里插入图片描述

凌晨三点,楼里只剩空调低鸣。林屿坐在工位前,盯着 SwiftUI 里的 List,像盯着一个多年的老朋友。这个老朋友不坏,甚至称得上可靠。可今天,他忽然觉得不对劲了。页面能跑,交互也顺,但那层说不清的“高级感”,总像隔着一层雾,伸手能碰到,握住却没有。问题出在哪?他顺着代码往下摸,摸到最后,才发现真正的悬念从来不在样式,而在工具选错了场子。

🧭 在 SwiftUI 中构建 List 的替代方案

每当你打算在 SwiftUI 里做一个可滚动页面时,第一反应往往是用 List。这很正常。List 名声在外,又是系统组件,出手就带着几分正统气息。

在这里插入图片描述

但话说回来,它并不总是最合适的选择

List 最擅长的,是展示那种整齐划一的统一数据。比如邮箱列表、待办事项、联系人,这类内容结构规整,节奏统一,List 处理起来得心应手。

可如果不是这种场景,画风就变了。
对于其他更灵活、更讲究布局和视觉层次的页面,ScrollView 搭配 lazy stack,几乎总是更好的方案。

在这里插入图片描述

这篇文章要讲的,就是如何在 SwiftUI 中构建一个自定义的可滚动容器,让我们对 look and feel 拥有真正精细、可控的掌握力。


⚙️ 先把底牌摊开:ScrollView 这几年,已经不是昨日黄花

先说一句实在话。

过去几年里,SwiftUIScrollView + lazy stacks 的性能做了相当大的改进。它早已不是那个“能用,但心里发毛”的角色。今天的它,已经足够稳,足够快,也足够灵活。

在这里插入图片描述

所以,如果你展示的不是那种几十万条统一数据,比如邮箱、待办清单这种超大规模列表,那么:

ScrollView is a way to go.

这句话轻描淡写,实际上意味深长。

它的意思不是 “List 不行”,而是:
如果你的页面不依赖大规模统一数据的复用机制,那你就没必要把自己绑死在 List 上。

工具有长处,也有边界。看不见边界,迟早吃亏。


🫀 CardioBot 的现状:已经不错,但还不够狠

这是林屿自己独立开发的 CardioBot app。

在这里插入图片描述

上面有 4 张截图:前两张是当前版本,后两张是林屿想达到的效果。

现在这款 app 使用的是标准 List。而且说句公道话,它现在的界面观感并不差,作者自己也很喜欢它目前的 look and feel

但人一旦开始较真,就回不了头。

在这里插入图片描述

林屿决定重新审视自己的 UI。目标并不激进,不是要把界面改得面目全非,而是要做到两件事:

  • 保留 iPhone 用户熟悉、直观、可识别的感觉
  • 让 UI 再精致一些,再讲究一些,再“骚”一点,但绝不轻浮

这类优化最难。它不是“重做”,而是“进一寸”。可往往,真正拉开差距的,就是这一寸。


🧱 为什么这里的 List 已经不再对味了

CardioBot 展示的是不同类型的健康指标。问题在于,这些内容不是统一数据集,而是一组风格不同、职责不同的内容块。

林屿用了多种 card 类型,比如:

  • HeroCard
  • TintedCard
  • RegularCard

看到这里,症结就露出来了。

如果数据并不统一,那么使用 List 去做 cell recycling,其实就没多大意义。List 的一身本事,主要是为海量、统一、标准化的数据而生。可这里是一桌散席,不是整齐列队。

在这里插入图片描述

林屿当然也试过继续依赖 List
他可以通过一些 list-specific view modifiers 做出接近目标的样子,比如:

  • listRowBackground
  • listItemTint
  • listRowInsets

它们在 List 内部确实很好使,像一把趁手的短刀。

可惜,刀再锋利,也有出鞘范围。
这些 list-specific view modifiers 一旦离开 List view,立刻失灵。也就是说,这些能力是 List 私有的,不可外借。

在这里插入图片描述

结果就是:
你想在 List 之外维持相同风格,就必须额外补样式。补来补去,补成了拆东墙补西墙,最后不是代码发虚,就是视觉跑偏。

这就不是“能不能做”的问题了,而是“做得值不值”。


🪄 真正的转机:Container View APIs

幸运的是,SwiftUI 后来引入了 Container View APIs

这套 API 看起来安安静静,实际上杀伤力很大。它允许我们把 SwiftUI 视图先拆解,改点东西,再重新组合回来。

这意味着什么?

在这里插入图片描述

意味着你不再只是“使用容器”,而是可以“制造容器”。
你可以借助 Container View APIs 构建可复用的容器视图,像 ListForm,甚至任何高度自定义的东西。

说穿了,这是一种权限的变化。
以前你在用系统给的积木。
现在你很Happy的开始自己烧砖。


📦 第一块积木:ScrollingSurface

由于林屿的 app 中每个页面都采用 ScrollView 加 lazy stack,所以他提炼出了一个统一类型:ScrollingSurface

public struct ScrollingSurface<Content: View>: View {
    public enum Direction {
        case vertical(HorizontalAlignment)
        case horizontal(VerticalAlignment)
    }

    let direction: Direction
    let spacing: CGFloat?
    let content: Content

    public init(
        _ direction: Direction = .vertical(.leading),
        spacing: CGFloat? = nil,
        @ViewBuilder content: () -> Content
    ) {
        self.spacing = spacing
        self.direction = direction
        self.content = content()
    }

    public var body: some View {
        switch direction {
        case .horizontal(let alignment):
            ScrollView(.horizontal) {
                LazyHStack(alignment: alignment, spacing: spacing) {
                    content
                }
                .scrollTargetLayout() // 告诉滚动系统:这里是目标布局区域
                .padding()
            }
        case .vertical(let alignment):
            ScrollView(.vertical) {
                LazyVStack(alignment: alignment, spacing: spacing) {
                    content
                }
                .scrollTargetLayout() // 垂直方向同理
                .padding()
            }
        }
    }
}

他的意思很直接:
ScrollingSurface 本质上就是对 ScrollViewLazyVStack / LazyHStack 的一个简单包装。根据方向不同,切换成垂直或水平滚动容器。

在这里插入图片描述

但别小看这个“简单”。

为什么它值得单独抽出来?

因为它做了三件很重要的事:

  • 统一了页面根结构
  • 统一了滚动方向的表达方式
  • 统一了 spacing 和 padding 的布局语义

林屿会把 ScrollingSurface 作为 app 每个页面的 root view。
这不是偷懒,这是定规矩。

在这里插入图片描述

规矩一旦立住,后面的样式和结构才能不乱套。


🃏 第二块核心积木:DividedCard

接下来,UI 里的关键原语出现了:DividedCard

它最重要的地方,在于使用了 Group(subviews:),这是 SwiftUI Container View API 的一部分。

public struct DividedCard<Content: View>: View {
    let content: Content

    public init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    public var body: some View {
        Group(subviews: content) { subviews in
            if !subviews.isEmpty {
                VStack(alignment: .leading) {
                    ForEach(subviews) { subview in
                        subview

                        if subviews.last?.id != subview.id {
                            Divider()
                                .padding(.vertical, 8) // 在每个子视图之间插入分隔线
                        }
                    }
                }
                .padding()
                .frame(maxWidth: .infinity, alignment: .topLeading)
                .background(
                    .regularMaterial,
                    in: RoundedRectangle(cornerRadius: 32) // 给整体包上圆角卡片背景
                )
            }
        }
    }
}

Group(subviews:) 到底妙在哪?

这招很关键。
它允许我们把通过 @ViewBuilder 传进来的视图拆成一个个子视图

在这里插入图片描述

换句话说,你不再只能把一整坨内容当黑盒来用,而是能看到里面每个子项,并逐个处理它们。

林屿在 DividedCard 里干的事情很漂亮:

  1. 先把内容拆开
  2. 遍历所有 subviews
  3. 在每个子视图后面加上 Divider,但最后一个不加
  4. 最后把整个结构包进一个带圆角的材质背景里

结果就是:
一组原本只是“连续排列的内容”,立刻拥有了卡片感、分组感和边界感。

这一手为什么重要?

因为很多产品界面都存在这样的结构:

  • 一张卡片里放多个入口
  • 每个入口既独立,又需要视觉连续
  • 中间要有分隔,但不能显得生硬

以前你可能要在每个地方重复写 Divider、padding、background、cornerRadius`,写多了就腻,改起来更烦。

在这里插入图片描述

现在不同了。
DividedCard 把这套规则提炼成了一个可复用 primitive

这就是架构的味道:
不是“这页看着对”,而是“以后都能对”。


🧩 第三块积木:SectionedSurface

另一个很有意思的 UI primitive,是 SectionedSurface

public struct SectionedSurface<Content: View>: View {
    let content: Content

    public init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    public var body: some View {
        ForEach(sections: content) { section in
            if !section.content.isEmpty {
                section.header.padding(.top) // 给 section 的 header 增加顶部间距
                section.content
                section.footer
            }
        }
    }
}

它使用了 ForEach(sections:),这个能力可以从传入的视图中提取所有的 Section,然后做统一处理。

林屿这里做了两件事:

  • 过滤掉没有内容的 section
  • 给 section header 增加一些顶部间距

这看着朴素,实际上很实用。

在这里插入图片描述

因为在真实业务里,section 常常是动态的。
某块有数据,就该显示;没数据,就该消失。
如果每个页面都自己处理一遍这些逻辑,迟早会写成一锅粥。

SectionedSurface 把这类规则直接吸收到了容器层。
页面只负责描述内容,容器负责决定组织方式。

这就叫分寸。
代码里有分寸,界面就不会失态。


➡️ 离开 List 后,NavigationLink 的箭头去哪了?

很多人一旦不用 List,很快就会发现少了点什么。
没错,就是 NavigationLink 右侧那个熟悉的小箭头,也就是 chevron

List 中,它会自动出现在 trailing edge,系统帮你安排得明明白白。可一旦离开 List,这个默认样式就没了。

在这里插入图片描述

林屿的办法很干脆:写一个自定义 ButtonStyle

public struct NavigationButtonStyle: ButtonStyle {
    public func makeBody(configuration: Configuration) -> some View {
        HStack {
            configuration.label
                .opacity(configuration.isPressed ? 0.7 : 1) // 按下时微微变淡,增加反馈感
            Spacer()
            Image(systemName: "chevron.right")
                .foregroundStyle(.tertiary) // 补回 List 风格的右侧箭头
        }
        .contentShape(.rect) // 扩大点击区域,让整行都可点
    }
}

extension ButtonStyle where Self == NavigationButtonStyle {
    public static var navigation: Self { .init() }
}

这一招的好处在于,它不是临时补救,而是顺势把“导航型按钮”的风格单独抽了出来。

在这里插入图片描述

以后只要写:

.buttonStyle(.navigation)

整页涉及导航的按钮,就能统一表现。

这才像回事。
高手不是把洞补上,高手是顺手把墙也砌直。


🏗️ 实战拼装:SummaryView

下面这段代码,展示了前面这些新原语在 app 中的实际用法。

public struct SummaryView: View {
    let summary: SummaryStore
    
    public var body: some View {
        ScrollingSurface {
            SectionedSurface {
                coachSection
                activitySection
                recoverySection
                vitalsSection
                heartRateSection
                alcoholicBeveragesSection
            }
        }
        .buttonStyle(.navigation) // 统一套用导航按钮样式
    }
    
    @ViewBuilder private var activitySection: some View {
        Section {
            if !summary.metrics.workouts.isEmpty {
                DividedCard {
                    ForEach(summary.metrics.workouts, id: \.workout.uuid) { snapshot in
                        NavigationLink {
                            WorkoutDetailsView(snapshot: snapshot)
                        } label: {
                            WorkoutView(snapshot: snapshot)
                        }
                    }
                }
            }
        } header: {
            SectionHeader(
                .horizontal,
                title: Text("activitySection"),
                systemImage: "figure.run"
            )
            .tint(.orange)
        }
    }
}

这一段真正漂亮的地方在哪?

表面上看,它的使用方式和 List API 非常像:

  • Section
  • NavigationLink
  • 有 header
  • 有内容分组

但底层已经换了天地。

在这里插入图片描述

林屿通过:

  • ScrollingSurface
  • DividedCard
  • SectionedSurface
  • NavigationButtonStyle

重新拼出了类似 List 的使用体验,同时拿回了对 look and feel 的精准控制。

更妙的是,如果某个页面压根不需要 sections,只要把 SectionedSurface 去掉即可,其余 primitive 仍然能继续复用。

这就说明它们不是页面特供,而是真正的可复用 building blocks

在这里插入图片描述

到了这一步,已经不是“替代 List”那么简单了。
这是在搭自己的界面语言。


真相大白:弃用 List 非叛逆,懂了取舍是清醒

最后,林屿把话说得很准。

SwiftUI 里替换 List,并不是要放弃一个强大的组件。它真正要表达的是:

不是背叛 List,而是为场景选择正确的工具。

如果你面对的是大型、统一的数据集,List 依旧是极好的选择,毫无问题。

在这里插入图片描述

但当你的 UI 需要更细致的结构、更独特的样式、更符合产品自身 design language 的表达时,现代 SwiftUI 已经给了我们足够的自由。

借助 ScrollView、lazy stacks 和 Container View APIs,我们不只是可以重建 List 的能力,某些时候甚至能够超越它。

ScrollingSurfaceDividedCardSectionedSurface 这样的自定义 primitive,证明了一件事:

真正成熟的 SwiftUI 代码,不只是把视图摆出来,而是把可复用的规则提炼出来。

性能、清晰度、设计语言,三者并行不悖。
这才是正路。

在这里插入图片描述


🌒 尾声:他最终没有推翻 List,只是看透了它

天快亮的时候,林屿合上电脑,办公室的灯光仍旧冷,心里却亮了。

他没有把 List 当成敌人。
也没有为了“自定义”而自定义。

在这里插入图片描述

他只是终于明白:
组件从来不是信仰,它只是工具。

该用 List 的时候,别拧巴;
该用 ScrollView 和自定义容器的时候,也别手软。

很多人写 UI,写到最后,写成了对系统组件的依赖。
可真正厉害的人,写到最后,会慢慢长出自己的容器、自己的规则、自己的语言。

在这里插入图片描述

那一刻,所谓 List replacement,其实已经不重要了。
重要的是,他终于从“会用组件”,走到了“会造秩序”。

而这,才是这篇文章最狠的一刀。

uni-app scroll-view 滚动卡死?一行CSS直接复活(iOS必看)

作者 于小洋
2026年4月10日 11:34

uni-app scroll-view 滚动卡死?一行CSS直接复活(iOS必看)

做uni-app开发的同学,有没有遇到过这种崩溃场景:页面用了scroll-view做滚动容器,点击Tab切换锚点后,整个页面突然不能滑动了,刷新也没用,只有重新进入页面才能恢复?

我最近就踩了这个坑,花了大半天排查,最后发现居然只要一行CSS就能解决,今天把整个排查过程和原理分享出来,帮大家避坑,尤其是做iOS端开发的同学,建议直接收藏备用!

一、问题复现(和我遇到的一模一样)

先给大家还原下我遇到的场景,方便大家对号入座:

  • 页面结构:用 scroll-view 包裹整个页面内容,内部分3个模块(基本信息、买车意向、卖车意向),顶部有Tab切换,点击Tab通过 scroll-into-view 实现锚点定位。

  • 问题现象:进入页面后,点击「卖车意向」Tab,锚点直接定位到模块最底部;此时尝试上下滑动页面,发现整个页面完全卡死,不能向上滑,只能向下滑(甚至向下滑也不流畅),刷新页面也无法恢复。

  • 环境:iOS端(真机+模拟器+Safari浏览器都复现),Android端正常,小程序端正常。

一开始我以为是锚点定位逻辑写错了,反复检查scroll-into-view、锚点高度计算,改了半天还是卡死,直到加上一行CSS,瞬间复活!

二、排查过程(踩坑记录,帮你省时间)

排查过程中,我走了3个弯路,大家可以跳过这些无效操作,直接看解决方案:

弯路1:怀疑锚点高度计算错误

一开始觉得是锚点高度获取有误——页面有「展示完整信息」的折叠/展开功能,初始化时获取的锚点高度是折叠状态的,展开后高度变化,导致定位偏移,进而触发滚动异常。

于是封装了锚点高度重新计算方法,在折叠/展开、Tab切换后重新查询DOM高度,虽然解决了锚点定位到底部的问题,但滚动卡死依然存在

弯路2:怀疑scroll-view滚动逻辑错误

接着检查scroll-view的滚动监听方法(scrollChange),发现里面有复杂的高度判断逻辑,比如用anchor2TopCopy动态计算偏移量,以为是判断条件出错导致滚动锁死。

简化了滚动监听逻辑,改成简单的三段式判断(根据滚动距离切换Tab),锚点定位更精准了,但滚动卡死问题还是没解决

弯路3:怀疑scroll-view样式配置错误

检查scroll-view的样式,确认已经设置了scroll-y="true"、flex:1、height:100%,没有多余的overflow样式冲突,排除了样式配置问题。

关键突破:定位到iOS原生回弹冲突

因为只有iOS端有问题,Android端正常,猜测是iOS原生特性和uni-app scroll-view的冲突。想起iOS有个「橡皮筋回弹」效果(overscroll),当scroll-view滚动到边界时,继续拉拽会出现空白回弹,会不会是这个回弹导致滚动状态错乱?

抱着试试看的心态,加了一行禁止回弹的CSS,没想到——滚动瞬间恢复正常,卡死问题彻底解决!

三、解决方案(一行CSS搞定,直接复制)

就是这行CSS,直接复制到你的页面样式中,iOS端滚动卡死问题瞬间解决:

::v-deep .uni-scroll-view, 
::v-deep .uni-scroll-view-content {
  /* 禁止iOS橡皮筋回弹,解决scroll-view滚动卡死 */
  overscroll-behavior: none;
}

补充说明:

  • ::v-deep 必须加:因为uni-app的scroll-view是组件封装的,需要穿透样式到子组件。

  • 适配范围:同时作用于.uni-scroll-view和.uni-scroll-view-content,确保所有滚动容器都禁止回弹。

  • 不影响其他功能:这行代码只禁止“边界回弹”,不影响正常滚动、锚点定位,Android端不受影响(overscroll-behavior在Android上兼容性有限,不会生效,也不需要生效)。

完美搭配(解决锚点+滚动双重问题)

如果你的页面也有折叠/展开模块,建议搭配锚点高度重新计算方法,实现“锚点精准+滚动流畅”:

// 重新计算所有锚点高度(折叠/展开后调用)
updateAnchorTop() {
  const query = uni.createSelectorQuery().in(this);
  query
    .select('#anchor1') // 基本信息锚点
    .select('#anchor2') // 买车意向锚点
    .select('#anchor3') // 卖车意向锚点
    .boundingClientRect((res) => {
      if (res[0]) this.anchor1Top = res[0].top;
      if (res[1]) this.anchor2Top = res[1].top;
      if (res[2]) this.anchor3Top = res[2].top;
    })
    .exec();
},

// 折叠/展开按钮点击事件
arrowClick() {
  this.arrow = !this.arrow;
  // 等待DOM渲染完成后重新计算锚点高度
  this.$nextTick(() => {
    this.updateAnchorTop();
  });
}

四、问题原理(为什么这行CSS能解决?)

核心原因:iOS的橡皮筋回弹(overscroll)与uni-app的scroll-view锚点定位冲突,导致滚动状态锁死

  1. 当点击Tab触发scroll-into-view锚点定位时,若定位到模块底部,会触发iOS的“越界回弹”(overscroll)。

  2. uni-app的scroll-view组件底层对滚动状态的处理,与iOS原生回弹机制不兼容,回弹后会导致scroll-view的滚动事件被阻塞,出现“卡死”。

  3. overscroll-behavior: none 的作用就是禁止元素的越界回弹行为,从根源上避免了冲突,滚动状态自然恢复正常。

补充:这不是你的代码写错了,而是uni-app在iOS端的一个经典兼容性bug,很多开发者都遇到过,一行CSS就能规避。

五、常见补充场景(避坑延伸)

如果加上这行CSS后,滚动还是有问题,大概率是以下2个原因,对应解决即可:

场景1:scroll-view高度计算错误

确保scroll-view的父容器有明确高度,scroll-view本身设置:

scroll-view {
  flex: 1;
  height: 100%;
  overflow-y: auto; /* 兜底,避免滚动异常 */
}

场景2:Tab切换锚点定位不精准

在Tab点击事件中,等待DOM渲染完成后再赋值锚点,避免异步高度问题:

tabClick(e) {
  this.indexNum = e;
  this.$nextTick(() => {
    this.anchor = e === 0 ? 'anchor1' : e === 1 ? 'anchor2' : 'anchor3';
    // 定位后兜底校准高度
    setTimeout(() => this.updateAnchorTop(), 100);
  });
}

六、总结

如果你在uni-app开发中,遇到iOS端scroll-view滚动卡死、不能滑动的问题,尤其是结合锚点定位、Tab切换时,直接用这行CSS就能解决:

::v-deep .uni-scroll-view, 
::v-deep .uni-scroll-view-content {
  overscroll-behavior: none;
}

本质是规避iOS原生回弹与uni-app scroll-view的兼容性冲突,属于“一招制敌”的解决方案。

另外,结合锚点高度重新计算方法,能同时解决“锚点定位偏移”和“滚动卡死”两个问题,适配更多复杂页面场景。

希望这篇文章能帮你节省排查时间,避免踩坑~ 如果还有其他uni-app滚动相关的问题,欢迎在评论区交流!

最后,求个点赞收藏,你的支持是我分享的动力 😊

赛博探案集:用 Vision 框架在像素迷宫中“揪”出文字真凶

2026年4月7日 09:59

在这里插入图片描述

这里是后厂村阴影中最神秘的“全栈侦探事务所”。当你的 if-else 走到尽头,当你的 Bug 堆积如山,资深探长“老司机”就是你最后的救命稻草。本期案卷记录了一次关于“像素与文字”的离奇遭遇:实习生阿强因“人肉 OCR”识别截图密码失败,险些引发上线事故。面对这起“视力危机”,我们拒绝蛮力,祭出了 Apple 强大的 Vision 框架。这不仅是一篇关于如何用 Swift 实现 OCR(文字识别)的硬核教程,更是一场从构建“文字捕手”到破解“坐标迷宫”的技术探险。准备好了吗?泡好你的枸杞咖啡,跟随老司机的代码,一起揭开隐藏在图片像素背后的真相。

🕵️‍♂️ 引子

在一个雷雨交加的周五深夜,位于后厂村的“全栈侦探事务所”依然灯火通明。传说中,这里有一位代号为“老司机”的资深工程师,他不仅能用汇编语言写情书,还能在没有任何文档的遗留代码(Legacy Code)中自由穿梭。

就在刚刚,事务所的大门被撞开了。实习生阿强跌跌撞撞地跑进来,手里挥舞着一张模糊不清的截图,脸上写满了被产品经理折磨后的绝望。“老大!出大事了!这图片里藏着服务器的 Root 密码,但我手抄了三次都提示错误!现在上线倒计时只剩 30 分钟了!”

在这里插入图片描述

老司机缓缓放下手中早已凉透的黑咖啡,推了推鼻梁上那副防蓝光眼镜,嘴角勾起一抹神秘的微笑。“阿强,把你的‘人肉 OCR’停一停吧。在 Apple 的地盘上,我们有更优雅的武器——Vision 框架。”

在本次探案之旅中,您将学到如下内容:

  • 🕵️‍♂️ 引子
  • 🤖 第一章:不仅是扫码工具人的 Vision
  • 🛠️ 第二章:打造“文字捕手” (The Text Recognizer)
  • ⚠️ 老司机的技术批注:
  • 🎯 第三章:给真相画个圈 (Highlighting Found Text)
  • 🤝 终章:真相大白

他指尖在机械键盘上飞舞,屏幕上开始跳动起绿色的代码符文。“坐好,今晚带你见识一下,如何用机器学习的‘天眼’,让图片里的文字自己‘招供’。”

在这里插入图片描述


🤖 第一章:不仅是扫码工具人的 Vision

听好了,阿强。大多数人对 Apple Vision 框架的印象,还停留在扫个二维码或者条形码这种“小儿科”的阶段。这就好比你拿着一把激光剑去切西瓜——简直是暴殄天物!

实际上,Vision 就像是给你的 App 装上了一双“写轮眼”。它不仅能从图片中识别并定位文字(Text Detection),还能把图片里的特定区域剥离出来、在连续的视频帧里追踪物体、甚至检测你那僵硬的手势和坐姿!

在这里插入图片描述

我第一次跟 Vision 打交道的时候,是写了一个 Swift 命令行工具来移除图片背景 ✂️。那时候我就意识到,这玩意儿简直是修图师的噩梦,程序员的福音。但今天,我们要用它来做点更硬核的——文字识别

在这里插入图片描述


🛠️ 第二章:打造“文字捕手” (The Text Recognizer)

要在茫茫像素中提取文字,我们得先组装一个名为 TextRecognizer 的“审讯室”。在这个环节,我们要用到 Vision 的核心组件:RecognizeTextRequest

这就好比我们向系统提交一份“搜查令”,告诉它:“嘿,帮我把这张图里的字儿都给我找出来,而且要准(Accurate)!”

在这里插入图片描述

来看看这段代码,这可是我们的核心武器:

import Foundation
import SwiftUI
import Vision
 
struct TextRecognizer {
    var recognizedText = ""
    // 保存识别到的所有“线索”(观察结果)
    var observations: [RecognizedTextObservation] = []
 
    // 这个初始化器是异步的,因为查案需要时间,急不得
    init(imageResource: ImageResource) async {
        // 1. 创建搜查令:RecognizeTextRequest
        var request = RecognizeTextRequest()
        // 2. 将识别精度设置为 .accurate(我们要的是精准打击,不是瞎猜)
        request.recognitionLevel = .accurate
        
        // 3. 将 ImageResource 转换为 UIImage
        let image = UIImage(resource: imageResource)
        
        // 4. 重点来了!Vision 不吃 UIImage 这一套,它只认二进制数据 Data
        // 所以我们必须把图片“粉碎”成 PNG 数据
        if let imageData = image.pngData(),
           // 执行搜查任务(perform)。这一步可能会失败,所以用了 try? 来“掩耳盗铃”
           // 注意:这里是异步等待结果
           let results = try? await request.perform(on: imageData) {
            
            // 5. 将抓获的嫌疑人(观察结果)关进 observations 数组
            observations = results
        }
 
        // 6. 审讯环节:遍历每一个观察结果
        for observation in observations {
            // 获取可能性最高的那个“候选词”(topCandidates(1))
            // 就像指认现场,我们通常只信最像的那个
            let candidate = observation.topCandidates(1)
            if let observedText = candidate.first?.string {
                // 把招供的文字拼接到结果字符串里
                recognizedText += "\n\(observedText) "
            }
        }
    }
}

在这里插入图片描述

⚠️ 老司机的技术批注:

这里有个坑你要注意,阿强。RecognizeTextRequest 是个挑剔的家伙,它不能直接处理 Swift 的 ImageUIImage 对象,它需要生肉——也就是 Image Data

在这里插入图片描述

所以我们必须先把图片转成 Data 格式。另外,整个过程是 async(异步)的,毕竟机器学习这玩意儿虽然快,但也没快到能超越光速,我们得给 CPU 一点“思考”的时间。

在这里插入图片描述

接下来,我们把这个“文字捕手”集成到 SwiftUI 的视图里,让你亲眼看看效果:

import SwiftUI
 
struct TextRecognitionView: View {
    let imageResource: ImageResource
    // 状态变量,一旦侦探有了结果,界面就会刷新
    @State private var textRecognizer: TextRecognizer?
 
    var body: some View {
        List {
            // 展示嫌疑图片
            Section {
                Image(imageResource)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .clipShape(RoundedRectangle(cornerRadius: 8))
            }
            .listRowBackground(Color.clear)
 
            // 展示审讯结果(识别出的文字)
            Section {
                // 如果 textRecognizer 还没初始化好,就先显示空字符串
                Text(textRecognizer?.recognizedText ?? "")
            } header: {
                Text("从图片中提取的证词")
            }
        }
        .navigationTitle("文字侦探")
        .task {
            // 重点:在 .task 修饰符里调用异步初始化器
            // 就像在后台偷偷干活,不阻塞主线程 UI 的渲染
            textRecognizer = await TextRecognizer(imageResource: imageResource)
        }
    }
}

这时候,阿强凑过来看着模拟器屏幕,只见原本模糊的截图下方,整整齐齐地列出了识别出来的文字。“卧槽,神了!连那个像‘1’又像‘l’的字符都分清了!”

在这里插入图片描述


🎯 第三章:给真相画个圈 (Highlighting Found Text)

“别急着庆祝,阿强。”我敲了敲桌子,“光把字认出来还不够,我们要做到按图索骥。既然 Vision 已经告诉了我们文字在哪里,我们就得在图片上把它们圈出来,就像犯罪现场的粉笔线一样。”

在这里插入图片描述

这里涉及到一个让很多新手头秃的概念:坐标系转换

Vision 返回的坐标是归一化的(Normalized),也就是说,它的 x 和 y 都在 0.0 到 1.0 之间。左下角是 (0,0),右上角是 (1,1)。但我们的屏幕图片是按像素画的,而且 UIKit/SwiftUI 的坐标原点通常在左上角。这就好比火星人给地球人指路,如果不好好翻译一下坐标,你画的框可能会飞到姥姥家去。

我们需要定义一个 Shape,专门用来画框:

import Foundation
import SwiftUI
import Vision
 
struct BoundsRect: Shape {
    // 这里存的是 Vision 给我们的“火星坐标”(归一化矩形)
    let normalizedRect: NormalizedRect
 
    func path(in rect: CGRect) -> Path {
        // 关键时刻!将归一化坐标转换为图片的实际像素坐标
        // origin: .upperLeft 是为了适配 SwiftUI 的坐标系习惯
        let imageCoordinatesRect = normalizedRect
            .toImageCoordinates(rect.size, origin: .upperLeft)
        return Path(imageCoordinatesRect)
    }
}

在这里插入图片描述

🔍 技术扩展: toImageCoordinates 这个方法虽然原文没细说,但它大概率是一个扩展方法(Extension),用于把 0~1 的小数映射到图片的 widthheight 上,并处理坐标原点的翻转。这一步至关重要,不做这一步,你的框框就会像没头苍蝇一样乱撞。

在这里插入图片描述


在这里插入图片描述

现在,我们把这个“现形符”贴到图片上:

struct TextRecognitionView: View {
    // ... 前面的代码 ...
    
    // 定义一个深红色的框,充满了悬疑感
    let boundingColor = Color(red: 0.31, green: 0.11, blue: 0.11)
 
    var body: some View {
        List {
            Section {
                Image(imageResource)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .clipShape(RoundedRectangle(cornerRadius: 8))
                    .overlay {
                        // 如果侦探已经有了观察结果
                        if let observations = textRecognizer?.observations {
                            ForEach(observations, id: \.uuid) { observation in
                                // 遍历每一个观察点,画个圈圈诅咒...啊不,标记它
                                // observation.boundingBox 就是那个归一化的坐标
                                BoundsRect(normalizedRect: observation.boundingBox)
                                    .stroke(boundingColor, lineWidth: 3) // 描边
                            }
                        }
                    }
            }
            // ... 后面的代码 ...
        }
    }
}

在这里插入图片描述

随着代码重新编译运行,屏幕上的截图发生了变化。每一个单词周围都被套上了一个暗红色的方框,就像是被狙击手锁定的目标。

在这里插入图片描述


在这里插入图片描述

🤝 终章:真相大白

“看到了吗?”我指着屏幕上被红框圈出的一串字符,“那根本不是 Root 密码。”

阿强瞪大了眼睛,盯着那行被 Vision 精准识别出的文字:WIFI_PASSWORD: 12345678

“这……这就是隔壁会议室的 WiFi 密码?”阿强瘫软在椅子上,“我为了这个通宵了两天?”

在这里插入图片描述

我拍了拍他的肩膀,语重心长地说道:“虽然你是个笨蛋,但好在 Vision 框架足够聪明。记住,Vision 不仅仅能找字,它还能做更多事情——从视频里追踪隔壁老王的身影,到检测你是不是在偷偷抠脚(Body Pose Detection)。今天我们学的只是冰山一角,但也足够你在这个充满像素迷雾的开发世界里防身了。”

就这样,Vision 框架再次拯救了一个无知的灵魂(虽然并没有拯救他的加班费)。

在这里插入图片描述

希望宝子们喜欢这个故事,以及它背后的技术,但对于小伙伴们来说,利用 Apple 强大的 ML 能力去探索未知的旅程,才刚刚开始。

在这里插入图片描述

保持好奇,保持代码整洁,我们下个案子见。👋🙂 8-)

穿透内容审查与阻断:基于 DNS TXT 记录的动态服务发现与客户端安全加固实践

作者 eleven4096
2026年3月20日 13:55

✍️ 引言

在开发面向全球或特定复杂网络环境的 App(如 XXX、跨境电商、海外加速等)时,最大的痛点往往不是业务逻辑,而是服务端的生存能力。为了对抗域名污染 (DNS Poisoning)SNI 阻断 以及 证书审查,我们通常需要一套极其灵活的「备用链路」与「动态发现」机制。

本文将结合在 iOS/Swift 项目中的实际落地经验,深度剖析一套基于 DNS TXT 记录 派发动态入口域名双向 mTLS 证书(p12)基码 以及 原生 TCP 直连 IP 的高可用架构,并详解其间的技术难点与避坑指南。


🛠 一、 核心架构设计

我们的目标是:哪怕主 Base 域名完全死锁,客户端只要能向公用 DNS 发一个查询,就能满血复活。

1. 数据如何藏在 DNS TXT 里?

由于一台域名的 A记录 只能存 IP,且极其容易被封锁,我们选择将配置加密后塞入 DNS 的 TXT 记录。 我们使用了多级子域名来承载不同的模块(由于 TXT 字符长度限制,需要分片):

子域名 (Subdomain) 承载内容特征 安全措施
root.yourbase.com 加密后的后备 HTTPS 业务 API 域名列表 AES-128-ECB 加密 + Base64
1.yourbase.com mTLS 客户端证书 P12 文件的 Base64 前半段 纯文本分片拼装
2.yourbase.com mTLS 客户端证书 P12 文件的 Base64 后半段 纯文本分片拼装
ip.yourbase.com 绕过 SNI 审查的裸 TCP 直连 IP 点对点通道 纯文本

🧠 二、 技术难点与避坑指南

难点 1:iOS 系统 API 无法直接发起原生 UDP DNS 查询

🚨 问题背景:  iOS 的 getaddrinfo 或者 NWHostResolver 是高层级 API,它们往往只返回处理好的 IP 地址(A/AAAA 记录),极难直接读取到 TXT、SRV 记录。如果调用系统的 res_nquery(属于 C 层的 libresolv),在弱网下容易造成线程死锁,且容易触发 iOS 严格的后台审计。

💡 解决方案:使用 Network 框架手工构建 UDP 53 端口查询 我们在 Swift 中封装了一个 DNSResolver,通过 NWConnection(to: 53, using: .udp) 手工下发标准 DNS 报文(RFC 1035)

  1. 构造 DNS 查询帧

    swift
    var data = Data()
    let id = UInt16.random(in: 1...65535)
    data.append(contentsOf: id.bigEndianBytes)
    data.append(contentsOf: UInt16(0x0100).bigEndianBytes) // Flags: 标准查询
    data.append(contentsOf: UInt16(1).bigEndianBytes)      // Question 数量 1
    // ... 拼接子域名 QNAME、QTYPE 为 16 (TXT)
    
  2. 并发查询优化: 由于国内 DNS 偶尔会有运营商后门或缓存污染,我们使用 withTaskGroup 并发地向四个公共 DNS 服务器发送请求 (223.5.5.5114.114.114.1148.8.8.81.1.1.1),谁最快返回合法的 TXT 内容,就直接 cancelAll() 结束任务


难点 2:UDP 的截断陷阱 (Truncated) 与 TCP 回退

🚨 问题背景:  由于拼装了庞大的客户端 p12 证书 Base64 字符串,TXT 记录往往会合在一起超过 512 字节。 在标准的 DNS UDP 查询中,如果响应超过 512 字节,包头部的 TC (Truncated) 标志位会被置为 1,代表数据被截断。

💡 解决方案:标志位侦测与 TCP Fallback 我们在 UDP 接收处做了一层守卫:

swift
if (data[2] & 0x02) != 0 {  // TC Flag is set!
    // UDP 遭遇截断,降级使用 TCP 53 端口进行可靠全量查询
    return await queryTCP(domain: domain, server: server)
}

进入 queryTCP 时,会在帧最前面补上 2 字节的大端序长度头,直接利用 NWConnection.tcp 握手拿到绝对完整的几千字节 TXT 加密串,完美解决大文件丢失问题。


难点 3:防劫持的 “端到端解密” 校验

🚨 问题背景:  如果中间人(Mitm)故意把你的 TXT 记录篡改成钓鱼网站或错误信息,即便配置下发了,APP 也会崩溃或中招。

💡 解决方案:AES + TCP 握手活性测试

  1. 对称加密:对 root 的分流域名进行 AES-128-ECB 加密。中间人即使拿到了,没有客户端的硬编码 Key 也无法篡改。

  2. TCP 通信握手探测活性: 在真正切换配置前,Manager 会多跑一遍 tcpTest。由于有些域名可能已经“挂了”,客户端会在后台静默并发跑:

    swift
    let connection = NWConnection(to: host, using: .tcp)
    connection.stateUpdateHandler = { state in
        if state == .ready { finish(true) } // 代表服务器可通达,不是死域名
    }
    

难点 4:动态 mTLS 证书灌入 (Security Manager)

经过 AES 解密和两片 TXT (1.txt + 2.txt) 拼装后,我们得到了完整的证书 Base64 编码。 我们要实实现本地无感知实例化,不需要把证书文件落地写死到沙盒里(防止反编译静态检查):

  1. 直接在内存中将组合好的 Base64 数据转为 Data
  2. 使用 SecPKCS12Import 函数,并将空密码(或者约定的暗号)传入,从内存里动态吐出 SecIdentity 和关联的 SecCertificate
  3. 把 Identity 灌入全局 SessionDelegate。当走 HTTPS 握手时,若触发 .clientCertificate 的 URLAuthenticationChallenge,直接从 cache 提取该 Identity 给系统使用。

难点 5:SNI 阻断应急方案 —— 18字节头部纯裸 TCP 定制通道

对于国内在极限阻断(如 SNI 嗅探)下的特殊业务,HTTPS 甚至会被阻断。我们追加了 ip.yourbase.com 提取裸 IP:

  • 业务无感降级:当 HTTPS 全灭,NetworkChannelManager 自动引导流量降级到我们自己用原生 NWConnection 敲出来的裸 TCP 直连。
  • 自定义封包协议:由于对端没有 TLS 证书做阻断,我们在应用层通过自研非对称二进制报头([18字节头部][Path][Hdr][Body] 及 响应 14字节头部)在服务端和客户端穿梭自如,极大增强了业务的可达率骨干。

📈 三、 业务安全成效

通过这套机制的上线,我们成功做到了:

  1. 云端无感知脱壳切换:后台可随意增减高防域名、甚至随时全量更替 TLS 的客户端校验私钥,对老版本客户端保持完美兼容。
  2. 零阻断时长:冷启动到成功跑通业务 HTTPS 的时间通过 TaskGroup 的竞赛机制下降到了 平均 0.3 秒以内。

💡 总结

服务高防链路的最佳伴侣不是冗余服务器,而是灵活、弹性的 发现机制。 利用 DNS 53 这个处于网络信任基座的协议,将 分片加密数据 优雅地回传至 iOS 客户端并发解码,不仅安全可靠,更筑起了一道无法轻易折断的强硬长廊。


提示:  在使用 114 / 223 等大陆 DNS 查询时,注意频率控制以心跳避免被运营商拉入恶意解析黑名单。对于更深层的防污染,甚至可搭配 DNS over HTTPS (DoH) 来取代 53 端口查询。

NativeScript iOS 平台开发技巧

作者 sp42_frank
2026年3月20日 09:35

升级到 NativeScript 8.7 后出现 APPLE is not defined 错误

出现了__APPLE__ is not defined 错误,是在你将 @nativescript/core, @nativescript/ios, @nativescript/android 升级到 ^8.7.0 版本后可能遇到的一个烦人错误。

官方推荐所有人都升级到 NativeScript 8.7,因为它包含了许多错误修复和改进,例如 devtool 以及恢复了从 8.4 版本开始中断的网络检查功能。然而有些人可能会遇到像下面这样的奇怪错误:

System.err: ReferenceError: __APPLE__ is not defined
System.err:
System.err: StackTrace:
System.err: ./node_modules/@nativescript/core/accessibility/font-scale-common.js(file: src/webpack:/FarmOps/node_modules/@nativescript/core/accessibility/font-scale-common.js:1:7)

原因

__APPLE__ is not defined 错误是由于 NativeScript 在他们的构建代码中引入了一些新的占位符。这些占位符依赖 Webpack 在构建时进行替换。而这个逻辑是在 @nativescript/webpack 5.0.19 中引入的。所以关键是确保你使用的 @nativescript/webpack 至少是 5.0.19 版本,才能成功使用 NativeScript 8.7 构建你的项目。

解决方案

所以基本上,解决 __APPLE__ is not defined 错误的方法是确保两件事:

  1. 首先,确保 @nativescript/webpack 的版本在你的 package.json 中没有被限制,像这样是最好的:^5.0.0
  2. 其次,确保你的 npm 已经知晓了 @nativescript/webpack 的最新可用版本,并且没有任何缓存。对我而言,我会执行 rm -rf node_modulesrm package-lock.json 然后再重新运行 npm i 来确保。或者更简单地,执行 ns clean 然后重新运行。

你总是可以尝试查看 package-lock.json找到 @nativescript/webpack 部分。如果它看起来像这样: 在这里插入图片描述

这表明实际安装的版本是 5.0.18,这是不行的。需要用我上面提到的任一种方法来解决。

在确保 @nativescript/webpack 版本没问题后,你现在可以再次运行 ns run 来继续你的 NativeScript 开发工作。

附言:如果你正在经历常见的 NativeScript 问题,并且需要一些快速修复或解决方法,请务必查看我们的“快速修复”部分。在这一部分,你会发现我在 NativeScript 之旅中收集的技巧和窍门,以及解决常见问题的解决方法。希望能帮助到许多像我一样的人。

NativeScript iOS: 无法启动模拟器

作为一名 iOS 开发者,最令人沮丧的事情莫过于 iOS 模拟器突然停止工作。这个工具对于在受控环境中测试和调试你的应用程序至关重要。当它失效时,你的工作流就会戛然而止,打乱工作效率并造成不必要的压力。

问题:无法启动模拟器

模拟器就是不工作,拒绝启动。并且一直说“无法启动模拟器”。 在这里插入图片描述

解决方案:

这个修复方法非常简单。

对于 Mac Ventura 13.0 及更高版本的操作系统 -> 点击 Mac 左上角的苹果图标 > 系统设置 > 搜索存储空间 > 等待加载,然后点击开发者 (Developer)。

在这里插入图片描述

在下一个屏幕中,选择删除 Xcode 缓存 (deleting Xcode Caches)。

删除完成后。尝试重新启动你的模拟器,现在它应该又能正常工作了。

如何正确修复:Info.plist 键 'BGTaskSchedulerPermittedIdentifiers' 必须包含一个标识符列表在这里插入图片描述

对于一个 NativeScript 应用,这个错误通常出现在 iOS 应用开发的上下文中,具体来说,当你或你安装的某些插件试图使用后台任务时,就会出现这个问题。

解决方法

  1. 打开你的应用的 App_Resources/iOS/Info.plist 文件。
  2. 如果尚不存在,添加键 BGTaskSchedulerPermittedIdentifiers
  3. 将其类型设置为 Array(数组)。
  4. 对于每个后台任务,在此数组中添加一项。每一项都应该是一个字符串,代表一个后台任务的唯一标识符。

使用示例

<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>

如果你有任何自定义的后台任务,你也需要将其 ID 列入上面的数组中。除此之外,你可以直接使用上面这段代码。

$(PRODUCT_BUNDLE_IDENTIFIER) 将被解析为你在 nativescript.config.ts 中定义的应用 Bundle ID,例如:com.newbiescripter.myawesomeapp

请记住,在 iOS 中使用后台任务有一些限制和准则,因为苹果旨在优化电池续航和性能。请确保你使用后台任务的方式符合这些准则。

苹果谷歌纷纷调低官方抽成,苹果谷歌全球抽成比例汇总

作者 CocoaKier
2026年3月13日 18:06

一、苹果中国区抽成“紧急”下调

2026年3月12日,苹果突然宣布中国区AppStore官方抽成从 30% 改为 25%,小型开发者抽成从15% 改为 12%2026年3月15日生效来源

想必,今天大家都被这个截图刷屏了吧。

图片.png

为什么说“紧急”呢?
1、“根据与中国监管部门的沟通”,写得很清楚,是中国监管部门推动的;
2、“自3月15日起”,约等于立刻生效,对比谷歌的三个月后生效,凸显一个“急”;
3、“调整无需开发者在此之前签署新条款”,手续流程都免了,直接生效!
2、“更新版协议的简体中文版将于一个月内在 Apple开发者网站上线”,流程后面再补,先上线!

不知道苹果发生了什么,但是感觉很爽。有种苹果被工信部发了违规整改通知的感觉(DDDD),让苹果也尝尝工信部的厉害,马上整改,立刻上线!哈哈哈。

中国开发者什么都不用做,代码都不用改,就额外增(bai)加(piao) 3%~5% 的收益。

感谢那些为此做出贡献的人!

补充:有律师说出了苹果紧急“降税”的真相 ,有兴趣的可以点开看看。

二、谷歌将陆续降低全球抽成并开放三方支付

苹果紧急降低抽成除了迫于监管压力,估计也迫于竞争对手的压力。

早在3月4日,谷歌在安卓开发者网站发布了一篇博客《选择和开放的新时代》宣布将陆续在全球降低抽成,开放第三方支付,并且后续除了《小型开发者计划》外,还会新推出《应用体验计划》和《游戏升级计划》来让利开发者

《应用体验计划》《游戏升级计划》的本质:质量换费率。通过经济激励(降低费率)来引导开发者提升应用和游戏的整体品质。开发者必须达到相应的技术集成和体验标准,来满足计划条件,才能获得费率减免。举例说明,比如,游戏类必须集成 Play Games Services 功能(如成就系统、现代玩家个人资料认证)。Play Console 中的“Android Vitals”指标,确保应用在崩溃率、ANR(无响应)率等方面符合谷歌的健康度标准。

计划的具体内容,谷歌尚未公布。

谷歌将现有的抽成拆成了两部分:
Google商店服务费:标准20%、参加上述新计划15%、小型开发者10%、订阅10%(取最小值)
Google支付服务费:约5%(每个地区可能不一样)

在美国、英国和欧洲经济区 (EEA),支付服务费为 5%。其他地区的支付服务费详情谷歌后续公布。

商店服务费,只要你在谷歌商店上架就要交,不管你用谷歌支付还是三方支付;
支付服务费,用谷歌支付就要交,用三方支付不交。

谷歌最终抽成比例:
官方支付抽成:15%~25%
三方支付抽成:10%~20%

谷歌新政策全球上线后,官方支付和三方支付只差5%,三方支付还得加上3%左右的通道费,和官方支付相比,三方支付毫无竞争力,这也是为什么谷歌敢在全球开放三方支付的原因。

需要注意的是,这次费率变化并非即刻生效,而是将分时间、逐步在全球不同地区推广:

各区域的推出日期 地区 《应用体验计划》《游戏升级计划》上线地区
2026年6月30日 欧洲经济区、英国、美国  
2026年9月30日 澳大利亚 澳大利亚、欧洲经济区、英国、美国
2026年12月31日 日本、韩国 日本、韩国
2027年9月30日 世界其他地区 世界其他地区

三、苹果、谷歌全球抽成比例汇总

目前,谷歌和苹果,在全球都面临着反垄断、三方支付、三方商店的压力,革命一旦发起,就像星星之火一样会传递到全世界,一会这个国家闹,一会那个国家闹。面对这样的情况,谷歌和苹果却走出了不一样的应对路数。

1、谷歌全球统一标准

谷歌,将在2026年到2027年陆续在全球执行统一的新标准,开放三方支付、开放三方商店。

Google商店服务费:标准20%、参加上述新计划15%、小型开发者10%、订阅10%(取最小值)
Google支付服务费:约5%(每个地区可能不一样)

官方支付抽成:15%~25%
三方支付抽成:10%~20%

全球实行时间线:

各区域的推出日期 地区 《应用体验计划》《游戏升级计划》上线地区
2026年6月30日 欧洲经济区、英国、美国  
2026年9月30日 澳大利亚 澳大利亚、欧洲经济区、英国、美国
2026年12月31日 日本、韩国 日本、韩国
2027年9月30日 世界其他地区 世界其他地区

2、苹果按闹施政

从目前来看,苹果是按闹施政,谁闹我就便宜点,不闹就维持原样。但感觉不是长久之计,说不定苹果后续也会像谷歌那样统一标准。目前情况来看,谷歌还是眼光更长远一些,走在了前面,胸襟更大。

以下是苹果当前(2026.3.13)全球费率情况

地区 官方内购参考佣金 三方支付苹果抽成 备注
欧盟 13% - 20%,官方文档 15%~20% 欧盟计费很复杂,还会按安装量抽成
日本 15% - 26%,官方文档 10%~15%,外部链接购买 10%~21%,应第三方购买  
韩国 15% ~ 30% 11% ~ 26%  
美国 15% - 30% 0%,外部链接购买 海外公司可以申请;必须同时提供内购作为备选;仍然向苹果上报收入用于审计
中国 12% ~ 25%,官方文档 不允许三方支付  
其它 15% ~ 30% 不允许三方支付

和谷歌一样,苹果也把抽成拆了商店服务费+支付服务费,从上表可以看到三方支付和官方支付比也没有优势。

美国外链支付比较特殊,可以做到0%费率,但同样要满足三方支付的苛刻条件:必须接入官方内购作为备选、有苹果警告弹窗、仍然需要上报三方收入给苹果审计。

如果你对三方支付感兴趣可以看看我往期文档《三方支付真的香吗?日本iOS、Google三方支付调研报告 》,这篇虽然讲得是日本,但三方支付的接入流程和要求,全球都是一样的。

苹果谷歌商店:如何监控并维护用户评分评论

作者 CocoaKier
2026年2月28日 18:59

前阵子,我无意中发现我们的应用在 App Store 上悄然出现了几条差评,但团队里似乎没人注意到。这让我意识到一个严重的问题:如果我们不能及时听到用户的声音,怎么能及时发现应用的不足,留住用户呢? 更令人担忧的是,潜在用户在下载前往往会浏览评论区,一条未被回应的负面评价,可能就足以让他们转身离开,影响新增转化。

如果能在用户留下评论(尤其是差评)的第一时间收到通知,我们就能快速响应、修复问题、安抚情绪,甚至将一次不满转化为一次忠诚度的提升。更重要的是,积极、真诚地回复用户评论,不仅能展现团队的专业与负责,还能向所有观望者传递一个信号:我们在乎每一位用户。

本篇文章将从实操角度出发,为不熟悉苹果和谷歌开发者后台的开发或运营同学,讲解如何监控苹果谷歌商店的评分评论,以及如何回复用户评论,为大家提供一些帮助。

一、苹果

苹果开发者后台 appstoreconnect.apple.com/,需要 客户支持 权限。

1、如何监控评分和评论

苹果后台目前不支持收到新的评分评论后邮件通知开发者。只支持“开发者回复”(当顾客编辑你已回复的评论时,你将收到电子邮件),如需开启“开发者回复”邮件通知,按下面步骤操作:

登录 App Store Connect。
点击右上角的用户头像,进入 “用户和访问”。
选择你的账户,在左侧菜单点击 “通知”。

Tips:“收到评分评论后邮件通知开发者”,这个功能在旧版 iTunes Connect 中曾经存在,但在新版 App Store Connect 中已被移除。猜测苹果可能不想开发者过度关注单条评分评论。

如果目前想要监控苹果商店的评分评论,有几个方案可参考:
1、使用官方的 App Store Connect App,每天刷一刷,自己主动去看。App内可以设置“接收用户评分”通知,但不确定现在还是否有效。
2、苹果官方提供了App Store Connect API,可以自己开发程序拉取用户评分,再进一步做监控。
3、滴答清单定个周期性提醒,每天上班打开商店详情瞅一眼,现在苹果上线了Web版AppStore了,瞅一眼也很方便。
4、借助第三方平台。

2、查看和回复用户评论

(1)通过网页端查看

登录苹果开发者后台,appstoreconnect.apple.com/

评分评论入口:分发 - 评分和评论 图片.png

点击“回复”可以回复用户评论
图片.png

(2)通过官方App "App Store Connect" 查看

iOS端下载地址:apps.apple.com/cn/app/app-…
(如果你搜不到可能是你手机系统版本太低了。没有安卓端。)

图片.png App Store Connect App核心功能:
-- 销售与趋势监控(查看 App 的下载量、销售额)
-- 版本状态管理(跟踪审核状态,回复审核)
-- 用户评论处理(查看和回复评论)

App Store Connect内查看评分及评论入口:
图片.png

3、重置总评分

发布新版本到 App Store 时(必须更包),你可以重置 App 总评分。重置后,你的 App Store 产品页面将显示说明,提示顾客 App 的总评分最近已重置。此说明将一直显示,直到有足够多的顾客对新版本进行了评分且页面出现新的总评分。

请注意,重置总评分并不会重置顾客评论,App Store 仍将继续显示历史的顾客评论

图片.png

二、Google

Google开发者后台 play.google.com/console/dev…,需要 用户反馈 权限。
“用户反馈”权限

1、如何监控评分和评论

Google官方支持收到新的评论后邮件提醒开发者,并支持按应用、评分星级设置不同的提醒开关。注意:邮件提醒默认是关闭的,需要手动开启。请按下列步骤操作。

Google开发者后台 - 设置 - 个人邮件通知(这个只会改你个人的通知设置,不会改整个团队的) 图片.png

按需将邮件提醒开关打开,修改后记得保存。
图片.png

如果你的账号拥有开发者账号下多个App的权限,默认是所有应用都给你发邮件,点击下图位置,可以选择哪些应用接收邮件。 图片.png

收到新的评论后,Google会给你推邮件,模板样式如下,包含了应用名称、评分星级、评论内容,不用打开Google后台就能看到评论内容,很方便。
注意:如果你接收了多个应用的邮件,请留意邮件标题里App的名字。

图片.png

2、查看和回复用户评论

(1)网页端

Google后台 - 应用 - 监控与改进 - 评分与评价。

Google后台的评论,Google会默认帮你翻译成你的语言,很贴心。如果你想看原始评论,点击“显示原评论”查看。你也可以在这里回复用户的评论。
图片.png

(2)官方 Google Play Console App

Google也像苹果一样,提供了官方的供开发者维护自己App的应用,Google Play Console App。你可以通过它在移动端方便的看评分和回复评论。

iOS端:apps.apple.com/cn/app/goog…
安卓端:play.google.com/store/apps/…

Google Play Console App

Google Play Console App 核心功能:

  • 查看数据指标:监控安装量、卸载量、更新量以及应用的崩溃率(ANR/Crash)。
  • 回复用户评论:及时查看并回复用户的评价,这对于维护 App 评分至关重要。
  • 订单管理:查看应用内购买和订阅的订单详情,甚至可以进行简单的退款操作。
  • 发布状态监控:跟踪应用版本的审核进度和发布状态。

3、Google不支持重置评分评论

Google不像苹果那样可以主动重置评分。虽然你不能手动重置,但 Google Play 的评分系统是动态权重的,更加偏重于近期(Recent)的用户评分权重会更高

这意味着:
(1)如果你的应用过去因为有 Bug 而评分很低,只要你在新版本中修复了问题,随着新用户和老用户在近期的好评增多,你的平均分会逐渐回升。
(2)时间是最好的解药:只要新版本的体验确实提升了,评分曲线会自动向好的方向修正。

三、结束语

其实维护应用商店的评论,并不需要多么复杂的流程或高深的技巧,但你做了和没做,用户感受是不一样的,每个人都希望被尊重,用真诚打动你的用户吧!

希望这篇文章能给你一点帮助。如果你有更好的监控方法,欢迎留言交流。

参考文档
【苹果官方文档】查看评分和评论

百款出海社交 App 一夜下架!2026,匿名社交的生死劫怎么破?

作者 iOS研究院
2026年2月25日 20:15

2026年2月24日,出海社交领域迎来标志性的“黑色星期二”,百余款社交类App在无任何预警、无邮件通知、无申诉通道的情况下,被App Store集体下架。即便部分应用近期刚完成版本更新、运营状态平稳,也未能幸免。此次事件引发行业震动,苹果的清理行动究竟是偶然误伤还是定向整治?下架风暴的背后暗藏哪些监管逻辑?出海社交开发者如何突破困境、实现可持续发展?本文将深入拆解事件本质,梳理监管趋势,提供合规生存路径。

ScreenShot_2026-02-25_194516_417.png

ScreenShot_2026-02-25_194451_015.png

定向整治而非偶然误伤,四大市场同步发力

此次App Store下架行动并非随机操作,而是覆盖美国、澳大利亚、巴西、新加坡四大核心市场的定向清理,各市场虽审查重点略有差异,但整治核心高度统一,均聚焦于高风险社交场景。

美国作为全球最核心的应用市场,下架应用表面涵盖AI音乐、职场社交、旅游、育儿等多个品类,但核心筛选标准清晰——凡是包含“Live Chat”“Video Chat”“Meet New Friends”等关键词、以陌生人实时互动为核心功能的社交应用,均成为清理重点。

新加坡与澳大利亚的清理逻辑高度一致,对匿名社交类应用实施“零容忍”政策,大量主打“匿名聊天”“视频聊天”的产品被集中移除,其中不乏Aloha Live - Anonymous Chat、Xonder: Anonymous Chat & Vent等直接以“匿名”为核心卖点的应用,凸显两地对不可追溯社交模式的严格监管态度。

巴西市场的清理范围进一步扩大,除纯社交应用外,春辉乐玩、玩伴Vibe等具备旅游属性的轻度社交产品也被纳入下架名单。这一举措背后,是巴西市场将用户数据安全与未成年人保护纳入核心审查维度,审查标准提升至历史新高。

中国开发者高频踩雷:四类高危产品触发监管红线

梳理此次被下架的中国开发者相关产品,可发现其普遍存在明确的“高危特征”,均精准触碰了全球监管红线,具体可分为四大类:

1. 匿名树洞类产品

以默言、nimi-i人专属匿名聊天为代表,这类产品精准定位职场人、社恐群体的表达需求,主打“匿名对话”“无社交压力”等核心卖点,部分产品甚至取消点赞、推荐、动态广场等功能,极致强化匿名属性。但在监管层面,匿名意味着用户行为不可追溯,此类模式被明确界定为“高风险交互模式”,极易成为不良信息传播的载体,从而触发监管处罚。

2. 速配交友类产品

连连婚恋、LivMe-Meet new friend等产品均以“陌生人速配”为核心模式,前者面向职场人群提供免费婚恋交友服务,后者主打全球范围内的随机匹配聊天。此类产品的核心痛点的在于,多数中小开发团队难以承担7×24小时实时内容审核的成本,缺乏完善的审核机制,导致诈骗、色情等违法违规信息极易滋生,成为监管重点整治对象。

3. AI情感伴侣类产品

Joiy、ItsMee等产品将AI技术与情感社交深度结合,推出AI聊天、情绪匹配、专属AI聊天机器人等功能,看似是产品创新,实则触碰监管敏感点。AI技术本身并非违规核心,但当AI被用于模拟人类进行情感交流,且存在触达未成年人的可能时,监管容忍度降至零。此次下架也明确释放信号:情感类AI社交已成为全球监管的下一重点领域。

4. 马甲工具/社区类产品

部分产品以工具、垂直社区为外壳,暗藏社交属性,例如摄影社区CNU-顶尖视觉精选,虽以摄影内容分享为核心,但包含UGC内容发布、用户私信互动等社交功能,最终也被纳入清理范围。这一现象表明,只要涉及用户互动与内容传播,无论产品外在形态如何,均需遵守社交应用监管规范,不存在“法外之地”。

双重监管合围:苹果新规与全球法律形成监管合力

此次下架风暴的爆发,并非苹果单独行动,而是苹果平台规则升级与全球各国监管政策收紧形成的合力,推动出海社交行业正式进入“强合规时代”。

苹果平台规则升级:匿名社交被明确禁止

2026年2月6日,苹果悄然更新《App Store审核指南》,在1.2章节“用户生成内容”中,明确将“随机或匿名聊天”与色情内容、人身威胁、欺凌等列为App Store禁入类型,并保留“未经通知即可移除应用”的权利。

此前广泛应用于陌生人社交的Chatroulette式随机匹配模式,曾是行业核心创新点,如今已被定义为高风险功能。苹果的监管逻辑清晰:匿名+随机社交模式需要极致的内容审核能力,而多数中小开发团队难以承担相应成本,为规避平台风险,采取“一刀切”的清理策略。

全球各国监管收紧:未成年人保护成核心红线

如果说苹果新规是“平台层面的管控”,全球各国的法律政策则是“市场层面的约束”,且均以未成年人保护为核心,进一步压缩不合规产品的生存空间:

——巴西、澳大利亚、新加坡:自2月24日起,下载18+应用需通过苹果年龄验证;巴西额外规定,包含“开箱抽奖”等类赌博机制的应用,直接评级为18+,直接切断此类社交+游戏类产品的未成年人用户市场。

——美国:犹他州《应用商店责任法案》已于2025年5月生效,要求应用商店强制验证用户年龄,未成年人账号需关联家长账号,开发者违规将面临家长最高1000美元/次的索赔,苹果为规避“连坐”风险,进一步提高应用审核标准。

——欧洲:欧盟近期认定TikTok的“成瘾性设计”(如无限滚动、自动播放)违反《数字服务法案》,拟处以全球年收入6%的罚款;西班牙更推进“禁止16岁以下未成年人使用社交媒体”的政策,进一步强化对未成年人的保护。

综上,此次下架风暴是全球监管层对社交产品的一次“全面清算”,过去“先野蛮生长、后合规整改”的出海模式已彻底失效。

2026年出海社交合规生存指南:三大路径实现突围

面对全球监管收紧的大环境,出海社交开发者若想实现可持续发展,核心在于放弃侥幸心理、坚守合规底线,以下三条路径可作为破局关键:

路径一:放弃匿名模式,搭建实名/强认证体系

若产品商业模式依赖“用户匿名、无需对言行负责”的核心逻辑,需尽快完成转型。未来社交产品的核心底线是“可追溯”,即便采用昵称体系,也需搭建完善的持久账户体系,通过手机号验证、身份信息核验等强认证方式,确保用户行为可追溯、可管控,从源头降低不良信息传播风险。

路径二:将合规融入产品功能,适配全球监管要求

苹果推出的“申报年龄范围API”不应被视为运营负担,而应作为核心功能进行适配。开发者可针对不同年龄段用户设计差异化内容与功能:对未成年人开启严格的内容过滤、使用时间管理机制;对成年人提供合规范围内的社交服务。这种“分龄管理”模式,不仅能满足全球监管要求,更能提升产品公信力,成为打入欧美主流市场的核心优势。

路径三:严控AI功能风险,建立完善的内容过滤机制

随着AI技术在社交领域的广泛应用,AI陪聊、AI生成头像、AI匹配等功能成为产品创新方向,但需严格把控风险。开发者在引入AI功能前,需明确三大核心问题:AI训练数据是否合法合规?是否存在生成涉黄、涉政等敏感内容的可能?是否会诱导未成年人做出危险行为?无论采用何种大模型,均需建立严格的输出过滤机制,即便牺牲部分产品趣味性,也要确保内容绝对安全——海外市场中,单一违规内容(如AI生成的疑似儿童违规图片),即可导致应用永久下架,开发者甚至需承担刑事责任。

结语:合规是出海社交的唯一生路

2026年2月24日的下架风暴,只是全球社交领域监管收紧的一个开端。随着全球数字治理体系的不断完善,过去依赖技术红利、模式创新就能快速出海的时代已一去不复返,合规能力将成为出海社交开发者的核心竞争力。

对于在此次风暴中下架的产品,行业深感遗憾;而对于仍在坚守的开发者,需重新审视产品逻辑,主动拥抱监管、搭建完善的合规体系。唯有坚守合规底线,才能在全球出海赛道中长久立足——2026年,合规才是出海社交的唯一生存通行证。

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

相关推荐

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

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

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

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

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

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

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

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

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

开工第一天,别让AI写的代码触发3.2f封号。

作者 iOS研究院
2026年2月24日 14:17

背景

今天是农历正月初八,春节后的第一个工作日。后台有粉丝留言,迎来的开年的第一记重磅打击3.2f待终止通知。

踩线原因也是老生常谈了,严查分类之隐藏功能问题

中英对照.png

老iOSer对于这种情况已经是见怪不怪了,很多时候并非开发者想做某些Sao操作,实属无奈的多。毕竟,有业务苹果不能正面允许,不得已就采用这种上有政策下有对策的打法

原因分析

通过进一步沟通,层层抽丝剥茧。终于定位到踩到隐藏功能的导火索,在AI加持的情况下使用了非公开的API获取业务层面需要的功能权限。从业务的角度来看功能确实实现了,从苹果监管的角度来看调用了越权的API属性。通过键值对的方式Hook数据结果。

实话讲AI背大锅,对于很多跨行的开发者来说,为了满足公司的开发需求保住饭碗使用AI的方式本身没有问题。关键的问题在于,无法Review AI所编写的代码是否合规

所以,AI本质是一把双刃剑,在提高开发效率的同时,也需要额外考虑风控问题。

隐藏功能

隐藏功能的前身是苹果开发者指南中的-2.3.1条款。

主要意在通过一些动态下发的方式,直接或间接干预苹果审核所看到的内容。将符合苹果审核的内容作为A面,顺利通过审核,提高审核通过率。【俗称的AB面,也叫马甲包】

随着AppStore审核规则的加强,对于隐藏功能的判定不仅仅只是单纯的功能切换,而是上升到更为全面的元数据以及概念层面。

简单来说:

少做不做挂羊头卖狗肉的事情,苹果的算法比开发者想象中更加强大

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

相关推荐

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

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

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

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

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

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

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

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

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

AppLovin 危机升级:SDK 安全争议未平,建议移除为妙

作者 iOS研究院
2026年2月6日 14:33

背景

继 1 月做空机构 CapitalWatch 指控 AppLovin 深度涉入洗钱网络、关联东南亚 “杀猪盘” 后,这场资本风波的余震仍在持续。最新市场数据显示,截至 2026 年 2 月 5 日,AppLovin(股票代码:APP)股价已从 2025 年 11 月 10 日的 651.32 美元跌至 375.23 美元,三个月累计跌幅达 42.39% ;仅 2 月前 5 个交易日,股价就从 483 美元跌至 375.23 美元,单周跌幅超 22%,换手率最高达 6.65%,市场恐慌情绪可见一斑。

争议再发酵:从股东合规到 SDK 技术风险

此前 CapitalWatch 的报告已指出,AppLovin 主要股东 Hao Tang、Ling Tang(被指为 Hao Tang 亲属)及关联方合计持股超 28%,涉嫌通过广告业务协助转移团贷网非法集资款、东南亚诈骗资金。尽管 AppLovin 全盘否认指控,称 “无法控制个人股票买卖”,但市场对其股东层面的合规失职质疑未消 —— 作为上市公司,对主要股东的背景审查、反洗钱流程是否到位,至今仍是未解之谜。

更关键的是,这场争议已直接波及普通开发者。有行业分析指出,AppLovin 的 SDK 存在两大核心风险:一是技术合规问题,其 SDK 被曝包含指纹追踪、静默安装功能,前者可能违反用户隐私保护法规(如 GDPR、CCPA),后者则可能绕过用户授权强制安装应用,存在被应用商店下架的隐患;二是连带风险,若后续监管部门(如美国司法部、SEC)对 AppLovin 启动调查,或要求平台自查涉事 SDK,开发者可能面临 “猝不及防的下架压力”,影响应用正常运营。

股价暴跌背后:多重利空下的市场信心崩塌

从股价走势看,AppLovin 的颓势并非偶然。除了洗钱、SDK 合规争议,其商业模式本身也存在隐忧。此前已有做空机构指出,AppLovin 约 35% 的广告收入来自超休闲游戏,而这类业务的虚假点击占比或达 20% ;同时,公司 60% 的流量依赖 Meta 和 Google,若上游平台调整政策,收入可能面临断崖式下跌。

叠加最新的合规风险,机构对其估值的分歧持续扩大。截至 2 月,尽管仍有 9 家机构给出 “强力推荐” 评级,但最低目标价仅 80 美元,较当前股价隐含 75.8% 的跌幅。空头仓位也在激增,1 月 3 日单日做空量占比达 21.36%,累计空头仓位超流通股 15%,逼近熔断阈值,市场对其信心已降至冰点。

开发者应对指南:规避风险刻不容缓

面对 AppLovin 的多重危机,开发者需优先考虑业务稳定性,避免踩入合规 “雷区”:

  • 评估替换方案:若当前应用集成了 AppLovin SDK,建议尽快调研广告聚合平台,通过接入多渠道广告源,降低对单一 SDK 的依赖,避免因 SDK 下架导致收入断层;
  • 自查合规细节:重点检查 AppLovin SDK 的指纹追踪、静默安装功能是否关闭,确保用户数据收集、应用安装流程符合当地隐私法规(如 GDPR 的用户同意要求);
  • 跟踪监管动态:密切关注美国司法部、SEC 及应用商店(如苹果 App Store、Google Play)的最新政策,若出现针对 AppLovin 的调查或下架通知,需第一时间启动应急方案。

AppLovin 的案例也为整个行业敲响警钟:在选择第三方 SDK 时,除了关注流量、收益,更需穿透式审查合规情况。

毕竟,一次合规危机带来的损失,可能远超过去的收益

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

相关推荐

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

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

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

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

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

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

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

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

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

❌
❌