1. 先盘清楚:一个"邀请好友"在剧本杀组队里到底要拆成几条链路
1.1 从开黑组队习惯反推需求边界
写这个系列到现在,已经有25篇了,从项目脚手架、UI框架选型、状态管理一路做到房间系统、角色分配。到第26篇,终于轮到"拉人"这个功能。之前在规划功能清单时,"邀请好友"被排进了MVP,但真正动手才发现:这个功能在剧本杀这个场景里,跟普通社交App的加好友完全是两码事。
普通交友App的邀请好友,核心目标是"建立好友关系";但剧本杀组队App里,邀请好友的终点是把人拉进一个房间,并且这个人可能已经注册、可能还没注册、可能是从微信群点链接进来的、也可能是房主面对面掏手机让他扫码的。入口不一样,落地的处理路径就完全不一样。
另一个关键点是运行平台。这个App是基于Flutter for OpenHarmony开发的,目标设备包括正在国产化替代的平板、一体机和部分开发板。在这个平台上做邀请功能,不能照搬Android的Intent+ShareCompat、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 Future<String> copyInviteText(String text) async { return await _channel.invokeMethod('copyText', {'text': text}); } static Future<void> 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 复制链接打开App:https://script.club/invite?code=A7K3P9纯文本格式的好处是任何聊天软件都能发,不依赖富媒体解析。
链接拉起的配置在module.json5里做,通过skills注册自定义URIScheme:
{ "module": { "abilities": [ { "name": "EntryAbility", "skills": [ { "actions": ["ohos.want.action.viewData"], "uris": [ { "scheme": "scriptclub", "host": "invite" } ] } ] } ] } }这样点击scriptclub://invite?code=A7K3P9链接时,系统会拉起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?code=A7K3P9链接,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侧加校验、关键操作写后回读"的习惯,能少走很多弯路。
最后再分享一个我做邀请功能时的心得:首版不要追求把所有入口一次做完,先把邀请码口令跑通,再把应用内直邀接上,最后才是系统分享和链接拉起。这样每一层都有足够的时间在真机上打磨,整个功能交付质量会稳得多。