HarmonyOS 小说文件查询案例:TaskPool + State Management V2 实战
效果
![]() | ![]() | ![]() |
|---|
前言
本文通过一个完整的小说文件查询应用案例,演示如何在 HarmonyOS 中结合 TaskPool 多线程与 State Management V2 进行应用开发。案例覆盖了从项目搭建、数据建模、并发服务到 UI 渲染的完整开发流程。
通过本案例,你将掌握:
- 使用
@Concurrent+fileIo在子线程中执行文件查询 - 使用
@ComponentV2+@ObservedV2构建响应式 UI - 使用
@Local+@Trace实现细粒度状态管理 - 数据在 TaskPool 子线程与主线程之间的传递模式
一、项目概述
1.1 功能描述
本应用实现了一个小说文件查询工具,核心功能包括:
- 初始化加载:应用启动时,根据嵌入的元数据在沙箱目录中创建小说文件(避免子线程访问 rawfile 路径限制)
- 关键词搜索:输入关键词后,在子线程中按文件名、作者、内容进行搜索
- 分类过滤:支持按「科幻、悬疑、文学、历史、现代」分类筛选
- 多维排序:支持按匹配度、文件大小、名称排序
- 实时反馈:搜索带 300ms 防抖,查询过程显示加载动画
1.2 技术栈
| 技术 | 说明 |
|---|---|
| 语言 | ArkTS |
| SDK | HarmonyOS 6.1 (API 23) |
| 状态管理 | State Management V2 |
| 多线程 | @ohos.taskpool + @Concurrent |
| 文件操作 | @kit.CoreFileKit (fileIo) |
| 组件模型 | @ComponentV2 |
1.3 UI 效果
┌──────────────────────────────────┐
│ 小说查询 TaskPool │
├──────────────────────────────────┤
│ 🔍 搜索小说名称、作者或内容... │
├──────────────────────────────────┤
│ 全部 科幻 悬疑 文学 历史 现代│
├──────────────────────────────────┤
│ 共 100 个结果 匹配度|大小|名称 │
├──────────────────────────────────┤
│ ┌──────────────────────────────┐ │
│ │ 科幻 匹配度 100%│ │
│ │ 星际迷航纪 │ │
│ │ 作者:陈星河 │ │
│ │ 公元2387年,人类文明已经... │ │
│ │ 📄 1.2KB 📅 2024-06-20 │ │
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ 悬疑 匹配度 80% │ │
│ │ 古城密码 │ │
│ │ ... │ │
│ └──────────────────────────────┘ │
└──────────────────────────────────┘
二、项目结构设计
2.1 目录结构
entry/src/main/
├── ets/
│ ├── common/
│ │ └── Constants.ets # 常量定义 + 小说种子数据
│ ├── model/
│ │ └── NovelFileInfo.ets # @ObservedV2 数据模型
│ ├── service/
│ │ └── NovelQueryService.ets # @Concurrent 查询服务
│ ├── viewmodel/
│ │ └── NovelListViewModel.ets # @ObservedV2 视图模型
│ └── pages/
│ └── Index.ets # 主页面 (@ComponentV2)
├── resources/
│ └── rawfile/
│ └── novels/ # 原始小说文件(参考素材,非运行时依赖)
│ ├── 星际迷航纪.txt
│ ├── 古城密码.txt
│ └── ... (100个原创小说文件)
设计说明:小说元数据(标题、作者、分类、简介)以
NOVEL_SEEDS常量数组的形式嵌入代码中。子线程在沙箱目录中根据种子数据创建文件,不依赖 rawfile 路径访问。这是因为 TaskPool 子线程无法通过fileIo直接访问 rawfile 目录。
2.2 架构分层
┌────────────────────────────────────────────────┐
│ UI 层 (@ComponentV2) │
│ Index.ets → SearchBar + FilterBar + CardList │
└──────────────┬─────────────────────────────────┘
│ @Local viewModel
┌──────────────▼─────────────────────────────────┐
│ ViewModel 层 (@ObservedV2) │
│ NovelListViewModel → 过滤/排序/状态管理 │
└──────────────┬─────────────────────────────────┘
│ 调用
┌──────────────▼─────────────────────────────────┐
│ Service 层 (主线程封装) │
│ NovelQueryHelper → Task 创建 + 数据转换 │
└──────────────┬─────────────────────────────────┘
│ taskpool.execute
┌──────────────▼─────────────────────────────────┐
│ TaskPool 层 (子线程) │
│ @Concurrent initNovelFiles / queryNovelFiles │
│ fileIo 文件读写操作 │
└────────────────────────────────────────────────┘
2.3 数据流向
- 初始化:
aboutToAppear→NovelQueryHelper.initFiles(NOVEL_SEEDS)→ TaskPool 子线程将种子数据写入沙箱文件 → 返回NovelFileRawData[]→ 转换为NovelFileInfo[]→ 赋值给 ViewModel - 搜索:用户输入 → 防抖 300ms →
NovelQueryHelper.search()→ TaskPool 子线程匹配 → 结果返回 UI - 过滤/排序:分类切换/排序按钮 → ViewModel 前端过滤 →
@Trace自动刷新 UI
三、数据模型层
3.1 NovelFileRawData — 跨线程传输载体
由于 TaskPool 子线程返回的数据必须是可序列化类型,而 @ObservedV2 类实例不可序列化,因此定义了纯 interface 作为传输载体:
export interface NovelFileRawData {
fileName: string // 文件名
filePath: string // 完整路径
fileSize: number // 文件大小(字节)
lastModified: number // 最后修改时间戳
preview: string // 内容预览(前120字符)
matchScore: number // 搜索匹配度(0-100)
category: string // 分类(科幻/悬疑/文学等)
author: string // 作者
title: string // 标题
}
设计要点:只包含基本类型(string、number),确保可以被序列化后跨线程传递。
3.2 NovelFileInfo — @ObservedV2 响应式模型
主线程接收到 NovelFileRawData 后,转换为 @ObservedV2 类实例,实现 UI 的细粒度响应式更新:
@ObservedV2
export class NovelFileInfo {
@Trace fileName: string = ''
@Trace title: string = ''
@Trace author: string = ''
@Trace category: string = ''
@Trace fileSize: number = 0
@Trace lastModified: number = 0
@Trace filePath: string = ''
@Trace preview: string = ''
@Trace matchScore: number = 0
@Trace isSelected: boolean = false
constructor(raw?: NovelFileRawData) {
if (raw) {
this.fileName = raw.fileName
this.title = raw.title
this.author = raw.author
this.category = raw.category
this.fileSize = raw.fileSize
this.lastModified = raw.lastModified
this.filePath = raw.filePath
this.preview = raw.preview
this.matchScore = raw.matchScore
}
}
}
为什么要 @Trace? 每个属性都标记 @Trace,当某个小说的 matchScore 变化时,只有引用该属性的 UI 组件会重新渲染,而不是整个列表刷新。
3.3 NovelListViewModel — 页面状态管理
@ObservedV2
export class NovelListViewModel {
@Trace novelList: Array<NovelFileInfo> = [] // 完整列表
@Trace filteredList: Array<NovelFileInfo> = [] // 过滤后列表
@Trace searchKeyword: string = '' // 搜索关键词
@Trace currentCategory: string = '全部' // 当前分类
@Trace isLoading: boolean = false // 加载状态
@Trace totalCount: number = 0 // 结果总数
@Trace currentSort: SortType = 'score' // 排序方式
}
关键方法 — 过滤逻辑:
updateFilteredList(): void {
let result: Array<NovelFileInfo> = [];
for (let i = 0; i < this.novelList.length; i++) {
let item: NovelFileInfo = this.novelList[i];
// 分类过滤
if (this.currentCategory !== '全部' && item.category !== this.currentCategory) {
continue;
}
// 关键词过滤
if (this.searchKeyword !== '') {
let keyword: string = this.searchKeyword.toLowerCase();
let match: boolean = item.title.toLowerCase().indexOf(keyword) >= 0
|| item.author.toLowerCase().indexOf(keyword) >= 0
|| item.preview.toLowerCase().indexOf(keyword) >= 0;
if (!match) continue;
}
result.push(item);
}
this.filteredList = result;
this.totalCount = result.length;
}
注意:使用
for循环而非Array.filter()是因为 ArkTS 编译器存在泛型推断限制(arkts-no-inferred-generic-params),高阶函数链可能导致编译错误。
四、并发服务层
4.1 种子数据定义
由于 TaskPool 子线程无法通过 fileIo 访问 rawfile 目录(rawfile 需要通过 resourceManager API 在主线程访问),我们将小说元数据以常量数组的形式嵌入代码中:
// Constants.ets
export interface NovelSeedData {
fileName: string
title: string
author: string
category: string
preview: string
}
export const NOVEL_SEEDS: NovelSeedData[] = [
{ fileName: '星际迷航纪.txt', title: '星际迷航纪', author: '陈星河', category: '科幻',
preview: '公元2387年,人类文明已经跨越太阳系的边界...' },
{ fileName: '古城密码.txt', title: '古城密码', author: '苏墨白', category: '悬疑',
preview: '考古学家方若琳在西北荒漠的一次抢救性发掘中...' },
// ... 共 100 本小说
]
踩坑经验:最初尝试在
@Concurrent函数中通过resourceDir + '/rawfile/novels/'访问 rawfile 文件,运行时抛出No such file or directory错误。这是因为 rawfile 资源在沙箱中并非以普通文件系统路径暴露,需要通过resourceManager.getRawFileContent()等 API 访问,而这些 API 在子线程中不可用。因此改为将元数据嵌入代码,在子线程中写入沙箱目录。
4.2 @Concurrent 初始化函数
应用启动时,根据种子数据在沙箱目录中创建小说文件。这个操作在 TaskPool 子线程中执行:
@Concurrent
function initNovelFiles(filesDir: string, seeds: Array<SeedItem>): Array<NovelFileRawData> {
let novelsDir: string = filesDir + '/' + NOVEL_DIR_NAME;
let results: Array<NovelFileRawData> = [];
// 确保沙箱目录存在
if (!fileIo.accessSync(novelsDir)) {
fileIo.mkdirSync(novelsDir, true);
}
for (let i = 0; i < seeds.length; i++) {
let seed: SeedItem = seeds[i];
let filePath: string = novelsDir + '/' + seed.fileName;
// 构建文件内容
let content: string = '标题:' + seed.title + '\n' +
'作者:' + seed.author + '\n' +
'分类:' + seed.category + '\n\n' +
'【内容简介】\n\n' + seed.preview;
// 幂等写入:已存在则跳过,不存在则创建
if (!fileIo.accessSync(filePath)) {
let fd: fileIo.File = fileIo.openSync(filePath,
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
let encoder: util.TextEncoder = util.TextEncoder.create('utf-8');
let encoded: Uint8Array = encoder.encodeInto(content);
fileIo.writeSync(fd.fd, encoded.buffer as ArrayBuffer);
fileIo.closeSync(fd.fd);
}
// 获取文件统计信息
let fileStat: fileIo.Stat = fileIo.statSync(filePath);
results.push({
fileName: seed.fileName,
filePath: filePath,
fileSize: fileStat.size,
lastModified: fileStat.mtime,
preview: seed.preview,
matchScore: 100,
category: seed.category,
author: seed.author,
title: seed.title
});
}
return results;
}
关键设计:
- 种子数据
seeds作为参数从主线程传入(普通interface数组可序列化) - 使用
accessSync检查文件是否已存在,保证多次启动的幂等性 - 使用
TextEncoder将字符串编码为Uint8Array后写入文件 - 同步 API(
mkdirSync、accessSync、statSync)在子线程中使用不会阻塞主线程
4.3 @Concurrent 查询函数
搜索功能的并发实现,支持文件名匹配和内容匹配:
@Concurrent
function queryNovelFiles(dirPath: string, keyword: string): Array<NovelFileRawData> {
let results: Array<NovelFileRawData> = [];
let lowerKeyword: string = keyword.toLowerCase();
let files: Array<string> = fileIo.listFileSync(dirPath);
for (let i = 0; i < files.length; i++) {
let fileName: string = files[i];
if (!fileName.endsWith('.txt')) continue;
let content: string = readTextFromFile(dirPath + '/' + fileName);
let metadata: MetaData = parseMetadata(content, fileName);
// 匹配度评分
let score: number = 100;
if (lowerKeyword !== '') {
let title: string = metadata.title.toLowerCase();
if (title === lowerKeyword) {
score = 100; // 完全匹配
} else if (title.indexOf(lowerKeyword) >= 0) {
score = 80; // 标题包含
} else if (content.toLowerCase().indexOf(lowerKeyword) >= 0) {
score = 50; // 内容包含
} else {
score = 0; // 不匹配
}
}
if (score > 0) {
results.push(/* 构建结果对象 */);
}
}
// 按匹配度降序排列
for (let i = 0; i < results.length - 1; i++) {
for (let j = i + 1; j < results.length; j++) {
if (results[j].matchScore > results[i].matchScore) {
let temp = results[i];
results[i] = results[j];
results[j] = temp;
}
}
}
return results;
}
匹配度评分机制:
| 匹配情况 | 评分 | 说明 |
|---|---|---|
| 标题完全等于关键词 | 100 | 精确命中 |
| 标题或作者包含关键词 | 80 | 模糊命中 |
| 仅内容包含关键词 | 50 | 全文命中 |
| 都不匹配 | 0 | 过滤掉 |
4.4 NovelQueryHelper — 服务封装
NovelQueryHelper 运行在主线程,封装 TaskPool 任务的创建和执行:
export class NovelQueryHelper {
private filesDir: string = '';
constructor(filesDir: string) {
this.filesDir = filesDir;
}
async initFiles(seeds: Array<SeedItem>): Promise<Array<NovelFileInfo>> {
let task: taskpool.Task = new taskpool.Task(initNovelFiles, this.filesDir, seeds);
let result: Object = await taskpool.execute(task, taskpool.Priority.MEDIUM);
let rawDataList: Array<NovelFileRawData> = result as Array<NovelFileRawData>;
return this.convertToNovelList(rawDataList);
}
async search(keyword: string): Promise<Array<NovelFileInfo>> {
let novelsDir: string = this.filesDir + '/' + NOVEL_DIR_NAME;
let task: taskpool.Task = new taskpool.Task(queryNovelFiles, novelsDir, keyword);
let result: Object = await taskpool.execute(task, taskpool.Priority.HIGH);
let rawDataList: Array<NovelFileRawData> = result as Array<NovelFileRawData>;
return this.convertToNovelList(rawDataList);
}
private convertToNovelList(rawDataList: Array<NovelFileRawData>): Array<NovelFileInfo> {
let result: Array<NovelFileInfo> = [];
for (let i = 0; i < rawDataList.length; i++) {
result.push(new NovelFileInfo(rawDataList[i]));
}
return result;
}
}
优先级选择策略:
- 初始化:
Priority.MEDIUM(默认优先级,不抢占资源) - 搜索:
Priority.HIGH(用户交互敏感,尽快返回结果)
五、UI 层实现
5.1 主页面声明
使用 @ComponentV2 + @Local 声明页面级状态:
@Entry
@ComponentV2
struct Index {
@Local viewModel: NovelListViewModel = new NovelListViewModel()
@Local searchInput: string = ''
@Local queryHelper: NovelQueryHelper | null = null
private searchTimer: number = -1
aboutToAppear(): void {
this.initData()
}
}
V2 vs V1 对照:
| V1 写法 | V2 写法 |
|---|---|
@Component struct Index | @ComponentV2 struct Index |
@State message: string | @Local message: string |
@State list: Array<Item> | @Local vm: ViewModel(配合 @ObservedV2) |
5.2 初始化流程
async initData(): Promise<void> {
this.viewModel.isLoading = true;
try {
let context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
this.queryHelper = new NovelQueryHelper(context.filesDir);
let list: Array<NovelFileInfo> = await this.queryHelper.initFiles(NOVEL_SEEDS);
this.viewModel.setNovelList(list);
} catch (e) {
this.viewModel.errorMessage = `初始化失败: ${JSON.stringify(e)}`;
}
this.viewModel.isLoading = false;
}
流程说明:
- 获取
UIAbilityContext取得filesDir(不再需要resourceDir) - 创建
NovelQueryHelper实例,传入filesDir - 调用
initFiles(NOVEL_SEEDS),将种子数据传入 TaskPool 子线程写入沙箱文件 - 返回的
NovelFileInfo[]赋值给 ViewModel isLoading变为false,@Trace触发 UI 刷新,列表显示
5.3 搜索防抖实现
doSearch(keyword: string): void {
if (this.searchTimer !== -1) {
clearTimeout(this.searchTimer);
}
this.searchTimer = setTimeout(async () => {
this.viewModel.searchKeyword = keyword;
if (keyword.trim() === '') {
this.viewModel.clearSearch();
return;
}
this.viewModel.isLoading = true;
try {
if (this.queryHelper) {
let results = await this.queryHelper.search(keyword);
this.viewModel.setServerResult(results);
}
} catch (e) {
this.viewModel.errorMessage = `查询失败: ${JSON.stringify(e)}`;
}
this.viewModel.isLoading = false;
}, 300); // 300ms 防抖
}
为什么需要防抖? 用户每输入一个字符都会触发搜索,如果不加防抖,连续输入 “星际” 会触发两次 TaskPool 查询。300ms 的延迟可以合并连续输入,只在用户停顿后执行一次查询。
5.4 搜索栏组件
Row() {
Image($r('sys.media.ohos_ic_public_search_filled'))
.width(20).height(20).fillColor('#999999')
TextInput({ placeholder: '搜索小说名称、作者或内容...', text: this.searchInput })
.layoutWeight(1)
.backgroundColor(Color.Transparent)
.onChange((value: string) => {
this.searchInput = value;
this.doSearch(value);
})
if (this.searchInput.length > 0) {
Image($r('sys.media.ohos_ic_public_cancel_filled'))
.width(18).height(18).fillColor('#cccccc')
.onClick(() => {
this.searchInput = '';
this.viewModel.clearSearch();
})
}
}
5.5 分类过滤条
使用横向 Scroll + ForEach 渲染分类标签:
Scroll() {
Row({ space: 10 }) {
ForEach(CATEGORIES, (category: string) => {
Text(category)
.fontColor(this.viewModel.currentCategory === category ? '#1890FF' : '#666666')
.backgroundColor(this.viewModel.currentCategory === category ? '#E6F7FF' : '#F5F5F5')
.borderRadius(16)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.onClick(() => {
this.viewModel.setCategory(category);
})
})
}
}
.scrollable(ScrollDirection.Horizontal)
V2 状态驱动:当 viewModel.currentCategory 变化时,@Trace 自动触发选中状态的样式更新。
5.6 小说卡片
@Builder
NovelCard(novel: NovelFileInfo) {
Column() {
// 分类标签 + 匹配度
Row() {
Text(novel.category)
.fontColor('#1890FF')
.backgroundColor('#E6F7FF')
.borderRadius(4)
Blank()
Text(`${novel.matchScore}%`)
.fontColor(novel.matchScore >= 80 ? '#52C41A' : '#FAAD14')
}
// 标题 + 作者
Text(novel.title).fontSize(18).fontWeight(FontWeight.Bold)
Text(`作者:${novel.author}`).fontSize(13)
// 内容预览
Text(novel.preview).maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis })
// 文件信息
Row() {
Text(novel.getFormattedSize())
Text(novel.getFormattedDate())
}
}
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.shadow({ radius: 4, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 2 })
}
六、关键技术要点
6.1 State Management V1 → V2 对照
| V1 装饰器 | V2 装饰器 | 用途 |
|---|---|---|
@State | @Local | 页面内状态 |
@Prop | @Param | 父传子(值传递) |
@Link | @Param + @Event | 双向绑定改为单向+回调 |
@ObjectLink | @ObservedV2 + @Trace | 对象级响应式 |
@Provide/@Consume | @Provider/@Consumer | 跨层级状态共享 |
@Watch | @Monitor | 属性变化监听 |
| — | @Computed | 计算属性缓存 |
6.2 跨线程数据传输模式
主线程 子线程 (@Concurrent)
┌──────────────────┐ ┌──────────────────┐
│ NOVEL_SEEDS │ ──→ │ 序列化传输 │
│ (interface[]) │ │ (深拷贝) │
│ 元数据嵌入代码 │ │ │
└──────────────────┘ └────────┬─────────┘
│ 反序列化 + 写入文件
▼
┌──────────────────┐
│ 创建沙箱文件 │
│ fileIo.openSync │
│ fileIo.writeSync │
│ 返回 RawData[] │
└────────┬─────────┘
│ 序列化返回
▼
┌──────────────────┐
│ new NovelFileInfo │
│ (@ObservedV2) │
│ @Trace 属性 │
└──────────────────┘
核心原则:
- 种子数据作为参数传入子线程(普通
interface数组可序列化) - 子线程创建文件后返回纯数据(
interface),主线程将其转换为响应式对象(@ObservedV2类)
6.3 ArkTS 泛型限制
ArkTS 编译器对泛型方法有严格限制,以下写法可能导致编译错误:
// ❌ 可能触发 arkts-no-inferred-generic-params
let names = this.novelList.map(item => item.title);
// ✅ 使用 for 循环替代
let names: Array<string> = [];
for (let i = 0; i < this.novelList.length; i++) {
names.push(this.novelList[i].title);
}
七、性能优化要点
| 优化项 | 做法 | 效果 |
|---|---|---|
| 文件操作不阻塞 UI | fileIo 操作放在 @Concurrent 子线程 | 主线程保持 60fps |
| 搜索防抖 | setTimeout 300ms | 减少不必要的 TaskPool 调用 |
| 精确 UI 刷新 | @Trace 标记每个属性 | 只有变化的属性触发对应组件重渲染 |
| 排序优先级选择 | 搜索用 HIGH,初始化用 MEDIUM | 用户交互优先响应 |
| 文件幂等创建 | accessSync 检查已存在则跳过 | 避免重复 IO |
| 预览截断 | 只读取前 120 字符 | 减少内存占用 |
八、完整源码文件清单
| 文件 | 路径 | 职责 |
|---|---|---|
| Constants.ets | ets/common/ | 常量定义 + 小说种子数据(分类、评分、配置) |
| NovelFileInfo.ets | ets/model/ | 数据模型 + 传输接口 |
| NovelQueryService.ets | ets/service/ | @Concurrent 并发函数 + Helper 封装 |
| NovelListViewModel.ets | ets/viewmodel/ | 视图模型(过滤/排序/状态管理) |
| Index.ets | ets/pages/ | 主页面 UI |
九、踩坑与解决方案
9.1 子线程无法访问 rawfile 目录
问题现象:在 @Concurrent 函数中通过 resourceDir + '/rawfile/novels/' 访问 rawfile 文件时,运行时抛出:
Error: No such file or directory
at initNovelFiles (NovelQueryService.ets:22:42)
taskpool::PerformTask occur exception
根因分析:
- rawfile 资源在沙箱中并非以普通文件系统路径暴露
- 访问 rawfile 需要使用
resourceManager.getRawFileContent()等 API - 而
resourceManager依赖于UIAbilityContext,在 TaskPool 子线程中不可用
解决方案:
- 将小说元数据以
NovelSeedData[]常量数组的形式嵌入代码 - 种子数据是纯
interface数组,可以序列化后传入子线程 - 子线程在
filesDir沙箱目录中根据种子数据创建文件 - 后续查询操作直接读取沙箱目录,无 rawfile 依赖
9.2 ArkTS 泛型推断限制
问题现象:使用 Array.filter()、Array.map() 等高阶函数时编译报错。
解决方案:统一使用 for 循环替代高阶函数链,避免触发 arkts-no-inferred-generic-params。
9.3 @Concurrent 函数中不可访问 UI 上下文
问题现象:在 @Concurrent 函数中调用 getContext() 报错。
解决方案:所有需要的路径信息(如 filesDir)在主线程中获取后,作为参数传入 @Concurrent 函数。
十、总结与扩展
10.1 核心收获
本案例展示了 HarmonyOS 应用开发的三个核心能力:
- TaskPool 多线程:将文件 IO 等耗时操作放在子线程执行,保证 UI 流畅
- State Management V2:通过
@ObservedV2+@Trace实现细粒度响应式更新 - 跨线程数据传递:使用纯
interface作为序列化载体,主线程转换为响应式对象
10.2 可扩展方向
- 全文搜索:在子线程中实现倒排索引,支持更精确的全文检索
- 分布式文件查询:结合分布式数据管理,搜索多设备上的小说文件
- 文件管理:添加删除、移动、重命名等文件管理功能
- 阅读功能:添加小说全文阅读页面,支持翻页、字体设置(已实现,详见《Navigation 页面导航实现指南》)
系列文档:
- 📘
@ohos.taskpool 使用指南— TaskPool API 详解与使用规范- 📗
本文— 基础查询功能的完整实现过程- 📙
Navigation 页面导航实现指南— 详情页跳转功能的添加步骤- 📕
小说查询案例总体介绍指南— 案例总体效果与架构介绍- 📓
小说初始化实现指南— 种子数据设计与批量文件初始化流程
参考文档:




1万+

被折叠的 条评论
为什么被折叠?



