起因
去年底做了个 iOS App 叫 NMTI——牛马人格测试。本质是 MBTI 的娱乐变体,但题目全是职场和校园场景,结果文案走毒舌路线,生成的海报标签类似"六边形悍匪""优雅吸血鬼""赛博隐形人"这种。
做之前翻了一圈 App Store 上已有的 MBTI 类 App,两个问题:要么太严肃,题目冗长,做完像参加了一场考试;要么太水,随便几道题就贴标签。我想做的是:场景化题目 + 犀利文案 + 能让人发朋友圈的结果海报。
这篇聊聊技术实现——维度怎么计分、人格 code 怎么推导、两套场景文案怎么组织、海报怎么渲染,还有一些踩过的坑。

四维度计分模型
传统 MBTI 有四个维度(E/I、S/N、T/F、J/P),我没直接照搬,根据职场场景重新定义了四个维度:C(掌控力)、S(稳定性)、E(表达欲)、A(执行力)。
数据结构:
struct DimensionScores: Codable, Equatable {
let c: Int
let s: Int
let e: Int
let a: Int
}
每道题关联一个 Dimension,选项 A 给该维度 +1,选项 B 给该维度 -1。全部答完之后,四个维度各得到一个累加值,正负号决定该维度的倾向方向。
人格 code 的推导逻辑其实就是把四个维度的正负号映射成字母再拼起来。简化版大概是这样:
func derivePersonalityCode(from scores: DimensionScores) -> String {
let cChar: Character = scores.c >= 0 ? "P" : "D" // 掌控+: Proactive / 掌控-: Deferring
let sChar: Character = scores.s >= 0 ? "R" : "O" // 稳定+: Resilient / 稳定-: Open
let eChar: Character = scores.e >= 0 ? "E" : "A" // 表达+: Expressive / 表达-: Absorbing
let aChar: Character = scores.a >= 0 ? "D" : "M" // 执行+: Driven / 执行-: Measured
return String([cChar, sChar, eChar, aChar])
}
四个维度,每个两个方向,2^4 = 16 种组合,刚好 16 种人格。比如 C+S+E+A+ 就是 PRED(六边形悍匪),C+S+E+A- 就是 VAMP(优雅吸血鬼)。
说实话一开始纠结过要不要用浮点数存分值——连续数值看起来更"精确"。后来想想,这是娱乐 App 不是心理量表,整数够用了,做雷达图渲染的时候整数处理也方便。
题目场景化设计
做了两套题包:「基础牛马包」(职场版)和「做题家版」(学生版),用 QuizPack 枚举区分。
每道题必须是具体场景,不是那种"你更喜欢独处还是社交"的抽象问题。测 E(表达欲)维度不会问"你外向吗",而是问"组会上老板提了一个你觉得有问题的方案,你会?A. 当场提出疑问 / B. 会后私下找老板聊"。
这种设计让选项没有明显的"好坏"之分,用户不会下意识选那个"看起来更优秀"的答案。写题目比写代码累多了。

