Flutter for OpenHarmony 实战:三方库 bonsoir 的鸿蒙化适配指南
欢迎加入 CPF-Flutter 鸿蒙社区:https://atomgit.com/CPF-Flutter
前两篇挑的都是"一个类、几个方法"的小库,这一篇换个量级:bonsoir 是 Zeroconf(也就是 Bonjour / mDNS)的 Flutter 实现,做三件事——广播自己的服务、发现局域网里的服务、把服务名解析成地址。功能面比前两个宽,通道结构也复杂一档:每个广播/发现实例都有一条自己的 EventChannel,原生侧得按实例 id 分别管状态。
它还有个和前两篇不一样的地方:上游是 monorepo。仓库根目录下 packages/ 里躺着 6 个包,pubspec.yaml 用的是 pub workspace。鸿蒙实现该放哪儿、查重该怎么查、下游怎么引,都跟着变了。
适配后的仓库:https://atomgit.com/oh-flutter/bonsoir

环境准备:本文只讲适配本身,不重复环境搭建步骤。Flutter for OpenHarmony SDK、DevEco Studio、模拟器/真机的完整配置见官方指引:
https://atomgit.com/CPF-Flutter/flutter_samples/blob/master/docs/ohos/getting-started/flutter-oh-env-setup.md
一、先看清上游给了什么契约
bonsoir 的 Dart 层做了一层封装:BonsoirBroadcast / BonsoirDiscovery 都继承自 MethodChannelBonsoirAction,方法名和通道名是拼出来的。packages/bonsoir_platform_interface/lib/src/actions/action.dart:
static const String _channelName = 'fr.skyost.bonsoir';
static const MethodChannel _channel = MethodChannel(_channelName);
Future<void> initialize() async {
if (eventStream == null) {
await _channel.invokeMethod('$_classType.initialize', toJson());
_eventStream = EventChannel('$_channelName.$_classType.$_id')
.receiveBroadcastStream()
.map(transformPlatformEvent);
}
}
Future<void> start() => _channel.invokeMethod('$_classType.start', toJson());
两个关键点藏在这几行里:
其一,_id 是每个实例随机生成的。
static int _createRandomId() => Random().nextInt(100000);
Dart 侧每 new 一个 BonsoirBroadcast 就抽一个 0–99999 的随机 id,同时用这个 id 拼出该实例专属的 EventChannel(fr.skyost.bonsoir.broadcast.27984 这种)。原生侧不能只建一条通道了事,得维护"id → 实例状态 → 该实例的 EventSink"三层映射。这是本次适配里结构上最花心思的地方。
其二,同一个库里存在两种参数前缀。
initialize / start / stop 走的是 toJson(),广播场景下会把 BonsoirService.toJson() 展开进来,键带 service. 前缀:
Map<String, dynamic> toJson() => {
...super.toJson(), // id、printLogs
...service.toJson(), // service.name、service.type、service.port…
};
而 resolveService 走的是另一个重载:
Future<void> resolveService(BonsoirService service) =>
invokeMethod('resolveService', service.toJson(prefix: ''));
prefix: '' —— 键不带前缀,直接是 name / type / port / attributes。同一个插件里两种写法,照着一种写完另一种必错。
整理出来的完整契约:
| Dart 侧 | 通道方法 | 参数 |
|---|---|---|
BonsoirBroadcast.initialize() | broadcast.initialize | id、printLogs + service.name / service.type / service.hostAddresses? / service.hostname? / service.port / service.attributes |
BonsoirBroadcast.start() / stop() | broadcast.start / broadcast.stop | 同上(带完整 service JSON) |
BonsoirDiscovery.initialize() | discovery.initialize | id、printLogs、type |
BonsoirDiscovery.start() / stop() | discovery.start / discovery.stop | 同上 |
serviceResolver.resolveService(s) | discovery.resolveService | id、printLogs、name、type、port、attributes —— 无 service. 前缀 |
serviceResolver.supportsMdnsHostname() | discovery.supportsMdnsHostname | 无参,返回 bool |
事件走各自的 EventChannel,事件体统一是 {id, service} 或只有 {id}:
| 方向 | 事件 id | 是否带 service |
|---|---|---|
| 广播 | broadcastStarted / broadcastNameAlreadyExists / broadcastStopped | 带 |
| 发现 | discoveryStarted / discoveryStopped / discoveryServiceResolveFailed | 不带 |
| 发现 | discoveryServiceFound / discoveryServiceResolved / discoveryServiceUpdated / discoveryServiceLost | 带 |
这里还有个容易翻车的细节。Dart 侧反序列化嵌套 service 的代码是:
factory BonsoirService.fromJson(Map<String, dynamic> json, {String prefix = 'service.'}) =>
BonsoirService.ignoreNorms(
attributes: Map<String, String>.from(json['${prefix}attributes']),
// ...
);
它拿到的是内层 service 那个 Map,但用的还是带前缀的默认参数。所以原生侧回给 Dart 的 service 字段里,键必须是 service.name、service.type、service.port、service.attributes——外层叫 service,内层键还带 service.。而且 attributes 和 port 是直接 Map.from / 取值不做判空的,必须每次都填,漏一个就是运行时异常。
二、动手前先查重
照例先查。bonsoir 的查重结果是干净的:CPF-Flutter / oh-flutter / hxa-flutter / oh-tpc 四个组织、bonsoir 与 fluttertpc_bonsoir 两种命名都不存在,AtomGit 与 Gitee 全站搜索 0 条;上游 Skyost/Bonsoir 的 491 条提交历史里也没有任何 ohos 相关改动。
但 monorepo 给查重加了一条它独有的坑,值得单独说。
ohos/ 不在仓库根目录。 社区里通行的查重办法是"看仓库根目录有没有 ohos/ 文件夹"——本仓库自己的查重脚本就是这么写的。这套判断对单包仓库成立,对 monorepo 会失效:本次适配的鸿蒙代码落在 packages/bonsoir/ohos/,根目录里永远不会有 ohos/。用根目录判断去查 oh-flutter/bonsoir,结论会是"还没适配",而实际上已经适配了。
所以文章开头那张截图特意截的是 packages/ 里的结构——它同时也是这条坑的说明。
另一条经验也在这里应验了:清单数据抓取于 2026-09-05,而适配一直在发生。bonsoir 在清单里是"需要适配",但清单本身滞后,结论只能靠当天现查,不能照着清单开工。
查重的技术细节前面两篇讲过,这里再重复一次,因为它太容易踩:不能用仓库页面的 HTTP 状态码判断仓库是否存在。AtomGit 前端是 SPA 路由,不存在的仓库地址同样返回 200,必须走 API:
curl -s "https://atomgit.com/api/v5/repos/oh-flutter/库名/contents"
# 返回 JSON 数组 → 仓库存在
# 返回 error_code → 不存在
三、鸿蒙侧的 API 选型
鸿蒙对应的能力在 @ohos.net.mdns(@kit.NetworkKit)。先把代码写在哪里说清楚,monorepo 的落点和单包仓库不一样:
Bonsoir/ # 仓库根(上游 monorepo)
├── pubspec.yaml # pub workspace 声明,不用改
├── packages/
│ ├── bonsoir/ # ★ 本次适配的主包
│ │ ├── pubspec.yaml # 加 ohos 声明(见第五节)
│ │ ├── README.OpenHarmony.md # 新增:英文适配文档
│ │ ├── README.OpenHarmony_CN.md # 新增:中文适配文档
│ │ ├── ohos/ # ★ 新增:鸿蒙工程
│ │ │ ├── index.ets # 只做导出,一行
│ │ │ ├── oh-package.json5 # 依赖 @ohos/flutter_ohos
│ │ │ ├── build-profile.json5 # 编译配置
│ │ │ └── src/main/
│ │ │ ├── module.json5 # 模块声明
│ │ │ └── ets/components/plugin/
│ │ │ └── BonsoirPlugin.ets # ★ 全部 ArkTS 代码在这里
│ │ └── example/
│ │ ├── lib/main.dart # 上游示例,未改动
│ │ ├── lib/ohos_demo.dart # 新增:OHOS 专用演示入口
│ │ └── ohos/ # 新增:示例的鸿蒙工程
│ ├── bonsoir_android/ # 其余平台包,全部未改动
│ ├── bonsoir_darwin/
│ ├── bonsoir_linux/
│ ├── bonsoir_platform_interface/
│ └── bonsoir_windows/
和单包仓库的区别就一条:ohos/ 是加在 packages/bonsoir/ 下的,不是仓库根。其余平台包一个字节都没动,Dart 层同样没动。
ohos/index.ets 内容还是那一行,类名要与 pubspec.yaml 里的 pluginClass 同名:
export { default } from './src/main/ets/components/plugin/BonsoirPlugin';
能力对照
三件事在鸿蒙上的落点:
| bonsoir 能力 | Dart 侧 | OHOS 接口 |
|---|---|---|
| 广播 | BonsoirBroadcast.start() | mdns.addLocalService(context, info) |
| 停止广播 | BonsoirBroadcast.stop() | mdns.removeLocalService(context, info) |
| 发现 | BonsoirDiscovery.start() | mdns.createDiscoveryService(context, type) + startSearchingMDNS() |
| 停止发现 | BonsoirDiscovery.stop() | stopSearchingMDNS() |
| 服务出现 / 消失 | discoveryServiceFound / discoveryServiceLost | on('serviceFound') / on('serviceLost') |
| 解析地址 | resolveService(service) | mdns.resolveLocalService(context, info) |
要点一:所有 mdns 接口的第一个参数都是 context
addLocalService、removeLocalService、createDiscoveryService、resolveLocalService —— 这四个接口签名里第一个参数都是 Context。而插件实现 FlutterPlugin 时,onAttachedToEngine 拿到的是 FlutterPluginBinding,里面没有 Ability 上下文。
所以插件必须同时实现 AbilityAware:
export default class BonsoirPlugin implements FlutterPlugin, MethodCallHandler, AbilityAware {
private context: common.UIAbilityContext | null = null;
onAttachedToAbility(binding: AbilityPluginBinding): void {
this.context = binding.getAbility().context as common.UIAbilityContext;
}
onDetachedFromAbility(): void {
this.context = null;
}
}
这一点很容易被漏掉:flutter create --platforms ohos 生成的骨架默认只实现 FlutterPlugin,编译能过、通道也能通,但一调 addLocalService 就是空 context 报错。只有需要 Ability 上下文的插件才加 AbilityAware——前两篇适配的音量和传感器都不需要,这一个需要。
要点二:启动搜索的方法名是 startSearchingMDNS()
.d.ts 里逐条翻了一遍,DiscoveryService 暴露的是:
startSearchingMDNS()
stopSearchingMDNS()
on('serviceFound' | 'serviceLost' | 'discoveryStart' | 'discoveryStop')
不是直觉上的 startSearchingService()。这个命名没有任何提示,只能靠翻接口文档确认。
要点三:TXT 记录是字节数组
BonsoirService.attributes 是 Map<String, String>,而鸿蒙 ServiceAttribute 的 value 是 Array<number>(字节数组):
interface ServiceAttribute {
key: string;
value: Array<number>;
}
两侧类型对不上,得在转换层做 UTF-8 编解码。广播时编码写入:
private static toServiceAttributes(attributes: Any): Array<mdns.ServiceAttribute> {
const result: Array<mdns.ServiceAttribute> = [];
if (attributes === null || attributes === undefined) {
return result;
}
const encoder = new util.TextEncoder();
const source = attributes as Record<string, string>;
Object.keys(source).forEach((key: string) => {
const bytes: Uint8Array = encoder.encodeInto(source[key]);
const value: Array<number> = [];
for (let i = 0; i < bytes.length; i++) {
value.push(bytes[i]);
}
result.push({ key: key, value: value });
});
return result;
}
发现时解码读回:
private static fromServiceAttributes(attributes: Array<mdns.ServiceAttribute> | undefined): Map<string, Object> {
const map = new Map<string, Object>();
if (attributes === undefined) {
return map;
}
const decoder = util.TextDecoder.create('utf-8', { ignoreBOM: true });
attributes.forEach((item: mdns.ServiceAttribute) => {
map.set(item.key, decoder.decodeWithStream(new Uint8Array(item.value), { stream: false }));
});
return map;
}
不做这一步,Dart 侧收到的 attributes 会是 int 数组,Map<String, String>.from(...) 直接抛类型错误。
要点四:解析要回查原始 info
resolveService 的入参只有 name / type / port(无前缀那套),而 mdns.resolveLocalService(context, info) 需要的是一个完整的 LocalServiceInfo 对象。两者不能直接对上。
所以发现实例里额外存了一份索引——serviceFound 时按名字落库,解析时按键取回:
private static serviceKey(info: mdns.LocalServiceInfo): string {
return `${info.serviceName}|${info.serviceType}|${info.port ?? DEFAULT_PORT}`;
}
const key: string = BonsoirPlugin.callServiceKey(call);
const info: mdns.LocalServiceInfo | undefined = instance.found.get(key);
if (info === undefined) {
this.emitDiscoverySimple(instance, EVENT_DISCOVERY_SERVICE_RESOLVE_FAILED);
result.success(null);
return;
}
mdns.resolveLocalService(context, info).then(/* ... */);
取不到就发 discoveryServiceResolveFailed 事件,而不是往 Dart 抛异常——上游的契约就是"解析失败走事件通知"。
四、生命周期:实例级状态是这里的重点
因为每个实例一条 EventChannel,onDetachedFromEngine 里的清理工作比前两篇重得多:
onDetachedFromEngine(binding: FlutterPluginBinding): void {
this.broadcasts.forEach((instance: BroadcastInstance) => {
this.stopBroadcast(instance, false);
});
this.discoveries.forEach((instance: DiscoveryInstance) => {
this.stopDiscovery(instance, false);
});
this.broadcasts.clear();
this.discoveries.clear();
this.channel?.setMethodCallHandler(null);
this.channel = null;
this.messenger = null;
}
两个细节:
- 必须逐个 stop,不能只清 Map。广播是向局域网注册的,只清 Map 而不调
removeLocalService,服务会一直挂在 mDNS 上。开发期反复热重载,局域网里就会累积出一批同名服务——而且因为重名冲突,后续的addLocalService会开始失败。 - 清理时传
emit = false。引擎都分离了,事件没人收,再往已失效的 sink 上打就是空转。
发现侧还要额外解绑监听:
service.stopSearchingMDNS();
service.off('serviceFound');
service.off('serviceLost');
EventSink 的挂接做成一个可复用的 StreamHandler,用两个闭包把实例和 sink 关联起来:
class InstanceStreamHandler implements StreamHandler {
private readonly listen: (events: EventSink) => void;
private readonly cancel: () => void;
constructor(listen: (events: EventSink) => void, cancel: () => void) {
this.listen = listen;
this.cancel = cancel;
}
onListen(args: Object | null, events: EventSink): void {
this.listen(events);
}
onCancel(args: Object | null): void {
this.cancel();
}
}
发出事件前先判 sink 是否存在。这一步不是防御性冗余:Dart 侧 initialize() 里的顺序是先 invokeMethod 再 receiveBroadcastStream(),也就是说原生侧执行 initialize 时,Dart 还没开始监听,onListen 尚未回调。如果 broadcast.initialize 里就把 sink 当已就绪用,第一次事件必然丢掉。适配里的处理是:
private emitBroadcast(instance: BroadcastInstance, eventId: string): void {
const sink = instance.sink;
if (sink === null) {
Log.i(TAG, `no broadcast listener for id=${instance.id}, drop ${eventId}`);
return;
}
// ...
}
五、pubspec 只加一个条目(但下游要写 path)
插件包自己的声明还是那一行:
flutter:
plugin:
platforms:
android:
default_package: bonsoir_android
ios:
default_package: bonsoir_darwin
macos:
default_package: bonsoir_darwin
windows:
default_package: bonsoir_windows
linux:
default_package: bonsoir_linux
ohos:
pluginClass: BonsoirPlugin
注意 OHOS 这一项只有 pluginClass,没有 default_package——鸿蒙实现就在主包里,不再拆一个 bonsoir_ohos 出去。这也意味着下游引用时不需要额外的包。
但下游的写法跟单包仓库不同,必须带 path:
dependencies:
bonsoir:
git:
url: https://atomgit.com/oh-flutter/bonsoir.git
ref: 7.1.5-ohos-1.0.0-beta.1
path: packages/bonsoir
少写 path: packages/bonsoir,pub 会在仓库根找 pubspec.yaml,而根上那个是 workspace 描述文件(name: bonsoir_workspace),直接报找不到包。
这里还有一个值得实测确认的点:packages/bonsoir/pubspec.yaml 里声明了 resolution: workspace,而 workspace 特性听起来像是"必须作为 workspace 成员才能解析"。实测结论是不会拦——在一个完全独立、非 workspace 的工程里按上面的写法引用,flutter pub get 正常通过:
+ bonsoir 7.1.5 from git https://atomgit.com/oh-flutter/bonsoir.git at 200977 in packages/bonsoir
+ bonsoir_android 7.1.3
+ bonsoir_darwin 7.1.1
+ bonsoir_linux 7.1.0
+ bonsoir_platform_interface 7.0.0
+ bonsoir_windows 7.3.0
bonsoir 来自 git,bonsoir_platform_interface 等依赖照旧从 pub.dev 解析,互不干涉。
六、踩坑记录
上游示例在 OHOS 上渲染不出来
上游 example/lib/main.dart 的主界面依赖一个新增服务的弹窗(点 + 打开)。在当前的 flutter_ohos 版本上,点下去只出现一层遮罩,弹窗内容不渲染,无障碍树里也找不到那些控件(FillNodesWithSearch failed)。
这是示例 UI 的问题,跟插件本身无关,但会挡住验证——没有界面就点不了按钮。处理办法是在 example/lib/ohos_demo.dart 里另写一个不依赖弹窗的演示页:两组按钮(开始/停止广播、开始/停止发现)+ 一个服务列表,专门用于 OHOS。
顺带一个好处:这个页面比上游示例更适合截图,因为广播状态、发现数量、解析结果都平铺在一屏里。
flutter build hap --debug --target-platform ohos-x64 -t lib/ohos_demo.dart
-t 指定入口文件,上游的 main.dart 保持原样不动。
模拟器是 NAT 网络,局域网 mDNS 用不了
模拟器拿到的地址是 10.0.2.15,这是 QEMU 的用户态 NAT 网段。它能和自己的宿主通信,但看不到宿主所在局域网里的其它 mDNS 广播——想"用手机发现电脑上跑的服务"在模拟器上做不到。
验证方案因此改成自闭环:同一个应用里既广播又发现。这看起来有点像自己给自己发消息,但它确实验证了完整的 mDNS 链路——服务真的被注册进了系统的 mDNS responder、发现查询真的走了网络、解析真的拿回了地址,而不是回了个空壳。后面第七节的验证结果可以印证这一点:解析到的端口与广播时声明的 8080 完全一致,地址就是本机网卡地址。
monorepo 的根 README 是符号链接,仓库首页会一片空白
推送完成后发现仓库首页看不到任何文档。原因在上游的仓库布局:根目录的 README.md 和 LICENSE 都不是实体文件,而是指向 packages/bonsoir/ 的符号链接(git 模式 120000):
120000 README.md -> packages/bonsoir/README.md
120000 LICENSE -> packages/bonsoir/LICENSE
120000 docs/README.md -> ...
有些平台会跟随符号链接去渲染 README,AtomGit 目前不会——于是首页没有任何文档内容,看起来像"README 没写";而适配文档放在 packages/bonsoir/ 里,从首页也点不进去。这不是适配漏了,是 monorepo 的布局在拖后腿。
修法是把根 README.md 换成实体文件。这里有个容易第二次踩的细节:在 Windows 上 core.symlinks=false,符号链接检出时会退化成"内容为链接目标的普通文本文件";直接覆盖这段文本再 git add,git 会把新内容仍然记成 120000 模式——等于一个指向整篇 README 文本的符号链接,在 Linux / macOS 上检出就是坏链接。必须把模式显式改掉:
git rm --cached README.md
git add README.md
git ls-files -s README.md # 要看到 100644,不是 120000
改完后再看远端,README.md 是 100644 模式的普通文件、96 行,首页文档正常显示。
顺带提一句:LICENSE 同样是符号链接。这类仓库适配完,最好把根目录的文档和协议都落成实体文件,别让平台去猜。
构建前几篇踩过的坑这里同样成立
不重复展开,只列结论:--target-platform ohos-x64(模拟器是 x64,默认 ohos-arm64 会装不上)、PUB_CACHE 与项目同盘、启动前 hdc shell power-shell wakeup、提交前清空 signingConfigs。
七、真机验证
验证在 HarmonyOS 7.0.0(26.0.0) Beta2 的 API 26 模拟器(ohos-x64)上完成,跑的是 example/lib/ohos_demo.dart。
广播:点「开始广播」,状态从 未开始 变为 已启动,收到 broadcastStarted 事件。

