【共创稿事节】鸿蒙文搜图 · 语义相册:用一句话找到相册里的任意照片,端侧 AI 检索、相似度排序、作用域过滤
【共创稿事节】鸿蒙文搜图 · 语义相册:用一句话找到相册里的任意照片,端侧 AI 检索、相似度排序、作用域过滤
图像超分系列走完「真实照片进、真实文件出」之后,相册方向还有一个更日常的痛点没解决:找图。几千张照片里翻一张「去年海边看日落的那张」,靠人肉滚动定位基本没戏。这一篇换一个 Core Vision Kit 能力——textSearchImage 文搜图:把相册图片喂给端侧分析器建索引,之后用一句自然语言(「海边日落」「雪山」「猫」)直接检索,命中结果按语义相似度降序排列,全程 NPU 端侧推理、零联网、数据不出设备。
先说清楚这个能力的技术底色。传统相册搜索是「文本对文本」——搜文件名、搜手动备注,没打过标签的照片等于不存在。文搜图是「文本对图像」的跨模态检索:端侧模型把每张图片编码成一个语义向量,把用户输入的一句话也编码成同一语义空间里的向量,两个向量算相似度,语义越接近分越高。所以「海边日落」不需要出现在任何文件名或备注里,只要照片画面里真的有海边和日落,就能被找出来——这是打标签方案永远做不到的。
工程地址:
LI_harmonyOS/text-image-finder
运行环境:HarmonyOS 7.0(API 26)+ ArkTS 严格模式,已在真机安装运行验证。
一、效果展示:一条完整的检索叙事线
完整流程只有三步:建索引 → 输入一句话 → 看结果。以下截图均为演示模式(内置 12 张真实照片素材)真机运行,真机模式走同一套 UI、只把检索内核换成 NPU。

