做了一个毒舌版 MBTI 测试 App,聊聊 16×2 套文案的数据架构和海报渲染踩的坑

5 阅读6分钟

起因

去年底刷小红书,发现 MBTI 相关内容传播量特别高,但市面上的测试工具要么太严肃(像在做心理量表),要么太粗糙(网页加载半天)。我就想:能不能做一个带毒舌文案的人格测试,结果页天然适合截图分享?

从去年 11 月开始动手,工作日晚上加周末,前后大概投了 200 小时,做出了「NMTI人格测试」。核心卖点是职场场景题 + 幽默犀利的结果文案,测完生成一张海报,标签类似「六边形悍匪」「优雅吸血鬼」「赛博隐形人」这种,分享欲直接拉满。

产品设计上的几个决定

说实话一开始我纠结了很久要不要做得学术一点。后来想通了:我不是在做心理咨询工具,是在做一个大家聊天时拿出来玩的东西。

所以我定了几条规则:

  1. 题目全部用具体场景,不问「你觉得自己是内向还是外向」这种废话,而是「周一早上开会,你发现同事把你的方案改了个面目全非,你会?」
  2. 结果文案必须有攻击性,但攻击的是「现象」不是「人」。比如「优雅吸血鬼」的描述是「满嘴大局观,实则不干活,还能把锅甩得极其自然」——看了会笑,不会真觉得被冒犯。
  3. 做了两套题包:「基础牛马包」面向职场人,「做题家版」面向学生群体,同一个人格类型在不同场景下有完全不同的文案。

技术架构:16 种人格 × 2 套题包怎么组织

16 种人格,每种有 slogan、tagline、详细描述三段文案,再乘以两套场景,光文案数据就是 16×2×3 = 96 段文本。一开始我把所有文案堆在一个文件里,写到第五种人格就已经看不下去了。

最后用的方案是:Personality 结构体存放职场版文案作为默认值,学生版文案通过扩展文件里的字典单独维护,运行时按题包类型动态取:

struct PersonalityCopy {
    let slogan: String
    let tagline: String
    let fullDesc: String
}

extension Personality {
    func copy(for pack: QuizPack) -> PersonalityCopy {
        switch pack {
        case .workplace:
            return PersonalityCopy(slogan: slogan, tagline: tagline, fullDesc: fullDesc)
        case .student:
            return Self.studentCopy[id]
                ?? PersonalityCopy(slogan: slogan, tagline: tagline, fullDesc: fullDesc)
        }
    }
}

这里的 fallback 逻辑是关键——学生版文案我是分批写的,不可能 16 种一次性交付。有了这个降级策略,写完几种就能发版,没写完的先显示职场版,用户体验上不会有空白。

存档兼容:加字段不崩老数据

用户会多次测试、切换题包。本地存档用的 Codable 序列化到 UserDefaults。问题是 1.0 版只有职场版,ArchiveEntry 里没有 pack 字段。1.1 加了学生版后,老用户的存档 decode 会拿不到这个字段。

init(from decoder: Decoder) throws {
    let c = try decoder.container(keyedBy: CodingKeys.self)
    id              = try c.decode(UUID.self, forKey: .id)
    nickname        = try c.decode(String.self, forKey: .nickname)
    personalityCode = try c.decode(String.self, forKey: .personalityCode)
    scores          = try c.decode(DimensionScores.self, forKey: .scores)
    date            = try c.decode(Date.self, forKey: .date)
    // 旧存档没有 pack,默认职场版
    pack            = try c.decodeIfPresent(QuizPack.self, forKey: .pack) ?? .workplace
}

decodeIfPresent + 默认值,不用做数据迁移,老用户更新后存档正常读取。这个模式我在好几个项目里都用了,对需要持续加字段的本地存储来说是最省事的方案。

互测功能:deeplink 串联两端

「测朋友」模式的流程是:A 生成一个测试链接发给 B,B 做完题后双方都能看到结果对比和相处建议。

技术上我用的是 Universal Links。A 端生成链接时,把自己的人格编码和一个临时 session ID 编码进 URL path。B 打开链接后进入答题流程,答完把结果连同 session ID 一起提交到后端(用的 CloudKit public database,省得自己搭服务器)。A 端通过 CKSubscription 监听这个 session ID 对应的 record 变化,收到通知后拉取 B 的结果展示对比页。

说实话最麻烦的不是技术,是文案量——理论上 16×16=256 种相处建议。我没全手写,按照 rivalCode(相克型)和 partnerCode(互补型)先覆盖了 32 种典型组合的详细文案,剩下的用维度差异规则生成通用版本。后续再根据用户反馈逐步补全。

海报渲染的坑

结果页有个「生成海报」按钮,点了之后把人格标签、配图、slogan 合成一张适合分享的图。

一开始用的 SwiftUI ImageRenderer(iOS 16+),代码写起来很舒服,但在 iPhone 11 上能卡 2-3 秒,用户点完按钮以为没反应又点一次。

后来换成了 UIGraphicsImageRenderer,把 SwiftUI 的海报视图通过 UIHostingController 转成 UIView,再离屏绘制:

let hostingController = UIHostingController(rootView: posterView)
hostingController.view.frame = CGRect(origin: .zero, size: targetSize)
hostingController.view.layoutIfNeeded()

let renderer = UIGraphicsImageRenderer(size: targetSize)
let image = renderer.image { ctx in
    hostingController.view.layer.render(in: ctx.cgContext)
}

切到这个方案之后,同一台 iPhone 11 上渲染时间从 2.8s 降到了 0.4s 左右。原因我猜是 ImageRenderer 内部走了一套更重的布局流程,而 UIGraphicsImageRenderer + CALayer.render 更直接。

还有个小坑:layoutIfNeeded() 必须在 render 之前调用,不然拿到的是空白图。这个问题我 debug 了一个小时才发现。

文案尺度把控

这个其实不算技术问题,但花的时间比写代码还多。

第一版文案写得太毒了,有几个人格的描述让测试者觉得不舒服。比如「全宇宙我最惨」这个类型,一开始我写的是直接嘲讽拖延症,几个朋友测完说「感觉被骂了」。

后来调整了原则:犀利可以,但要让被描述的人觉得「确实是这样哈哈」而不是「你在骂我」。具体操作就是在每段毒舌之后加一句「理解你为什么这样」的回收,让攻击变成自嘲式的共鸣。前后改了大概三分之一的文案。

四维度打分

我没有直接用 MBTI 的 E/I、S/N、T/F、J/P。定义了四个维度 C、S、E、A,每道题的选项给对应维度加分,最终根据高低组合出 2^4 = 16 种人格编码。

题目设计上花的功夫最多。每道题的四个选项要精确映射到某个维度的正负向,还得让用户觉得「这确实是我会选的」,不能太明显地暴露测量意图。找了十几个朋友测试,前后改了三四版题目才定稿。

当前状态

App 刚上架不久,数据还很早期。在想怎么用更低成本启动第一波传播——这种测试类产品的增长引擎就是「测完分享→别人看到也想测」这个循环。海报的视觉我花了不少心思,每种人格有专属配图和配色,至少截图发朋友圈不丑。

一个经验:文案质量 > 功能丰富度。一个让人想截图的结果页,比十个没人用的功能管用得多。

感兴趣的可以在 App Store 搜 NMTI 试试,测完回来评论区报下你的类型——我挺好奇掘金开发者里「六边形悍匪」和「赛博隐形人」哪个更多。