
做社区类App的时候最让人头疼的不是复杂动画也不是状态管理而是内容安全。我们当时上线了一个带评论和私信功能的模块走完审核流程后收到一堆“需加强监测”的反馈。后端同事紧急写了一套过滤服务功能没问题但用户体感很差——发出去的消息要转圈好一会儿才提示“发送成功”遇到大促流量高峰还会排队。后来我们开始调研端侧敏感词过滤方案最后选中了 Flutter 生态里一个叫 bad_words 的三方库并且在鸿蒙设备上完成了适配。这篇文章就把整个过程的选型逻辑、踩坑经历和落地细节写出来给同样在做 Flutter 跨端内容合规的团队一个参考。1. 为什么我要把敏感词过滤搬回端侧而不是全交给云端先交代一下背景。我们的App是社交属性偏重的产品用户量集中在国内但设备形态很杂低端安卓机、旧iOS、还有陆续进来的鸿蒙设备都有。最初的内容安全策略很简单——所有文本过一遍后端接口接口内部用规则引擎加人工抽检。这套方案的优点是集中管控、迭代快缺点也特别明显下面三个问题基本是社区类产品的通病。1.1 云端过滤的真实痛点延迟、成本、隐私首先是延迟。正常网络下一次文本检测接口的耗时在80到150毫秒之间但用户发消息时前端还要先调审核再执行发送逻辑整体链路拉长到300毫秒以上。这在IM场景里体感非常差对方已经看到“对方正在输入”结果这条消息迟迟不出现。其次是成本。敏感词过滤接口是纯计算型的但量大起来之后很烧带宽和CPU。我们上线头一个月光这个接口的QPS就占到了全站接口的18%。每次大促活动前架构组都要给这个服务单独扩容不然会连带拖慢其他业务接口。最后是隐私合规的压力。用户输入的文本内容在传到服务端之前其实是明文存储了一圈的。虽然我们有脱敏和日志清理机制但从用户视角看一句玩笑话还没发出去就先被服务器“读”了一遍隐私上不太好看。端侧过滤恰好可以把这一步前置让大部分文本根本不出设备。1.2 端侧过滤的优势与适用边界端侧过滤的核心理念是把明显的、高频的、无需语义判定的违禁词直接在设备本地拦截掉。它的适用边界其实非常清晰适合规则明确、语境单一的文本比如昵称、签名、弹幕、聊天短消息不适合语义复杂的段落比如长文评论里的话术变体、谐音隐喻这些必须交给云端的AI模型和人工审核去兜底。我们当时定的策略是双层过滤。端侧跑一个轻量级的词库匹配命中后直接拦截或者打标端侧没拦截住的再异步发给服务端做深度判定。这样一来原本全部打到云端的流量能砍掉七八成用户发消息的延迟也能降到接近零。后续在鸿蒙设备上做适配也是在这个双层框架下面进行的。1.3 端侧和云端协同的双层架构之所以强调“双层”而不是“端侧替代云端”是因为端侧方案有天然的缺陷——词库文件体积不能太大否则安装包和内存受不了规则更新依赖客户端发版或者热更存在时间差复杂的互联网黑话和变体也防不住。所以端侧只能解决80%的简单问题剩下的还是得交给云端做最终判定。我们架构上留了一个开关端侧检测结果不是硬拦截而是带置信度标签上报服务端根据标签决定是直接忽略、人工复审还是拉黑。这种设计也给后续鸿蒙适配留了很大的灰度空间。2. bad_words 这个库的真实底细能力边界和埋下的两个坑先说说这个库本身。bad_words 是 pub.dev 上一个很轻量的 Dart 库核心文件就两个一个存默认敏感词列表一个提供匹配算法。官方文档写得很简单加载一个 BadWords 对象然后调用hasBadWords或clean方法就能过滤文本。它支持自定义词库、自定义忽略词、还带一点模糊匹配能力乍一看确实很省事。但是用起来就会发现这个库的设计目标明显是英文环境和拉丁语系文本。鸿蒙适配的第一步不是写桥接代码而是把这个库的真实能力边界摸清楚不然后面会反复被打脸。2.1 API 与实现原理一场正则和 Levenshtein 的配合bad_words 的内部逻辑大致是两层。第一层是正则匹配它会把你传入的全部文本和内置词库做一次正则比对命中就认为有敏感词。第二层是 Levenshtein 距离计算用来捕获“变形词”比如字母之间插数字、单词里多打一个字符这类情况。// bad_words 的基本用法 final badWords BadWords(); badWords.addWords([fxck, damn]); bool blocked badWords.hasBadWords(fxck your attitude); String cleaned badWords.clean(damn it, fxck this);实际体验下来它的正则部分响应很快几千字的长文本也就是几毫秒的量级但 Levenshtein 部分一旦词库变大耗时就会肉眼可见地上升。原因很简单Levenshtein 是 O(mn) 的动态规划库内部在遍历所有候选词时相当于每个词都要做一次矩阵运算词条一多CPU压力就上来了。2.2 坑一Unicode 和中英文混排直接失灵鸿蒙目标市场的中文用户文本里最常见的是中英文混排、方言谐音、Emoji字符再加上手机输入法的联想误触。bad_words 对这些问题基本是束手无策的。原因是它的默认词库全是一水儿的英文字母词条匹配逻辑按空格和标点分词中文没有空格一句话会被当成一个超长单词直接跳过正则那一步。即使我们手动加了一些中文词条进去遇到“草”“靠”这类高频单字词它也无法区分到底是正常语气词还是辱骂词。这其实是所有端侧敏感词过滤项目的第一个认知门槛敏感词过滤的本质不是“查字典”而是“懂语境”。bad_words 只负责查字典语境理解这块必须自己在外部补。2.3 坑二Levenshtein 容错带来的误报率飙升Levenshtein 算法的初衷是好的它能识别 fuxk、fuuck、fuxxxk 这类变体。但它的容错阈值一旦设置得宽误报率会高得吓人。我们接入初期直接沿用了库默认的容错参数结果线上出现大量误杀。举个例子有人聊美剧《Good Omens》因为单词里包含了某个三个字母的敏感词子串且编辑距离小于阈值整句话被端侧拦截了。用户气得直接在工单里骂人。后来我们把 Levenshtein 的匹配阈值改了逻辑只对命中白名单里的明显变体才启用。这个坑在鸿蒙适配时又踩了一次因为鸿蒙那边编译出来的 Dart 版本行为差异不大但平台通道的耗时模型变了误杀后的人工申诉链路在鸿蒙上还没有完善导致测试同事被用户投诉搞得很被动。2.4 源码级排查它到底能不能搬到鸿蒙bad_words 本身是纯 Dart 实现没有任何原生代码理论上只要 Flutter 引擎能在鸿蒙上跑它就能跑。但我还是把源码翻了一遍大概几十行核心逻辑确认它没有依赖dart:io的文件系统能力也没有走 PlatformView。这给鸿蒙适配带来一个非常重要的判断库本身不用改问题出在词库和我们业务侧的边缘逻辑。我建议任何团队在做鸿蒙化适配前都先做一次源码依赖扫描不需要用什么高大上的工具直接看重型 API 调用就行。如果插件里有dart:ffi、dart:io或者隐式访问设备路径的逻辑那鸿蒙化的工作量就不是“适配”而是“重写”了。3. 鸿蒙化适配前的排查先确认哪些环节会挂再去改代码鸿蒙上的 Flutter 运行环境和安卓、iOS 不太一样它走的是 OpenHarmony 体系下的 Flutter 社区分支插件生态也还没有完全对齐。所以拿到适配任务后我最先做的不是写MethodChannel而是把工程能跑通这件事先确认了。3.1 构建环境与依赖扫描鸿蒙 Flutter 开发要求本地有一个能够导出鸿蒙工程的 Flutter 工具链通常是在官方 Flutter SDK 基础上切换目标平台为 ohos。第一次跑flutter create生成工程时会额外生成一个 harmony 目录后续鸿蒙侧的代码就放在这里。我先在这个工程里跑一个最简单的 Demo确认基本页面和路由能正常跳转再动手接插件。然后我用flutter pub deps看了一遍整个依赖树。bad_words 没有任何传递依赖这是它作为适配对象的加分项。但工程里还包含了path_provider、shared_preferences这类需要原生侧实现的插件这些在鸿蒙分支上有的有对应实现有的没有。我的建议是适配前先给所有三方库写一张兼容性表格逐一确认鸿蒙分支支持情况。这一步看起来费时间但能避免后面在原生工程里反复试错。3.2 API 兼容性核对bad_words 和它的依赖对 API 的调用方式比较朴素主要涉及字符串遍历和正则。唯一值得关注的是它内部使用的一些String扩展方法在鸿蒙分支的 Dart SDK 版本上必须一致。我们当时跑了一把flutter test几个核心用例全部通过说明语言层面的兼容性没有问题。真正要小心的反而是我们自己的业务代码。如果你们在调用 bad_words 前做了path_provider读取词库文件的操作那这一步在鸿蒙上就需要走平台通道因为dart:io在鸿蒙分支上虽然能跑但文件目录体系和安卓完全不一样直接套路径大概率拿不到文件。3.3 适配路径选型直接桥接还是整体替换摸完底之后我列了三条可行的适配路径用一张表说清楚了适用场景和改动量。适配路径适用场景改动量性能表现纯 Dart 逻辑平移词库小、匹配规则简单、无原生依赖小基本只改工程配置中规中矩受 Dart 引擎性能影响MethodChannel 桥接 ArkTS需要系统级文本服务、动态词库、多场景管理中需要设计通信协议不走 Dart 正则性能可控原生组件 FFI 深度定制超大词库、超高频调用、需要极致性能大基本属于重写最优但维护成本极高bad_words 本身属于纯 Dart但我们的词库建设已经超出了它的原生能力范围还涉及动态热更和鸿蒙系统级文本检测的对接所以最终选了第二条路线——Dart 层保留 bad_words 的 API 形状内部实现改为通过平台通道调用鸿蒙侧的原生检测能力。这样对外接口不变业务调用方不用改代码。4. 桥接改造的核心实操MethodChannel 协议设计与鸿蒙侧实现适配方案定下来之后核心工作就是搭桥。这一步涉及 Dart 侧和鸿蒙侧两端的代码以及一套稳定的通信协议。我建议先把协议表写好再动手写代码不然两边团队并行开发时字段名对不上很痛苦。4.1 通道协议设计与错误码约定我定义了一个名为com.example.text_safe的方法通道核心方法就三个check用于单条文本检测fetchRules用于拉取当前词库版本updateRules用于热更词库。请求和响应全部走 JSON字段名统一用驼峰。// 请求体 { text: 这是一条需要检测的文本, scene: comment, requestId: abc123 } // 响应体 { requestId: abc123, blocked: true, riskLevel: 1, matchedWords: [***], reason: hit_blacklist }Dart 侧封装好检测接口业务方只需要调用一个checkText()方法。从外部看和直接用 bad_words 没什么区别。class TextSafeFilter { static const MethodChannel _channel MethodChannel(com.example.text_safe); FutureCheckResult check(String text) async { try { final resp await _channel.invokeMethod(check, { text: text, scene: comment, }); return CheckResult.fromJson(MapString, dynamic.from(resp)); } on PlatformException catch (e) { // 通道异常时自动降级到 Dart 层实现 return _fallbackBadWords.check(text); } } }错误码这一块不能偷懒至少要约定好1000 表示通道不可用1001 表示词库未初始化1002 表示文本超限等等。我们在灰度期间发现很多线上问题其实都是错误码对不上导致日志排查无从下手。4.2 鸿蒙侧的检测实现与线程模型鸿蒙侧的核心逻辑是收到check调用后从词库对象中执行匹配然后返回结果。匹配算法我没有直接在鸿蒙侧重写一份复杂的正则引擎而是把词库编译成一个紧凑的字典结构运行期加载到内存匹配时走简化版的 AC 自动机。这块属于性能优化放到下一节展开。线程模型上要特别小心。鸿蒙侧接到 Flutter 的消息后默认是在 Flutter 引擎的 UI 线程上执行回调。如果你的检测逻辑超过几毫秒比如大文本或大词库就必须把任务抛到后台任务线程再通过 MainThread 把结果回传。否则首页滚动时只要触发一次检测掉帧是必然的。// 鸿蒙侧简化逻辑 const channel new MethodChannel(com.example.text_safe); channel.attachToAbility(abilityContext, (methodCall) { if (methodCall.method check) { const text methodCall.param.text as string; // 放到后台线程执行避免卡 UI const result await sensorEngine.check(text, methodCall.param.scene as string); return result; } });4.3 词库同步机制版本号、热更与降级鸿蒙端和安卓端最大的不同在于包管理机制鸿蒙应用上架后热更的路径和安卓不太一样所以词库更新不能指望发版。我们做了一个fetchRules的版本比对逻辑App 启动时带上本地词库版本号请求云端云端返回增量词条。鸿蒙侧拿到增量后写入沙箱目录同时写一份version.json用于下次校验。这里有个容易踩的坑热更词库时如果用户手机存储空间不足写入一半失败会导致旧词库也被破坏。我的建议是采用双缓存机制先写临时文件校验 MD5 通过后再覆盖正式文件确保任何时刻磁盘上都有一个完整可用的词库。降级策略也很重要。鸿蒙桥接通道偶发不可用比如引擎重启、权限异常这时候必须自动切回 Dart 层的 bad_words 内置词库保证用户发言不会被意外阻断。我们线上大概有千分之三的请求会走降级路径对用户体验几乎没有影响。4.4 长文本分片与通道限制Flutter 的 MethodChannel 对单次消息大小是有隐性限制的超过一定阈值会抛出异常。社区用户偶尔会发一长段粘贴的文本几百上千字都有可能。我们把check方法设计为支持分片文本超过 500 字时按标点切割成多段逐段检测最后合并结果。这个方案牺牲了一点效率但保证了稳定性。5. 端侧词库建设敏感词库怎么建才不会把App拖垮bad_words 默认词库的覆盖范围很有限基本都是英文脏话。做中文场景的端侧过滤词库只能自己搭。这块是整套方案里最核心也最容易被低估的部分它直接决定了误报率和性能表现。5.1 词库来源与合规注意事项我们的词库来源主要有三个渠道开源敏感词库、社区举报反馈池、安全团队手工维护的规则集。这里必须提醒一句直接从网上下载别人整理的敏感词表不经审校就上线风险很大一是可能包含大量过时或者错误的词条二是某些词条在不同语境下的敏感度差异极大。最稳妥的做法是建立反馈闭环。端侧过滤器被触发时记录用户申诉和上下文快照每周对申诉数据进行复盘把误杀的白名单词和漏网的变体词同步更新到词库。这样词库不是静态文件而是一个持续进化的体系。5.2 词库存储结构从哈希表到 AC 自动机词库量级在几千词条以内时用哈希表配合子串匹配就够了。但社交产品的词库很容易做到几万条这时候逐个匹配的效率就崩了。我建议直接上 AC 自动机——把词库构建成一个 Trie 树每个节点挂上失配指针扫描文本时只走一遍文本就能找到所有命中的词条。# AC 自动机构建的简化示意 def build_trie(words): root {} for w in words: node root for ch in w: node node.setdefault(ch, {}) node[end] True # 构建失败指针 build_fail_pointers(root) def search(text): node root for ch in text: while ch not in node and node ! root: node node.fail node node.get(ch, root) if node.get(end): report_match()鸿蒙侧我不是用 Dart 实现的 AC 自动机而是直接用 ArkTS 写了一遍原因很简单词库同步逻辑和匹配引擎都在原生侧避免 Dart 和原生来回传大词库对象。匹配引擎在鸿蒙后台线程运行单条短文本检测耗时基本控制在 2 毫秒以内。5.3 误报治理白名单、分级过滤与上下文词库越全误报越高这是铁律。我们做了三件事来抑制误报。第一建立白名单词表。比如某些品牌名、作品名、地名虽然包含了敏感词子串但在正常语境下是无害的直接放行。第二做分级过滤。不是所有命中都要拦截我们设置了三个风险等级高危词直接阻断中危词打标后放行并提醒低危词仅上报云端分析。这样用户的真实交流不会被过度干涉社区氛围也能保住。第三引入简单的上下文规则。比如命中词出现在引号内、或者前后文有“开玩笑”“手动狗头”这类标记可以降低风险等级。这个上下文逻辑不需要上深度学习几条正则加短文本辅助判断就够了。6. 性能验证与线上问题复盘桥接方案落地后我们做了一轮完整的性能验证也踩了几个线上问题。这部分给同行的建议是要有一套可量化的指标不然测试团队只能凭感觉反馈“好像变慢了”。6.1 性能测试的核心指标与压测方法我建议至少关注四个指标词库冷启动加载耗时、单条文本检测的 P95 延迟、检测过程中的内存增量、以及误报率。指标目标值测试方式词库加载耗时 50msApp 冷启动到引擎就绪后首次检测单条检测 P95 10ms随机 1 万条真实用户文本内存增量 5MB高频检测 10 分钟后 Java 堆栈快照误报率 0.5%抽样人工复核敏感词命中记录压测方法就是写 1 万条文本模拟不同长度、不同命中密度的组合循环执行检测函数打点记录耗时。鸿蒙侧可以用自带的分布式日志工具打点。6.2 三个真实线上问题第一个问题是鸿蒙低端机初始化卡顿。词库加到了 5 万条冷启动时在 UI 线程做了一次全量加载直接把首帧拖到了 3 秒以上。解决方法是把加载逻辑挪到后台线程并在加载期间先返回一个小型基础词库的检测结果等大词库就绪再切换。第二个问题是热更词库时崩溃。原因就是前面提到的写入中途失败导致旧词库损坏后来全部改成 A/B 文件切换模式这个问题基本消失。第三个问题是误报逃逸。某个版本的词库新增了几百条高音变体词结果把大量正常交流的群众误杀了。复盘后发现是 Levenshtein 容错参数调得太激进后来我们把“变体识别”单独成一条规则链只有同时满足相似度和长度约束才触发。6.3 端侧 AI 审核的扩展方向infrastructure做稳定之后我们开始看端侧 AI 能力比如用轻量级模型做辱骂分类、用设备端文本向量化做语义匹配。这个方向在鸿蒙设备的 NPU 上有天然的硬件基础。目前还在验证阶段我的判断是短期内不能替代词库方案但可以作为第二道过滤器特别适合处理那些“词库永远追不上”的新变体和隐喻。最后聊几句个人体会端侧敏感词过滤这件事真正难的不是算法也不是鸿蒙适配而是“尺度”两个字。词库松一点漏网之鱼多社区环境恶化词库紧一点误杀投诉飙升用户流失。没有一劳永逸的配置只能靠持续的反馈闭环和灰度验证一点一点磨。给同行的建议就三条第一端侧只做拦截不要做审判复杂的语义判定必须留到云端第二词库一定要做成可热更的结构并配上双缓存和版本回滚第三鸿蒙适配尽早做不要等发版前一周再补课。另外一个小技巧是鸿蒙侧的通道请求响应格式最好和安卓侧的一次性统一好后面维护两套协议太折磨人。