发现:点「开始发现」,状态变为 搜索中,列表里出现自己刚广播出去的服务。

解析:点服务条目下的「解析地址」,hostAddresses 被填上,界面显示端口与地址。
端口 8080 地址 10.0.2.15
端口与广播时声明的 8080 一致,说明 resolveLocalService 返回的是真实解析结果而不是默认值。

原生日志确认插件已加载、两个实例分别初始化:
FlutterEngineCxnRegistry --> Adding plugin: BonsoirPlugin
BonsoirPlugin --> broadcast.initialize id=27984 type=_bonsoirdemo._tcp
BonsoirPlugin --> discovery.initialize id=90095 type=_bonsoirdemo._tcp
两条日志里的 id 不同(27984 / 90095),正好印证了第一节说的"每实例一个随机 id"——原生侧确实按 id 建了两条独立的 EventChannel。
复现命令
. E:\flutter3fangku\.agents\ohos-env.ps1 # 载入 JAVA_HOME / DEVECO_SDK_HOME / FLUTTER_ROOT
$env:PUB_CACHE = "E:\pub-cache" # 必须与项目同盘
cd E:\flutter3fangku\_probe\bo_work\packages\bonsoir\example
flutter build hap --debug --target-platform ohos-x64 -t lib/ohos_demo.dart
hdc shell power-shell wakeup
hdc install -r build\ohos\hap\entry-default-signed.hap
hdc shell aa start -a EntryAbility -b fr.skyost.bonsoir_example
八、已知限制
三条,都写进了仓库的 README.OpenHarmony_CN.md。
supportsMdnsHostname() 返回 false。 上游这个方法用来问平台"能不能给出 .local 形式的 mDNS 主机名"。鸿蒙解析结果里的 host 是 NetAddress,只提供 IP 地址,没有主机名字段,所以如实返回 false。依赖 service.hostname 的调用方在鸿蒙上会拿到 null,得回退到自己用 IP。
discoveryServiceUpdated 事件不会触发。 鸿蒙 DiscoveryService 只暴露 serviceFound / serviceLost 两个服务级回调,没有"服务信息变更"这一路。事件 id 在 Dart 侧存在,但鸿蒙实现不会发出它。服务信息如果真的变了,走的是"先 lost 再 found"。
名称冲突的映射偏粗。 上游有一个 broadcastNameAlreadyExists 事件,鸿蒙这边是把 addLocalService 失败统一映射成它的。也就是说,其它原因(权限、网络不可用)导致的注册失败,用户收到的也是"名称已存在"。这是刻意的取舍:宁可给一个不精确的事件,也不要静默失败;但调用方不应该依赖事件里的名字去做重试逻辑——不同平台在冲突后是否改名重试的策略本来就不同。
另外有一条不在插件职责内、但使用者一定会遇到的:mdns 属于 SystemCapability.Communication.NetManager.MDNS,宿主应用需要在 module.json5 里声明 ohos.permission.INTERNET。插件本身不申请任何权限,示例工程里已经加好了:
"requestPermissions": [
{ "name": "ohos.permission.INTERNET" }
]
小结
这个库比前两个"厚"的地方,不在于接口多,而在于状态是有实例身份的。音量和传感器是全局单例语义,bonsoir 的每个广播/发现对象都有自己的 id 和通道;一旦接受了这一点,Map<id, Instance> 那套结构就是自然推导出来的。
真正需要留心的是三处"和直觉不一致":同一个库里两种参数前缀、所有 mdns 接口都要 Ability 上下文、monorepo 的 ohos/ 不在根目录。前两条会让代码跑不起来,第三条会让查重结论反过来——所以这次特意把仓库目录结构单独截图说明。
适配后的仓库和完整文档在这里:
https://atomgit.com/oh-flutter/bonsoir
dependencies:
bonsoir:
git:
url: https://atomgit.com/oh-flutter/bonsoir.git
ref: 7.1.5-ohos-1.0.0-beta.1
path: packages/bonsoir
欢迎加入 CPF-Flutter 鸿蒙社区:https://atomgit.com/CPF-Flutter
Flutter 三方库鸿蒙适配清单:https://atomgit.com/oh-flutter/flutter-ohos-adaptation-checklist
更多推荐




所有评论(0)