起因:想做一个「测完就想发朋友圈」的独立项目
独立开发者选项目,传播力是我第一个看的东西。去年观察到一个规律:MBTI 类测试的核心价值不在测试本身,在结果海报能不能让人截图转发。
所以核心矛盾很明确——怎么用最小的技术成本,做出一个「结果天然适合分享」的人格测试 App?
这就是 NMTI 人格测试的起点。四个自定义维度(C/S/E/A),排列组合出 16 种人格,每种配一个毒舌称号(「六边形悍匪」「优雅吸血鬼」「赛博隐形人」之类),文案走冒犯但又觉得准的路线。
四维计分和人格判定
每道题对应一个维度,选项带正负分值。答完所有题后,根据四个维度的累计分数判定正负极,拼出人格编码:
struct DimensionScores: Codable, Equatable {
let c: Int // 正值 → C+,负值 → C-
let s: Int
let e: Int
let a: Int
}
// 根据四维得分生成人格编码,如 "PRED" / "VAMP" / "WORK"
func resolvePersonalityCode(from scores: DimensionScores) -> String {
let cChar: Character = scores.c >= 0 ? "P" : "D"
let sChar: Character = scores.s >= 0 ? "R" : "O"
let eChar: Character = scores.e >= 0 ? "E" : "O"
let aChar: Character = scores.a >= 0 ? "D" : "M"
return String([cChar, sChar, eChar, aChar])
}
说白了就是四个 if-else 拼字符串。但这个结构决定了后面所有功能的数据流——存档、海报、互测对比全都依赖 DimensionScores。
多题包架构:同一人格,不同场景文案
上线后发现大学生用户比预想的多,职场场景题对他们代入感不够。1.1 版加了「做题家版」题包。
架构上的关键设计:人格模型不变,文案按题包切换。
enum QuizPack: String, CaseIterable, Codable {
case workplace // "基础牛马包"
case student // "做题家版"
}
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)
}
}
}
这里的 Self.studentCopy 是一个硬编码的 [String: PersonalityCopy] 字典,16 个 key 对应 16 种人格。我没用 JSON 文件,原因很简单——文案改动频率高,直接写在代码里改一行 build 一下就能验证,比维护一份 JSON 再写解析逻辑省事。等后面人格类型或题包多到一定程度再抽成数据文件不迟。
同样是 WORK(金牌老黄牛),职场版写「帮同事背锅还要摆得优雅」,学生版写「分组作业永远你写最多」。人格判定逻辑完全复用,只有展示层文案不同。
存档兼容也得处理——旧版的 ArchiveEntry 没有 pack 字段,decode 时用 decodeIfPresent 兜底默认 .workplace,不然旧用户升级后打开历史记录直接 crash。独立开发没灰度发布,这种防御性代码省不了。
结果海报渲染:ImageRenderer 的几个坑
海报是这个 App 的核心输出物。用的是 iOS 16+ 的 ImageRenderer,把固定尺寸的 SwiftUI 视图渲染成 UIImage。
踩过的坑:
坑一:scale 不固定导致不同设备出图分辨率不一样。 如果不手动设 renderer.scale = 2.0,它默认跟随设备屏幕的 scale。结果就是 iPhone 15 Pro Max 出来的海报尺寸和 SE 不一样,发到社交平台显示效果参差不齐。固定 2x(750×1334)是我测下来兼顾清晰度和文件体积的甜点。
坑二:后台线程调用偶尔返回 nil。 文档里没明说,但实际跑下来 ImageRenderer 在非主线程偶现空图。我最后统一放到 @MainActor 里跑,有一帧的卡顿,但胜在稳定。
坑三:视图里不能有异步加载的内容。 如果海报视图里有 AsyncImage,渲染的那一刻图片大概率还没到,出来的是 placeholder。所有海报素材必须是 bundle 内置的。
之所以没用 UIGraphicsImageRenderer + drawHierarchy,是因为海报视图本身就是 SwiftUI 写的,再桥接到 UIKit 截图反而多绕一层。
互测功能:没有后端的离线方案
我想做「发链接给朋友,对方测完后双方看对比」的功能。试了三条路:
- CloudKit 公共数据库同步 → 要求双方登录 iCloud,pass
- 短链 + 服务端存储 → 不想养后端,pass
- 纯离线方案:把测试配置编码进 Deep Link
最终选了第三条。用自定义 URL Scheme 传参:
nmti://invite?pack=workplace&from=PRED&nickname=小王
对方点击链接打开 App,走正常测试流程,测完后本地存一条带 fromCode 的记录,在结果页直接展示「你是 WORK,对方是 PRED,你们的相处模式是……」。
没有实时同步,没有服务端。trade-off 是对方的结果不会回传给发起者。说实话体验上有折损,但对于零后端成本的独立项目,这个方案能跑通核心场景。
还没解决的事
卡在两个点上。
分享转化率不够高。 海报预渲染做了(结果出来的瞬间就 render 好),但用户点「分享」按钮的比例偏低。我在想是不是应该把海报直接铺在结果页里,让用户看到结果的同时就看到「这张图发出去长什么样」。
双向互测的回传问题。 目前只有被邀请方能看到双方对比,发起者看不到。我试过几个方案都不满意:
- 蓝牙交换(MultipeerConnectivity):需要两人同时打开 App 且物理距离近,场景太窄
- 剪贴板方案:对方测完后把结果编码写入剪贴板,发起者打开 App 自动读取。iOS 16 以后读剪贴板会弹权限提示,用户体验很差
- iCloud KeyValue Store:只能同账号同步,没法跨用户
目前倾向的方向是用 App Group + 共享 CloudKit 私有数据库做一个极简的「信箱」,邀请码做 key,对方测完后往里扔一条记录,发起者轮询拉取。代价是要处理 CloudKit 的各种边界 case(网络断、配额满、用户没登录)。如果有人做过类似的零服务端双向数据交换,想听听你们的方案。