
1. 先盘清楚一个邀请好友在剧本杀组队里到底要拆成几条链路1.1 从开黑组队习惯反推需求边界写这个系列到现在已经有25篇了从项目脚手架、UI框架选型、状态管理一路做到房间系统、角色分配。到第26篇终于轮到拉人这个功能。之前在规划功能清单时邀请好友被排进了MVP但真正动手才发现这个功能在剧本杀这个场景里跟普通社交App的加好友完全是两码事。普通交友App的邀请好友核心目标是建立好友关系但剧本杀组队App里邀请好友的终点是把人拉进一个房间并且这个人可能已经注册、可能还没注册、可能是从微信群点链接进来的、也可能是房主面对面掏手机让他扫码的。入口不一样落地的处理路径就完全不一样。另一个关键点是运行平台。这个App是基于Flutter for OpenHarmony开发的目标设备包括正在国产化替代的平板、一体机和部分开发板。在这个平台上做邀请功能不能照搬Android的IntentShareCompat、iOS的UIActivityViewController那套思路。OpenHarmony的Ability分发、Want机制、剪贴板权限模型都有自己的脾气Flutter官方插件生态也还没完全覆盖OHOS。所以这张牌怎么打得先拆链路。我把邀请好友拆成了三个必须独立对待的问题房主如何把房间信息传给目标好友应用内直邀 / 系统分享 / 邀请码口令被邀请人拿到信息后如何找回对应的房间并完成加入链接解析、码校验、房间绑定加入成功后双方的状态如何同步房主端成员列表刷新、邀请状态变更为已接受1.2 三条候选链路与OpenHarmony的适配度对比动手之前我列了三种常规方案也针对OpenHarmony平台做了适配度评估。方案用户路径依赖的外部能力OHOS适配度说明应用内直邀房主在好友列表点邀请好友收到站内通知/请求后端推送通道、好友关系链中不依赖系统能力但需要有好友关系和实时通道分享链接/口令房主复制一段文本或调起系统分享面板发到微信/短信剪贴板、系统分享面板、DeepLink中偏高OHOS有对应能力但Flutter侧API不统一需桥接二维码扫码房主生成二维码好友用App扫描加入相机、扫码库中更适合线下面对面场景MVP可以后置考虑到项目迭代节奏我最终没有选二维码当首版主路径原因是扫码涉及相机权限和扫码组件在OHOS上的兼容验证投入产出比不高。首版重点做了应用内直邀和分享链接/口令两条链路覆盖了线上和线下两种典型拉人场景。1.3 最终落地组合应用内直邀邀请码口令系统分享最终定下来的组合是这样应用内直邀房主在房间详情页打开成员列表找到邀请好友入口进入好友列表点击某个好友的邀请按钮。这条链路适合双方都装了App、且已经是好友的场景。邀请码口令房主点击生成邀请码拿到一个六位短码如A7K3P9通过任何聊天工具发出去。好友在App首页输入邀请码即可加入。这条链路不要求对方在好友列表里甚至不要求对方已经登录。系统分享房主点击分享系统弹出分享面板把一条带邀请码的文案发到目标App。这条链路的本质是邀请码口令的载体升级——用系统分享免去手动复制粘贴。第一条链路的核心是好友关系状态机第二、三条链路的核心是邀请码的生成与房间预绑定而三条链路都要经过的公共底座是Flutter与OpenHarmony原生层的桥接通道。所以这篇文章的实战主线很清晰先搞定桥接再分别实现直邀和口令最后把被邀请人的落地体验串起来。2. 邀请码生成与房间预绑定这个离线方案比你想的可靠2.1 六位短码的字符表设计去掉易混淆字符是第一步邀请码口令是整个方案里最朴素但最不容易出错的环节。它不依赖推送、不依赖好友关系、不依赖系统分享面板只要服务器能校验码即可。设计第一件事是确定字符表。第一个教训千万不能用完整的0-9A-Za-z62字符集。用户拿到码之后经常要口述比如这个邀请码是A7K3P9如果在微信里转述0和O、1和I很容易搞混。所以我把字符表砍成了32个字符去掉0/O/1/I/L这些易混淆项class InviteCodeGenerator { // 去掉 0、O、1、I、L 等易混淆字符 static const String _chars ABCDEFGHJKMNPQRSTUVWXYZ23456789; static const int _codeLength 6; static String generate() { final rand Random.secure(); return List.generate( _codeLength, (_) _chars[rand.nextInt(_chars.length)], ).join(); } }32个字符、6位长度理论空间是32^6 1073741824超过10亿个组合。对于一个剧本杀组队App来说同一时间活跃的房间撑死几百个冲突概率可以忽略。但为了稳妥生成后还是要去后端校验一次唯一性如果撞了就直接重生成不需要做复杂的碰撞处理算法。2.2 房间预绑定与未注册用户兜底邀请码不是单纯的一串随机字符它必须能映射回某个房间。我在后端设计了一个invite_record表核心思路是邀请码与房间做预绑定生成邀请码的同时在服务端创建一条邀请记录把room_id、inviter_id、invite_code、expire_at写进去。这里有个容易踩的坑要不要让邀请码关联的加入动作直接生效我的建议是不要。邀请码只负责锁定房间不负责替用户做决定。被邀请人拿到码之后进入确认页面App把邀请码提交到服务端服务端返回该房间的基础信息剧本名、房主昵称、当前人数、开始时间由被邀请人确认后才真正写入成员表。这个设计在后面做未注册用户兜底时特别有用。未注册用户怎么办两个选择一是强制要求登录后才能输入邀请码二是允许游客身份进入确认页。实际体验下来强制登录会砍掉大量潜在组队转化。所以我在确认页做了游客态支持——未登录用户可以先用邀请码预览房间信息点击加入房间时再走登录/注册流程登录成功后自动携带邀请码完成加入。2.3 过期、撤销、并发冲突的处理邀请码不是永久有效的我给它的生命周期定了一个24小时有效期。过期策略很简单查询时判断expire_at now即可但定时的清理任务也得有否则invite_record表会无限膨胀。我在后端用了每日凌晨的定时任务批量删除过期超过7天的记录。撤销场景同样要处理。房主在房间详情页可以作废邀请码作废的本质是把invite_record.status从pending改成cancelled。这里有个细节如果你的邀请码生成逻辑是每次点击都重新生成那必须把旧码一起作废而不是只创建一个新记录否则会出现两个有效码指向同一房间的混乱状态。并发冲突是我在做压力测试时才暴露出来的问题两个人同时拿同一个邀请码提交加入请求服务端需要保证只有一个成功。我在invite_record表上加了一个invitee_id字段加入动作使用条件更新来实现原子操作UPDATE invite_record SET invitee_id ?, status accepted WHERE invite_code ? AND status pending AND invitee_id IS NULL如果更新影响行数为0说明已经被别人抢占了。这条SQL是在实战中反复打磨过的版本最初用先查再改的方式写了三个月逻辑并发一上来就穿帮。3. 应用内直邀背后的状态机与数据库设计3.1 invite_record 表结构设计应用内直邀跟邀请码口令不同它必须有明确的好友关系作为前置条件。我在用户系统里已经有了一张friend_relation表所以应用内直邀的落点依然是invite_record只是多带了一个invitee_id。完整的表结构如下这是我从项目数据库里直接摘出来的CREATE TABLE invite_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_id TEXT NOT NULL, inviter_id TEXT NOT NULL, invitee_id TEXT, invite_code TEXT NOT NULL UNIQUE, status TEXT DEFAULT pending, expire_at INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX idx_invite_code ON invite_record(invite_code); CREATE INDEX idx_invitee ON invite_record(invitee_id, status);invitee_id在邀请码口令场景下是空的在应用内直邀场景下是目标好友的用户ID。status字段是用字符串而不是枚举因为OpenHarmony侧用的数据库底层如果是关系型数据库字符串的可读性和可扩展性都更好。3.2 状态流转与异常分支邀请状态机看起来简单实际跑起来会出现很多边界分支。我在代码里把状态流转定义得很明确当前状态触发动作变更后状态触发条件pending被邀请人确认加入accepted服务端原子更新成功pending超过24小时expired定时任务或查询时懒更新pending房主作废邀请cancelled房主点击作废按钮pending房主解散房间cancelled房间解散时级联取消accepted房主移除成员无记录保留仅变更房间成员表这里最容易忽略的是解散房间时级联取消邀请这个分支。最初我设计时没有这个逻辑结果房主解散房间后好友手里还握着一个有效的邀请码点进去显示房间不存在体验极差。后来在房间解散的服务端逻辑里加了级联更新把该房间所有pending状态的邀请记录置为cancelled。3.3 不做IM的前提下如何让房主感知好友已加入应用内直邀的全流程里最难的不是数据库设计而是通知。房主点完邀请按钮之后好友那边怎么知道自己被邀请了首版我没做实时推送因为OpenHarmony上的厂商推送通道和Flutter侧的适配还不成熟。我用的方案是双通道轮询房主端邀请成功后房主进入等待受邀者确认状态前端每15秒拉一次邀请记录状态。被邀请方首页的消息Tab里有一个邀请列表进入App时、从后台回前台时、下拉刷新时会拉取最新的邀请记录。这个方案的体验肯定不如推送实时但对MVP来说完全够用。我后来在邀请列表上加了一个Stomp/WebSocket的长连接做增量通知但也没有完全移除轮询——因为它是最可靠的兜底。这套轮询为主、长连接增强的思路在OpenHarmony上还有一个额外的好处避免了后台进程常驻带来的功耗审核问题。设备端Flutter页面在后台被系统回收后长连接会断但轮询由服务器端逻辑触发前端重新回前台时拉一次就能对齐状态。4. Flutter与OpenHarmony原生桥接剪贴板、分享与Ability路由4.1 MethodChannel 在OHOS Flutter上的注册姿势如果只做应用内直邀其实不需要碰原生层。但要做剪贴板复制、系统分享、链接拉起App就绕不开Flutter与OpenHarmony原生的桥接。Flutter for OpenHarmony的桥接方式与标准Flutter一致还是MethodChannel。Dart侧的定义没有任何特殊之处class InviteBridge { static const MethodChannel _channel MethodChannel(com.script.club/invite); static FutureString copyInviteText(String text) async { return await _channel.invokeMethod(copyText, {text: text}); } static Futurevoid shareInviteText(String text) async { await _channel.invokeMethod(shareText, {text: text}); } }真正有区别的是原生侧。OpenHarmony的Flutter插件不是写在MainActivity.kt里而是在工程的ohos目录下用ArkTS实现。以UIAbility为例需要在onWindowStageCreate里拿到FlutterAbility的实例然后通过registrar注册MethodChannel的处理器。初次接入时最容易踩的坑是直接照搬Android的PluginRegistry.Registrar写法结果在OHOS上报编译错误。OHOS的Flutter插件入口是FlutterPlugin与PluginRegistrant注册代码长这样export class InvitePlugin implements FlutterPlugin { private channel: MethodChannel | null null; onAttach(engine: FlutterEngine): void { this.channel new MethodChannel( engine.getBinaryMessenger(), com.script.club/invite, StandardMessageCodec.INSTANCE ); this.channel.setMethodCallHandler((call: MethodCall) { if (call.method copyText) { // 剪贴板写入逻辑 } else if (call.method shareText) { // 分享面板逻辑 } }); } onDetach(): void { this.channel?.setMethodCallHandler(null); this.channel null; } }4.2 剪贴板写入ArkTS侧实现与权限说明剪贴板在OpenHarmony上由ohos.pasteboard提供。写入本身不需要权限但读取操作从API 11开始会触发系统弹窗。我们的邀请场景只需要写入所以权限负担不大。ArkTS侧实现import { pasteboard } from kit.BasicServicesKit; function copyToClipboard(text: string): void { const data pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text); const systemPasteboard pasteboard.getSystemPasteboard(); systemPasteboard.setData(data).then(() { // 复制成功 }).catch((err: Error) { // 复制失败 }); }这里有个需要在真机上确认的坑某些OpenHarmony版本的剪贴板setData在高频调用时会偶发失败失败原因是系统剪贴板服务被占用。我实际遇到过一次前一次复制操作还没完成就发起了第二次直接返回error: 17100003。解决方案是加一个简单的互斥队列同一时间只允许一个setData在途。4.3 拉起系统分享面板与自定义URIScheme系统分享在OpenHarmony上的实现路径是ohos.systemShare可以分享文本、链接和文件。Flutter侧调用shareText后ArkTS侧调用import { systemShare } from kit.ShareKit; function shareText(text: string): void { const shareData: systemShare.SharedData { title: 邀请你加入剧本杀房间, text: text, }; systemShare.ShareData(shareData).then(() { // 分享面板弹出成功 }); }我最初做的时候这块踩了个不大不小的坑在部分OpenHarmony版本上systemShare只能在UIAbility的窗口可见时调用如果从后台直接拉起会静默失败。所以我在Dart侧做了一层防护——调用分享前先检查App生命周期状态如果不是resumed状态就等一下再调。分享出去的文案我统一用这个格式来承载邀请信息【剧本杀组队邀请】 本子《雾中的庄园》 房主老白 时间今晚 20:00 人数4/6 邀请码A7K3P9 复制链接打开Apphttps://script.club/invite?codeA7K3P9纯文本格式的好处是任何聊天软件都能发不依赖富媒体解析。链接拉起的配置在module.json5里做通过skills注册自定义URIScheme{ module: { abilities: [ { name: EntryAbility, skills: [ { actions: [ohos.want.action.viewData], uris: [ { scheme: scriptclub, host: invite } ] } ] } ] } }这样点击scriptclub://invite?codeA7K3P9链接时系统会拉起App并把参数放进Want。4.4 onNewWant被链接拉起时Flutter侧拿不到参数的根源链接能拉起App了但参数怎么传进Flutter页面这是整个桥接环节最绕的地方。App在后台时链接拉起走的是复用Ability的路径参数通过onNewWant回调而不是onCreate进来。很多人在这一步直接把参数抛给了原生页面Flutter侧完全无感知。我采用的方案是在UIAbility里维护一个待处理Want队列onCreate和onNewWant都会把Want压入队列然后通过事件通道或MethodChannel主动向Flutter侧推送。private pendingWant: Want | null null; onNewWant(want: Want): void { this.pendingWant want; this.dispatchWantToFlutter(want); } onCreate(want: Want): void { this.pendingWant want; } private dispatchWantToFlutter(want: Want): void { const code want.parameters?.code as string; if (code) { this.channel?.invokeMethod(onInviteLinkReceived, { code: code }); } }Flutter侧需要在initState里注册这个事件监听同时处理冷启动场景下invokeMethod时机太早、channel还没准备好的问题。我的做法是原生侧先缓存参数Flutter侧通过getInitialInviteCode()主动拉取一次加上事件推送双保险。5. 被邀请人的完整落地体验从冷启动到加入房间5.1 冷启动与热启动参数分发策略被邀请人的体验链路是点击链接 → App启动 → 解析参数 → 进入邀请确认页。但冷启动和热启动的参数分发路径完全不同很容易在其中一个分支上漏掉。冷启动时EntryAbility的onCreate会拿到完整Want但此时Flutter引擎可能还没起来MethodChannel还不存在。解决方法是把参数先存到Ability的成员变量里等Flutter侧主动来取// Flutter侧 final String? initialCode await InviteBridge.getInitialInviteCode();热启动时App已经在运行链接拉起走onNewWant需要主动推送给Flutter。这两个通道必须同时保留缺一个就会出现有时候能打开确认页、有时候只是打开了App的诡异问题。我在这块还做了一个细节处理无论是冷启动还是热启动只要拿到了邀请码都先做一个本地去重校验——如果当前栈顶已经是同一个邀请码的确认页就不重复push新页面避免用户多点几次分享出现页面堆叠。5.2 邀请确认页与二次确认交互邀请确认页是整个功能的门面我把它做成了从房间详情页抽取出来的独立页面包含四个区块剧本海报与基本信息、房主信息、房间状态、加入按钮。页面数据来源不是本地缓存而是用邀请码实时请求服务端拉取。这里有一个体验上的细节网络慢的情况下确认页会出现一段白屏。我加了一个骨架屏先把从链接参数里能拿到的信息比如邀请码、时间渲染出来等服务器返回后再填充完整信息。加入按钮的交互也做了防呆处理被邀请人点击加入房间时如果房间人数已满按钮变为置灰并显示房间已满如果剧本已经开场显示已发车。这些状态判断都在服务端完成App端只是展示。5.3 加入成功后房主端与成员列表的联动刷新被邀请人加入成功后房主端的成员列表必须同步刷新否则房主会一直以为自己还在等一个人。我是这么打通这个同步的被邀请人的加入请求成功之后服务端推送一个RoomMemberChanged事件到房间的消息通道。房主端收到事件后做两件事刷新成员列表接口拿到最新的memberList如果房主正处于成员列表页面更新列表数据并弹一个轻提示XX已加入房间如果房主在后台或者没有建立长连接兜底方案还是前面提到的轮询。确认页加入成功后被邀请人直接跳转到房间详情页房主端最迟15秒内通过轮询也能感知到变化。这里要特别注意加入数据写库和推送事件必须在一个事务里。最初我是先写库、后发事件结果推送通道偶发失败出现成员列表里有人、房主端却没刷新的脏状态。把推送改为事务内联动后这个问题就消失了。6. 真机调试踩坑记录RK3568上剪贴板失灵与Want参数丢失6.1 剪贴板调用返回空的完整排查链路我在RK3568开发板上调试时遇到一个很头疼的问题点击复制邀请码后提示复制成功但粘贴出来是空的。这个问题在模拟器上完全复现不出来排查链路值得记录。第一步我在ArkTS原生侧加日志确认setData执行成功返回结果无异常。说明写入路径没问题。第二步在另一个App里测试粘贴发现仍然为空。我开始怀疑是剪贴板服务异常用hdc shell执行系统剪贴板相关命令查看状态同时检查了开发板的系统日志锁定到一条剪贴板服务进程被异常挂起的log。第三步比对模拟器和真机的差异模拟器上的pasteboard服务是完整的而RK3568某个系统镜像里剪贴板服务存在偶发crash。应用层setData返回了成功但系统服务在持久化前就挂了。最终解决方案是在Dart侧复制成功后主动回读一次做校验final copied await InviteBridge.copyInviteText(text); final clipboardValue await Clipboard.getData(Clipboard.kTextPlain); if (clipboardValue?.text ! text) { // 提示用户再次点击或者走系统分享面板兜底 }回读校验在大多数手机上都能过但能100%捕获这个系统级偶发问题。这个经验后来被我用在了其他地方——所有写后不回读的操作都要留个心眼。6.2 冷启动时Want参数丢失的系统调用时序第二个坑出现在链接拉起App的冷启动场景。现象是App完全退出后点击scriptclub://invite?codeA7K3P9链接App正常启动但Flutter侧怎么都拿不到code参数。排查过程也是一层层剥的先在UIAbility的onCreate里打印Want确认原生层能拿到code然后在onWindowStageCreate里打印也能拿到但Flutter侧的getInitialInviteCode返回空。问题最终定位在时序上onCreate执行时我虽然把Want缓存到了pendingWant但在onWindowStageCreate里注册MethodChannel时误用了registerFlutterPlugin之前的某个时机去调用invokeMethod此时Flutter引擎还没跑起来消息发出去就丢了。改成Flutter侧主动拉取后这个时序问题就规避掉了// Flutter侧 final String? initialCode await InviteBridge.getInitialInviteCode(); // ArkTS侧 private getPendingInviteCode(): string | null { const code this.pendingWant?.parameters?.code as string; this.pendingWant null; // 取完即清避免重复消费 return code; }6.3 生命周期叠加MethodChannel导致的内存泄漏最后一个坑跟生命周期有关。ArkTS侧注册的MethodChannel如果在onDetach里没有正确地setMethodCallHandler(null)Activity被销毁后插件实例会被泄漏。这不是一个立刻爆发的bug但会在反复进出房间后让内存持续上涨最终导致整机卡顿。我在排查时用hdc shell查了进程内存发现一个InvitePlugin的强引用一直挂在某个已销毁的Ability上。修复方法就是对称的注册与注销onAttach里注册onDetach里注销。另外我给Flutter侧的InviteBridge也做了生命周期管理——不能在页面dispose之后再调用invokeMethod否则会抛MissingPluginException。我在桥接类里加了一个_disposed标记调用前统一判断避免崩溃。这三个坑分享出来是想说明一点Flutter for OpenHarmony的组合框架层面已经能跑通业务但涉及系统能力的地方一定要在真机上多轮验证。模拟器能过的用例在RK3568、RK3588这类设备上往往会冒出各种各样系统服务层面的问题。调试时养成原生侧加日志、Dart侧加校验、关键操作写后回读的习惯能少走很多弯路。最后再分享一个我做邀请功能时的心得首版不要追求把所有入口一次做完先把邀请码口令跑通再把应用内直邀接上最后才是系统分享和链接拉起。这样每一层都有足够的时间在真机上打磨整个功能交付质量会稳得多。