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