起因:严肃的MBTI测试没人转发
去年办公室突然流行起MBTI测试。我观察了一圈,发现大家测完之后最感兴趣的不是那些学术分析,而是互相对着结果吐槽:“你果然是I人”“难怪你开会从来不说话”。
说白了,大部分人做MBTI就是拿来发朋友圈的谈资。
但市面上的MBTI工具要么太严肃(长篇学术描述,谁看啊),要么太同质化。我就想:能不能做一个职场场景驱动的人格测试,结果文案写得毒舌一点,让人看完忍不住截图转发?
这就是「NMTI人格测试」的由来。N 取自 New,也取自"牛马"的谐音——对,就是这么直白。
产品长什么样
几个核心功能:
- 测自己:回答一系列职场/校园场景题,系统算出你属于16型人格中的哪一种,配上专属毒舌文案
-
- 测朋友:把链接发给朋友,对方做完之后双方能看到互测对比和"相处指南"
-
- 结果海报:每个人格有个标签,比如「六边形悍匪」「优雅吸血鬼」「赛博隐形人」,生成可以直接分享的海报
两个题包:「基础牛马包」面向职场人,「做题家版」面向学生党。同一个人格在不同题包下,文案风格完全不同。
- 结果海报:每个人格有个标签,比如「六边形悍匪」「优雅吸血鬼」「赛博隐形人」,生成可以直接分享的海报
多文案架构:16种人格 × 2套题包怎么组织
这事儿我纠结了挺久。
最开始我把所有人格数据塞在一个大数组里,每个 Personality 包含职场版和学生版的文案字段。写到第三个人格就受不了了——一个结构体十几个字段,改一个版本的文案要在一堆属性里翻半天。
后来我把文案拆成了独立的 PersonalityCopy 结构,职场版跟着 Personality 本体走,学生版用一个静态字典按 id 映射:
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 到职场版文案也很自然,不会出现空数据的情况。
说实话这个设计不复杂,但我最初的方案是用 enum 的 associated value 来做,写了一半发现 Codable 支持起来巨麻烦,删了重来的。
## 存档模型版本迁移:一个真实的线上crash
用户可以多次测试,保存历史记录对比不同时期的结果。存档模型长这样:
```swift
struct ArchiveEntry: Identifiable, Codable {
let id: UUID
var nickname: String
let personalityCode: String
let scores: DimensionScores
let date: Date
let pack: QuizPack
}
```
这里有个真实踩坑:1.0 版本上线的时候只有职场版,`ArchiveEntry` 里根本没有 `pack` 字段。等 1.1 加了学生版,老用户的存档数据反序列化直接 crash。
解决方案是在自定义的 `init(from decoder:)` 里用 `decodeIfPresent` 做兼容:
> `pack = try c.decodeIfPresent(QuizPack.self, forKey: .pack) ?? .workplace`
旧存档没有 pack 字段就默认职场版。这个问题说起来简单,但当时 TestFlight 用户反馈闪退的时候我排查了快两个小时才定位到——因为 crash log 指向的是 `JSONDecoder` 内部,不是我的代码行。错误信息是 `keyNotFound`,但堆栈里全是系统框架的符号,我一开始以为是 iOS 版本兼容问题,绕了好大一圈。
教训就是:任何持久化的数据模型,从第一天起就应该考虑字段可选和版本迁移。我知道这是老生常谈,但不踩一次真记不住。
## 四维度评分和人格编码映射
NMTI 的人格判定基于四个维度:C(控制)、S(社交)、E(能量)、A(执行)。每道题的选项会给对应维度加分或减分,答完所有题后看每个维度的最终得分正负。
编码规则是这样的:每个维度正方向取一个字母,负方向取另一个字母,四个拼在一起就是人格编码。比如 C+S+E+A+ 对应 "PRED"(六边形悍匪),C+S+E+A- 对应 "VAMP"(优雅吸血鬼),C+S+E-A+ 对应 "WORK"(金牌老黄牛)。
这些缩写不是随便起的,每组字母对应人格标签的英文缩写——PRED 取自 Predator,VAMP 取自 Vampire,诸如此类。2^4 = 16 种组合,16 个人格,一一对应。
`DimensionScores` 就存四个 Int,结构很简单。算法本身没什么技术含量,工作量全在文案上。
## 人格关系匹配:rival 和 partner
每个人格有 `rivalCode` 和 `partnerCode`,指向它的天敌和最佳搭档。比如「六边形悍匪」(PRED) 的对手是 DOOM(人形末日钟),搭档是 WORK(金牌老黄牛)。
两个人都测完之后,系统会根据双方的人格编码给出相处建议。匹配逻辑分三层:
1. 先查是不是 rival/partner 关系——这两种有手写的专属毒舌文案,比如 rival 的文案会写"你俩在一个工位三天之内必出事故"
2. 2. 如果不是,看四个维度的同向数量:有3个以上维度同向的,走"互补型"文案模板;刚好2个同向的,走"中性相处"模板;只有0-1个同向的,走"谨慎相处"模板
3. 3. 每个模板有3-4条文案随机抽取,避免重复感
16种人格两两组合有120种,全写定制文案不现实。用这个分层规则之后,我手写的文案量从120组降到了大概40组,剩下的靠模板 + 随机组合覆盖。
## 海报生成:ImageRenderer 的主线程陷阱
结果页的分享海报是用户传播的核心载体。我的做法是纯 SwiftUI 渲染:把人格标签、slogan、用户昵称组合成一个 View,然后用 `ImageRenderer` 导出为图片。
一开始我用的是 `UIGraphicsImageRenderer` + UIHostingController 的老方案,iOS 16 之后换成了 `ImageRenderer`,代码量少了一半多。但上线后收到几个用户反馈说分享出去的海报是纯白图。
排查了一阵发现问题出在这里:我在一个 `Task {}` 异步上下文里调用 `ImageRenderer`,而 `ImageRenderer` 内部需要在主线程渲染。在非主线程调用时它不会 crash,但渲染出来的 `uiImage` 的 scale 会变成 0,导致图片尺寸为空,最终导出就是一张白图。
修复方式很简单,用 `@MainActor` 确保渲染在主线程:
```swift
@MainActor
func renderPoster(view: some View) -> UIImage? {
let renderer = ImageRenderer(content: view)
renderer.scale = UIScreen.main.scale
return renderer.uiImage
}
```
关键是要显式设置 `renderer.scale`。不设置的话,在主线程它会自动取屏幕 scale,但在非主线程它可能拿到 0 或 1,渲染结果就不对。这个坑 Apple 文档里没有明确写,Stack Overflow 上翻了好几个帖子才拼出完整的原因。
## 一些数据
App 今年初上架 App Store,目前版本 1.1。下载量大概在几百级别,还在冷启动阶段,没有投广告预算,主要靠用户测完分享海报带自然流量。
我统计了一下,测试完成后点分享的比例大概在 35% 左右,这个数字比我预期的高。分享率最高的人格不是听起来最厉害的「六边形悍匪」,而是「优雅吸血鬼」和「精神状态堪忧」这种自嘲型标签。看来大家更愿意分享的是"看我多惨多真实",而不是"看我多牛"。这个发现让我后续调了几个人格的 slogan 方向,往自嘲和共鸣感靠。
## 最后
这个项目写到现在,我 Excel 里光文案版本就存了47个 sheet,比代码 commit 还多。16种人格 × 2套题包 × 每个人格改了少说五六版,再加上 rival/partner 的相处文案……反正写文案的时间远远超过写 Swift 的时间。
如果你也在做 iOS 独立开发,或者对人格测试类产品的数据模型设计感兴趣,欢迎评论区聊聊。特别是存档数据迁移这块,我现在用的 `decodeIfPresent` 方案只能应对加字段的情况,如果以后要改字段类型或者删字段,估计还得上一套更正式的 migration 方案——如果你有实践经验,真心想听听。
459



被折叠的 条评论
为什么被折叠?