一套人格、两套文案的架构
16 种人格类型是固定的,但职场版和学生版的文案完全不同。
比如 VAMP(优雅吸血鬼),职场版 slogan 是"满嘴大局观,实则不干活,还能把锅甩得极其自然",学生版是"我只负责想,具体写什么,你来吧"。再比如 WORK(金牌老黄牛),职场版是"不仅被白嫖,还要把姿态摆得极其优美",学生版是"不仅被全组安排,还要把姿态摆得极其优雅"——措辞微调,但场景完全不同。
文案按场景分发的架构:
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)
}
}
}
学生版文案存在一个 [String: PersonalityCopy] 字典里,key 是人格 code。如果某个人格还没写完学生版文案,就 fallback 到职场版——早期开发时确实有几个没写完,这个 ?? 省了我很多"文案没准备好就 crash"的麻烦。
我考虑过用 protocol 多态来做这件事,定义一个 CopyProvider protocol 让不同场景各自实现。但想了想,目前只有两套场景,用字典映射 + fallback 已经够简单明确了。如果以后加第三套(比如"恋爱版"),到时候再重构也不迟。过早抽象在独立开发里是大忌,我吃过亏。
写这 32 套文案(16 人格 × 2 场景)花了大概两周。试过用 AI 辅助生成,但出来的东西太正确、太圆滑,缺那种刺痛感。最后还是自己一条条磨出来的。比如 WORK(金牌老黄牛)的描述:"你明明可以拒绝,但你没有。组里最重的活永远流向你的桌上,你笑着接了,心里只是默默叹了口气。"这种有点扎心的调调,AI 写不出来。
人格关系图谱的设计
每个人格有两个关联字段:rivalCode(天敌)和 partnerCode(最佳搭档)。这块设计时我花了点心思想关系的规则。
几个关键决定:
天敌关系是非对称的。 A 的天敌是 B,不代表 B 的天敌是 A。现实中也是这样——你最受不了的那个人,可能根本没把你放在眼里。比如 PRED(六边形悍匪)的天敌是 DOOM,但 DOOM 的天敌不是 PRED。这种非对称让结果更有戏剧性,用户看到"你的天敌是 XX,但 XX 的天敌其实是 YY"会觉得有意思。
搭档关系也是非对称的。 WORK(老黄牛)的最佳搭档是 PRED(悍匪),因为老黄牛需要一个能帮自己挡事的人。但 PRED 的搭档是 WORK——这个恰好是双向的,因为悍匪确实需要一个靠谱的执行者。不过这种双向是"恰好",不是规则强制的。
我特意避免了天敌的自环。 就是不会出现 A 的天敌的天敌是 A 自己。如果 PRED 天敌是 DOOM,DOOM 天敌是 PRED,用户看到互为天敌会觉得"这不就是随便配的嘛"。非对称 + 避免自环,让关系图谱看起来更像一个有方向的网络,而不是简单的配对。
数据直接硬编码在 Personality.all 数组里。16 个人格的数据量太小了,硬编码反而是最可靠的。改起来也快,不用维护额外的 JSON 文件或数据库。
海报生成:用 ImageRenderer 踩的坑
结果海报是这个 App 最重要的传播载体。每个人格有一张预制插画(imageName),加上人格标题和 tagline 文案,渲染成一张可分享的图片。
核心用的是 iOS 16 引入的 SwiftUI ImageRenderer。大致逻辑:
let posterView = PosterView(
personality: personality,
copy: personality.copy(for: pack),
scores: scores
)
let renderer = ImageRenderer(content: posterView)
renderer.scale = UIScreen.main.scale
if let uiImage = renderer.uiImage {
UIImageWriteToSavedPhotosAlbum(uiImage, nil, nil, nil)
}
PosterView 就是一个普通的 SwiftUI View,里面有插画、人格标题、slogan、维度雷达图。把一个 View 直接扔给 ImageRenderer,它帮你渲染成 UIImage,这个 API 真的省事。
但有个坑:最开始我用的是 UIGraphicsImageRenderer,在部分机型上文字排版会错位——具体来说是 Dynamic Type 开了大字体的情况下,文字会溢出预设区域。换成 ImageRenderer 之后就没问题了,因为它直接复用 SwiftUI 的布局引擎。代价是最低支持版本得拉到 iOS 16,不过 2024 年了,这个取舍可以接受。
另一个小问题:ImageRenderer 在后台线程调用时偶尔会出空图。我最后把渲染逻辑全部 @MainActor 标注了,稳定了。

旧存档兼容
一个很小但很实际的问题。1.0 版本只有职场版,ArchiveEntry 里没有 pack 字段。1.1 加了学生版之后,老用户的存档反序列化会直接挂。
处理方式是自定义 Decoder,用 decodeIfPresent 做 fallback:
pack = try c.decodeIfPresent(QuizPack.self, forKey: .pack) ?? .workplace
一行代码的事,但如果没想到,线上用户一更新就闪退。独立开发没有 QA 帮你测老数据迁移,全靠自己记住"之前的数据长什么样"。我现在的习惯是每次加字段都同时写一个单元测试,用旧版 JSON 跑一遍反序列化,确认不崩。
一些数据和反思
说实话,数据不好看。上架一个多月,下载量基本可以忽略。
分析了几个原因:
- App Store 搜"MBTI"竞争太激烈,我的 App 名字是"NMTI",搜索匹配天然吃亏
- 没做任何付费推广
- 社交分享的路径断了——用户生成海报分享到微信,但被分享的人没有便捷方式跳回 App
下一步如果继续做,大概会加 App Clip 支持,让被分享的人不用下载就能直接测。还有把"测朋友"的链接做成 Universal Link,体验会好很多。
这个项目对我来说更像是练手——练 SwiftUI 组件化、练 Codable 的版本迁移、练怎么用最少的架构支撑一个"看着简单实际不简单"的产品。
最后
回头看,这个项目技术上没有任何一个单点是"难"的,但把这些东西组合在一起让体验顺畅,比想象中麻烦。尤其是文案——技术问题搜一下就有答案,但"怎么用一句话让人觉得被精准扎到了"这事儿搜不到。
有个问题想听听大家的看法:做多场景文案分发的时候,你们会倾向于字典映射(像我这样 [String: Copy] + fallback),还是用 protocol 多态让每个场景自己实现?我目前场景少所以选了前者,但总觉得加到第三套第四套时会不太舒服。