第 1 步:就绪。 应用启动后端侧能力初始化完成,状态栏提示「已就绪」,结果区是空态引导——一个相机 emoji 加两行说明文字,告诉用户下一步该干什么。空态不是留白,是引导:
第 2 步:建索引。 点「加载示例图片」:12 张照片按作用域分组登记进端侧索引,状态栏实时回显「已索引 12 张」。注意右上角的计数是全局口径——切到「宠物」域再点加载,只登记宠物域的 4 张,计数从 12 涨到 16,不会重复也不会翻倍:
第 3 步:检索。 搜索框输入「海边日落」点搜索,350ms 内返回命中网格:每张卡片右下角是相似度角标(百分比),下方是标题与精确到小数点后两位的相似度数值,整组结果按相似度降序——语义最相关的图永远排第一:
换一个域再试:切到「宠物」Tab 搜「猫」,只在宠物域的 4 张图里找,猫的相似度一骑绝尘排第一,狗、兔子、鱼这些「同为宠物但不是猫」的图以明显更低的分数跟在后面——相似度不是二值的「命中/未命中」,而是连续的「多相关」,这正是语义检索和标签过滤的本质区别:
二、这个 App 解决什么问题
相册找图的传统做法有三种,各有各的死角:
| 方案 | 死角 |
|---|---|
| 按时间/地点滚动翻 | 几千张照片里定位「去年那次海边日落」,翻到眼瞎 |
| 手动打标签/建相簿 | 打标签的成本比找图本身还高,没人坚持得下来 |
| 按文件名搜索 | 照片文件名是 IMG_20250816_001 这类流水号,和内容零关联 |
文搜图给出的答案是:建索引时端侧自动算语义特征,检索时拿文本语义和图片语义直接算相似度。用户只需要会说话——「海边日落」「雪山」「猫」——剩下的交给 NPU。这个实验台把它落成三件事:
- 作用域(scope)分组:照片按「旅行/工作/宠物」分域建索引,检索时只在指定域内找,跨域时聚合检索,避免「搜猫翻出会议室」;
- 相似度可量化:每张命中图带 0~1 相似度分值,UI 上同时给百分比角标和两位小数数值,用户能感知「多相关」,不只是「有没有」;
- 零权限建索引:真机模式走系统安全选择器选图入库,
module.json5不申请任何权限——数据面(选图)和权限面(申请相册读权限)彻底解耦。
三、交互设计:一个状态字段驱动的三态界面
页面没有路由跳转,全部状态收在一个 @State 集合里,build() 按数据渲染:
@State keyword: string = '';
@State loading: boolean = false;
@State ready: boolean = false;
@State indexedCount: number = 0;
@State statusText: string = '正在初始化文搜图能力…';
@State currentScope: string = SCOPE_ALL;
@State resultList: SearchHit[] = [];
七个字段各管一摊:keyword 绑搜索框、loading 管检索态、ready 管能力就绪、indexedCount 管索引计数、statusText 是全局状态栏的唯一文案源、currentScope 管作用域、resultList 管结果。没有第二个真相来源,UI 任何时刻的画面都是这七个字段的纯函数。
三个关键状态:
- 就绪态:
aboutToAppear里init()完成后ready = true,状态栏提示「已就绪,点击「加载示例图片」后即可搜索」,结果区显示空态引导; - 检索态:
loading = true时搜索按钮禁用、状态栏LoadingProgress转圈 + 「正在检索"xxx"…」,防止重复触发; - 结果态:
resultList非空渲染网格,空则状态栏给「暂无结果,换个说法试试」——空结果也是一等公民,不是报错。
3.1 防重入:loading 是闸门不是装饰
doSearch 的前两道检查决定了它永远不会被并发触发:能力未就绪直接 toast 返回,关键词为空(含纯空格,trim() 兜住)也直接返回。进入检索后 loading = true 把按钮闸门关上,finally 里无论成败都复位——异步流程的收口永远放 finally,不放 then,否则一次异常就把按钮永久锁死:
/** 执行搜索 */
private async doSearch(): Promise<void> {
if (!this.ready) {
this.toast('能力未就绪');
return;
}
const query: string = this.keyword.trim();
if (!query) {
this.toast('请输入描述文字');
return;
}
this.loading = true;
this.statusText = `正在检索“${query}”…`;
try {
const hits: SearchHit[] = await this.service.search(query, this.currentScope, 12);
this.resultList = hits;
this.statusText = hits.length > 0
? `找到 ${hits.length} 张相关图片`
: '暂无结果,换个说法试试';
} catch (e) {
this.statusText = '检索失败,请重试';
} finally {
this.loading = false;
}
}
注意状态栏文案的四种结局:找到 N 张 / 暂无结果 / 检索失败 / 正在检索——每一条异步路径都有专属文案,用户永远知道系统在干什么、干成了还是没干成。
3.2 快捷词:把演示成本压到一次点击
搜索框上方加了一排演示快捷词(海边日落/雪山/代码/猫),点一下即填入并自动检索——演示和体验都省去打字:
/** 演示快捷词:一键填入并检索,免手打 */
private quickWords: string[] = ['海边日落', '雪山', '代码', '猫'];
/** 快捷词点击:填入关键词并直接检索 */
private onQuickWord(word: string): void {
this.keyword = word;
this.doSearch();
}
四个词不是随手选的:海边日落 演示多标签强命中(高分)、雪山 演示域内命中与域外空态的对比、代码 演示工作域检索、猫 演示相似度的梯度分布——每个快捷词对应一个要演示的技术点。
3.3 作用域 Tab:切域即清结果
顶部一排作用域 Tab(全部/旅行/工作/宠物)只改 currentScope 并清空上次结果,不需要回主页重来。点当前已选中的 Tab 直接短路返回,不触发任何状态变化:
@Builder
scopeTab(item: ScopeItem) {
Button(item.label)
.width('auto')
.height(36)
.padding({ left: 18, right: 18 })
.borderRadius(18)
.fontSize(14)
.fontColor(this.currentScope === item.key ? '#FFFFFF' : '#2D6CFF')
.backgroundColor(this.currentScope === item.key ? '#2D6CFF' : '#E8F0FF')
.onClick(() => {
if (this.currentScope === item.key) {
return;
}
this.currentScope = item.key;
this.resultList = [];
})
}
清空结果是个刻意的设计:旧域的检索结果对新域来说是脏数据——在「旅行」域搜出的雪山结果,切到「宠物」域后如果还挂在页面上,用户会误以为宠物域里有雪山。宁可让用户再点一次搜索,也不给错误的上下文。
四、工程结构
text-image-finder/
├── entry/src/main/
│ ├── ets/
│ │ ├── pages/Index.ets # 三态页面、scope Tab、快捷词、结果网格
│ │ └── common/
│ │ ├── TextImageSearchService.ets # 单例服务:init/导入建索引/检索/清空/释放
│ │ ├── DemoData.ets # 12 张示例图:scope + 标签 + rawfile 路径
│ │ └── scopeConfig.ets # 作用域配置与中文标签映射
│ └── resources/rawfile/demo/ # 12 张真实照片素材(旅行/工作/宠物)
三层职责切得很干净:
| 层 | 文件 | 职责 | 不知道的事 |
|---|---|---|---|
| 页面层 | Index.ets | 状态机、布局、交互反馈 | 不知道检索是 NPU 还是标签匹配 |
| 服务层 | TextImageSearchService.ets | 索引生命周期、匹配、排序 | 不知道 UI 有哪些状态 |
| 数据层 | DemoData.ets + scopeConfig.ets | 素材定义、域配置 | 不知道自己被谁消费 |
UI 只调 init / importFromAlbum / search / clearData / release 五个方法,完全不感知底下是 NPU 语义推理还是演示标签匹配——和超分系列「双模式」同一套设计哲学。接口即防火墙:只要 SearchHit 的字段结构不变,换内核 UI 一行不改。
五、核心实现
5.1 作用域:把相册分成三个「小相册」
textSearchImage 的 scope 是底层分组标识,仅允许 1~32 位字母或数字,中文只能放在 UI 层。所以配置里 key/label 分离,另留一个特殊值 all 表示跨域聚合:
/** 全部(特殊值:检索时跨所有业务 scope 聚合) */
export const SCOPE_ALL: string = 'all';
/** 业务作用域列表(不含“全部”) */
export const SCOPES: ScopeItem[] = [
{ key: SCOPE_ALL, label: '全部' },
{ key: 'travel', label: '旅行' },
{ key: 'work', label: '工作' },
{ key: 'pet', label: '宠物' }
];
导入时按 scope 分组入库,检索时 all 走全量、其他走过滤——一进一出两侧都收口在服务层,UI 永远只传一个 scope 字符串。scopeLabel() 反查函数负责 key→中文的显示转换,状态栏文案里「已载入 4 张示例图(宠物)」的括号内容就来自它。
5.2 数据层:12 张图和它们的同义词矩阵
每张演示图的定义是四元组:id、rawfile 路径、scope、标签数组。标签数组是整个 Mock 引擎的灵魂——它不是简单的一个词,而是同义词矩阵:
export const DEMO_IMAGES: DemoImage[] = [
// —— 旅行 travel ——
{ id: 'sea', rawfile: 'demo/sea_sunset.png', scope: 'travel', title: '海边日落', tags: ['海边', '海', '日落', '夕阳', '沙滩', '风景', '旅行', '大海'] },
{ id: 'snow', rawfile: 'demo/snow_mountain.png', scope: 'travel', title: '雪山', tags: ['雪山', '雪', '山', '冬天', '风景', '旅行', '滑雪'] },
{ id: 'city', rawfile: 'demo/city_night.png', scope: 'travel', title: '城市夜景', tags: ['城市', '夜景', '夜', '灯光', '建筑', '旅行', '街道'] },
{ id: 'forest', rawfile: 'demo/forest.png', scope: 'travel', title: '森林', tags: ['森林', '树', '绿色', '自然', '风景', '旅行', '徒步'] },
// —— 工作 work ——
{ id: 'desk', rawfile: 'demo/desk_computer.png', scope: 'work', title: '电脑桌面', tags: ['电脑', '桌面', '笔记本', '办公', '工作', '键盘', '屏幕', '显示器'] },
{ id: 'meeting', rawfile: 'demo/meeting_room.png', scope: 'work', title: '会议室', tags: ['会议', '会议室', '工作', '讨论', '白板', '团队', '办公'] },
{ id: 'whiteboard', rawfile: 'demo/whiteboard.png', scope: 'work', title: '白板', tags: ['白板', '工作', '计划', '笔记', '会议', '写字'] },
{ id: 'code', rawfile: 'demo/code.png', scope: 'work', title: '代码', tags: ['代码', '编程', '开发', '工作', '电脑', '屏幕'] },
// —— 宠物 pet ——
{ id: 'cat', rawfile: 'demo/cat.png', scope: 'pet', title: '猫', tags: ['猫', '宠物', '喵', '动物', '可爱', '小猫'] },
{ id: 'dog', rawfile: 'demo/dog.png', scope: 'pet', title: '狗', tags: ['狗', '宠物', '汪', '动物', '可爱', '小狗'] },
{ id: 'rabbit', rawfile: 'demo/rabbit.png', scope: 'pet', title: '兔子', tags: ['兔子', '宠物', '动物', '可爱', '小兔'] },
{ id: 'fish', rawfile: 'demo/fish.png', scope: 'pet', title: '鱼', tags: ['鱼', '宠物', '动物', '水', '金鱼'] }
];
看「海边日落」那张图的 8 个标签:海边/海/日落/夕阳/沙滩/风景/旅行/大海——用户不管说「海」「大海」「夕阳」「沙滩」还是「海边日落」,都能命中它。这就是在 Mock 里尽量逼近真实语义检索的召回能力:真实 NPU 模型天然理解「夕阳约等于日落」,Mock 用同义词矩阵人工模拟这层理解。跨域词也刻意埋了钩子:旅行 同时出现在 4 张旅行图的标签里,搜「旅行」能一次召回整个旅行域。
5.3 建索引:导入即登记,幂等去重
真机模式通过系统安全选择器选图后拷贝进沙箱、逐张 insertImage 建索引;演示模式把 12 张示例图按 scope 登记进内存索引,接口完全一致:
public async importFromAlbum(scope: string, maxNumber: number = 20): Promise<number> {
if (!this.initialized) {
throw new Error('尚未初始化');
}
const targets: DemoImage[] = scope === SCOPE_ALL
? DEMO_IMAGES
: DEMO_IMAGES.filter((d: DemoImage) => d.scope === scope);
let added: number = 0;
for (const d of targets) {
if (!this.imported.some((x: DemoImage) => x.id === d.id)) {
this.imported.push(d);
this.indexedCount++;
added++;
}
if (this.imported.length >= maxNumber) {
break;
}
}
hilog.info(DOMAIN, TAG, 'mock import scope=%{public}s added=%{public}d', scope, added);
return added;
}
三个细节:
- 幂等去重:
some按 id 判重,重复点「加载」不会翻倍计数——先在「全部」域载入 12 张,再切「宠物」域点加载,4 张宠物图已在索引里,added为 0,状态栏如实提示「没有可载入的示例图」而不是假装又载入了 4 张; - maxNumber 上限:默认 20,防止一次灌爆索引——真机上相册选图一次选几百张,不设上限会把端侧索引和内存同时打爆;
- 返回
added而不是总数:UI 能区分「载入了 N 张」和「没有可载入的示例图」两种结果,增量语义比总量语义信息量更大。
5.4 检索:文本语义 vs 图片语义
检索主流程先按 scope 过滤出候选池,再对每张图算匹配度、排序、截断 topN。匹配规则是双向包含:用户输入包含标签,或标签包含用户输入,都计为命中——「海」能命中「海边」,「海边日落」也能命中「海边」:
public async search(query: string, scope: string, topKey: number = 12): Promise<SearchHit[]> {
if (!this.initialized) {
throw new Error('尚未初始化');
}
const keyword: string = query.trim();
if (!keyword) {
return [];
}
// 模拟端侧推理耗时,便于演示 loading 态
await this.delay(350);
const pool: DemoImage[] = scope === SCOPE_ALL
? this.imported
: this.imported.filter((d: DemoImage) => d.scope === scope);
const hits: SearchHit[] = [];
for (const d of pool) {
let matched: number = 0;
for (const tag of d.tags) {
if (keyword.includes(tag) || tag.includes(keyword)) {
matched++;
}
}
if (matched === 0) {
continue; // 未命中标签不返回,与真实 textSearchImage 行为一致(只返回相关图)
}
const jitter: number = this.pseudo(d.id);
const sim: number = 0.6 + 0.35 * Math.min(1, matched / 2) + jitter * 0.05;
hits.push({
imagePath: d.rawfile,
similarity: Number(sim.toFixed(3)),
rawfile: true,
title: d.title
});
}
hits.sort((a: SearchHit, b: SearchHit) => b.similarity - a.similarity);
return hits.slice(0, topKey);
}
值得展开的有五点:
- 350ms 人工延迟:不是偷懒,是给 loading 态留展示窗口。真实 NPU 检索几十张图也在几百毫秒量级,如果 Mock 秒回,
LoadingProgress一闪而过,用户根本感知不到「系统在推理」——演示效果反而失真; - 未命中不返回:早期版本给未命中图一个 0.12~0.30 的「弱相关兜底分」,结果任何关键词都有返回——搜「雪山」在宠物域里也冒出四张低分图,既不真实(真实
textSearchImage只返回相关图)也让空态 UI 永远展示不出来。改成matched === 0直接continue,行为与真实 API 对齐; - 相似度分档:
0.6 + 0.35 × min(1, matched/2) + jitter × 0.05——命中 1 个标签 0.6 起步,命中 2 个封顶 0.95。搜「海边日落」时海边图同时命中「海边」和「日落」两个标签拿高分,只命中「海」的图明显低一档,分数梯度对应语义梯度; - 确定性抖动:
pseudo(d.id)对 id 字符串做哈希折叠出 0~1 的小数,同一张图的分数永远稳定——不会出现这次搜 87% 下次搜 82% 的诡异跳变。用Math.random()固然省事,但检索结果的可复现性是演示可信度的底线; - 降序 + topN:
sort后slice(0, topKey),UI 网格最多 12 张,语义最相关的永远在第一屏。
5.5 结果卡片:一张图带两个相似度口径
每张命中卡片用 Stack 把相似度角标压在图片右下角,标题行右侧再给精确到两位小数的分值——百分比管「一眼扫」,小数管「较真」:
@Builder
resultCard(item: SearchHit, index: number) {
Column() {
Stack({ alignContent: Alignment.BottomEnd }) {
// Mock 演示:imagePath 为 rawfile 资源;真机实现时为沙箱绝对路径
if (item.rawfile) {
Image($rawfile(item.imagePath))
.width('100%')
.height(150)
.objectFit(ImageFit.Cover)
.borderRadius(12)
.backgroundColor('#EDEFF3')
} else {
Image(item.imagePath)
.width('100%')
.height(150)
.objectFit(ImageFit.Cover)
.borderRadius(12)
.backgroundColor('#EDEFF3')
}
Text(`${Math.max(0, Math.round(item.similarity * 100))}%`)
.fontSize(12)
.fontColor('#FFFFFF')
.backgroundColor('#2D6CFF')
.borderRadius(10)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.margin(8)
}
.width('100%')
Row({ space: 6 }) {
Text(item.title ?? `#${index + 1}`)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${(item.similarity).toFixed(2)}`)
.fontSize(12)
.fontColor('#8A8F99')
}
.width('100%')
.margin({ top: 6, left: 2 })
}
.width('100%')
}
图片加载分两条路:rawfile: true 走 $rawfile() 读内置素材,false 走沙箱绝对路径——SearchHit 里那个 rawfile 布尔字段就是为这一步准备的,真机/演示同一张卡片组件直接复用。两个防御性细节也值得抄走:Math.max(0, ...) 兜底保证万一相似度异常,角标至少显示 0% 而不是 NaN%;标题兜底 #${index+1} 保证真机模式下没有 title 字段时卡片也不塌角标。
5.6 作用域过滤的实测效果:同一句话的两个结局
切到「旅行」域搜「雪山」,只在 4 张旅行图里找,雪山图以最高分排第一:
反过来,切到「宠物」域搜「雪山」——宠物域里没有雪山,返回空数组,状态栏给出「暂无结果,换个说法试试」,结果区回落到空态引导图。空结果不是异常,是检索系统的正常输出,UI 必须为它设计专门的状态而不是弹错误:
同一句「雪山」在两个域里一有一无,作用域过滤的价值直接可见:域切小了,检索既快又准,还不会串台。这组对照也是快捷词里专门放「雪山」的原因——一个词演示两种结局,比两个词各演示一种更有说服力。
5.7 生命周期成对收口
init 在 aboutToAppear 里调用,release 必须在 aboutToDisappear 里成对收口,否则退出页面后端侧索引资源不释放,下次进页面重复 init 会报「重复初始化」:
aboutToAppear(): void {
// 在组件生命周期中初始化端侧分析器服务(成对:aboutToDisappear 中 release)
const context: common.Context = this.getUIContext().getHostContext() as common.Context;
this.service.init(context).then((ok: boolean) => {
this.ready = ok;
this.statusText = ok ? '已就绪,点击「加载示例图片」后即可搜索' : '文搜图能力初始化失败,请确认设备已升级 HarmonyOS 7.0';
if (ok) {
this.indexedCount = this.service.getIndexedCount();
}
});
}
aboutToDisappear(): void {
// 生命周期收口:释放端侧索引资源
this.service.release();
}
初始化失败也有专门文案——「请确认设备已升级 HarmonyOS 7.0」直接把排查方向告诉用户,而不是一句干巴巴的「初始化失败」。
Toast 统一走 this.getUIContext().getPromptAction(),避免全局 promptAction 的上下文歧义与弃用告警——ArkTS 严格模式下的标准写法:
/** 统一 Toast 出口:走 UIContext,避免全局 promptAction 的上下文歧义与弃用告警 */
private toast(message: string): void {
try {
this.getUIContext().getPromptAction().showToast({ message: message });
} catch (e) {
const err = e as BusinessError;
console.error(`showToast failed, code: ${err.code}, message: ${err.message}`);
}
}
Toast 本身也包了 try-catch——反馈通道自己不能成为新的故障点,Toast 失败只记日志,绝不向上抛。
六、真机运行指南
- 演示模式(默认):点「加载示例图片」载入 12 张内置真实照片素材(旅行 4 / 工作 4 / 宠物 4),标签匹配引擎出结果,任何设备都能完整跑通建索引→检索→空态全流程;
- 真机模式:把
TextImageSearchService内部实现替换为RealTextImageSearchService——init调visionBase建分析器、导入走PhotoViewPicker选图 +insertImage建索引、search调textSearchImage,返回结构完全一致,UI 零改动; - 检索结果与索引数据全在端侧,不联网、不上传,清空索引用「清空索引」按钮(
clearData),重进页面自动重新初始化。
七、踩坑清单速查表
| 现象 | 原因 | 修法 |
|---|---|---|
| 搜任何词都出低分结果 | 未命中时给了「弱相关兜底分」 | matched === 0 直接 continue,与真实 API 行为对齐 |
| 空结果态永远展示不出来 | 同上,永远有返回 | 修掉兜底分后,空数组回落到空态引导 UI |
| scope 传中文报错 | 底层标识仅允许 1~32 位字母/数字 | key 用 travel/work/pet,中文放 label 层 |
| 重复点「加载」计数翻倍 | 导入未去重 | 按 id some 判重,幂等导入 |
| 退出页面再进报「重复初始化」 | release 没有成对调用 | aboutToAppear init / aboutToDisappear release |
| 同一张图相似度每次都变 | 用了随机抖动 | 基于 id 的确定性伪随机,分数稳定 |
| Toast 告警「弃用」 | 用了全局 promptAction | 走 getUIContext().getPromptAction() |
| 结果卡片图片不显示 | rawfile 素材与沙箱路径混用 | SearchHit.rawfile 布尔分流:$rawfile() vs 绝对路径 |
| 检索中重复点搜索状态错乱 | loading 闸门没关 | loading = true 禁用按钮,finally 复位 |
| 切域后旧结果残留误导 | 切域未清结果 | Tab onClick 里 resultList = [] |
八、总结
至此相册方向走出了「找图」这一步:一句话进、相似度排序的图片网格出。
- 作用域把大相册切成小相册,域内检索快且不串台;
- 相似度从「有没有」升级到「多相关」,百分比角标 + 两位小数双口径;
- 建索引、检索全在端侧 NPU,零权限、零联网、数据不出设备;
- 空结果是一等公民,**「暂无结果,换个说法试试」**而不是报错弹窗;
- 状态机七字段单真相源,每条异步路径都有专属状态文案。
下一步可以往两个方向推:一是和图像超分系列打通——检索命中的低清老照片先 4× 超分再展示,「找到即增强」;二是把索引从内置素材换成真实相册全量,配合媒体库变更监听做增量索引,让「语义相册」真正接管几千张照片。两边的内核都已经在各自系列里备齐了。
更多推荐





所有评论(0)