从零开始开发纯血鸿蒙应用
- 一、前言
- 二、逻辑封装的原则
- 三、实现 FileUtil
- 1、统一的存放位置
- 2、文件的增删改查
- 2.1、文件创建与文件保存
- 2.2、文件读取
- 2.2.1、读取内部文件
- 2.2.2、读取外部文件
- 3、文件删除
- 四、总结
一、前言
应用的动态,借助 UI 响应完成,所谓 UI响应,就是指对用户操作的回应。通常,UI 响应可分为纯逻辑响应和内容刷新两大类型,前者指用户触发动作发生后,不会在应用页面上有任何改变,而后者往往会产生页面内容的更新。
简单的UI响应,处理代码可以直接放在组件的事件处理方法中
,鸿蒙UI组件支持的通用事件有如下:
复杂的响应处理,也即代码量比较多,无法通过只调用一个系统API来完成的,就需要封装在另外的方法体中,然后将对应的方法以函数参数的形式传入组件的事件处理函数中,这时候就涉及到逻辑封装
了。
二、逻辑封装的原则
与 UI 封装一样,逻辑封装也有相应的原则必须遵守。于我而言,原则之一就是,能与UI实现文件独立的,就不要杂糅在 Component struct 里面,除非涉及到更新UI内容
。用独立文件进行封装时,最好搞成工具类的形式,将负责相同类型处理的代码,统一放置在相同的 ets 文件中,方便进行源码管理,比如,在本工程中用到的文件读写处理,我就是专门放在了 lib_util模块的 FileUtil 中。
封装工具类的时候,应当将实现方法以静态方法的形式对外提供,所以,有必要将 类构造函数私有化,即如下:
要知道,很多UI响应处理都是面向过程
的,因而面向对象
的那一套类体实现,就不要采用了,即便类体里面存在某些需要初始化操作的字段,也应该有同样是静态方法的 init 方法去实现。
最后,每个工具类的每个方法都应该有函数头注释和日志打印,函数头注释要采用文档类型,这样在其他ets 文件中进行使用的时候,才能通过鼠标悬停获知说明信息:
三、实现 FileUtil
正如第一篇所说,本工程旨在实现一个支持通用纯文本文件浏览和编辑的纯血鸿蒙应用,因此,文件的读写操作,在本工程里面是重中之重的,下面就分享一下我在实现 FileUtil 过程中的一些考量。
1、统一的存放位置
在应用内创建的纯文本格式的文件,不论文件后缀为何,我都是统一放在沙箱目录 fileDir下的 docs 文件夹中,如此一来,便可以降低获取文件名列表的方法的复杂性。
在鸿蒙API中,允许根据指定目录和指定文件后缀,去获取文件名列表,例如本工程里面实现的 getFileNameList 方法:
/*** 获取文件名列表* @param ctx 上下文* @returns 返回指定目录下的纯文本文件的文件名列表*/static getFileNameList(ctx: common.UIAbilityContext) {const prefix: string = ctx.filesDir;const dir: string = DirectoryConstants.DOCUMENT_PATH;const path: string = `${prefix}/${dir}`;const option: ListFileOptions = {recursion: false,listNum: 0,filter: {suffix: ['.txt', '.log', '.csv', '.ini', '.conf', '.md', '.markdown', '.rtf', '.json','.xml', '.ets', '.java', '.py','.c', '.cpp', '.h', '.html']}}return fs.listFileSync(path, option)}
应用沙箱路径可以借助 UI 上下文进行获取,所以,方法参数就是一个 UI 上下文。将文件直接放在应用沙箱的一级目录,如 file 目录下,是不明智的,所以,必须另辟一个子目录进行存放,而子目录名可以记录在 lib_constants 中。
2、文件的增删改查
就像数据库一样,文件也是可以增删改查的。
2.1、文件创建与文件保存
首先,看一下文件的新增和修改:
/*** 保存文本数据到文件* @param ctx 上下文* @param data 待保存的数据* @param fileName 目标文件名* @returns 是否写入成功*/static async saveToFile(ctx: common.UIAbilityContext, data: string, fileName: string): Promise<number> {const prefix: string = ctx.filesDir;const dir: string = DirectoryConstants.DOCUMENT_PATH;const path: string = `${prefix}/${dir}/${fileName.trimEnd()}`;const file = fs.openSync(path, fs.OpenMode.READ_WRITE|fs.OpenMode.CREATE);return new Promise((resolve, reject) => {fs.write(file.fd, data).then((writeLen) => {Logger.info(`write ${writeLen} bytes to ${path}`, TAG)resolve(writeLen);}).catch((err: BusinessError) => {Logger.error(`write file failed, ${err.message}`, TAG)reject(err);}).finally(() => {fs.closeSync(file)})})}
考虑到 IO 操作通常都比较慢,所以,采用异步方法的形式进行实现,为了保障做到文件不存在时创建、存在时就写入,需要将文件以 fs.OpenMode.READ_WRITE|fs.OpenMode.CREATE
打开。
为了减少重复的目录存在性判断代码,我在 entry 模块的 util 目录下的 EntryUtil 中,专门用一个 createDirectory 方法负责目录的创建:
static createDirectoryDocs() {if (EntryUtil.context) {const prefix = EntryUtil.context.filesDir;const docsLocator = `${prefix}/${DirectoryConstants.DOCUMENT_PATH}`;if (fs.accessSync(docsLocator)) {Logger.info(`${docsLocator}已存在`, TAG);} else {fs.mkdir(docsLocator).then(() => {Logger.info(`${docsLocator}创建成功`, TAG);}).catch((err: BusinessError) => {Logger.error(`${docsLocator}创建失败: ${err.message}`, TAG);})}} else {throw new Error("context is not init");}}
并在 EntryAbility 的 onCreate 方法中调用。
回到 saveToiFile 方法,该方法会返回一个 Promise<number>
对象,这是 Typescript 或者说 Javascript 中,专门为异步方法提供的返回值类型;当文件内容成功写入目标文件中时,会将写入的字节数通过 resolve 回调函数返回给调用者,而如果写入失败,则用 reject 回调函数抛出错误。
由于是文件写入操作,所以,除了 UI 上下文外,还需要文件名和文件内容。
2.2、文件读取
文件读取分为读取内部文件和外部文件两种,并且针对性地封装了相应的方法。对于内部文件,即在本应用中创建的文件,只需传入一个文件名即可,而对于外部文件、即其他应用通过系统的文件分享功能传入的文件,就需要传入完整的 file uri
才能打开。
2.2.1、读取内部文件
首先,看一下内部文件的读取实现代码:
/*** 读取文件内容* @param ctx 上下文* @param fileName 文件名* @returns 文件内容*/static async getFromFile(ctx: common.UIAbilityContext, filename: string): Promise<string>{const prefix: string = ctx.filesDir;const dir: string = DirectoryConstants.DOCUMENT_PATH;const path: string = `${prefix}/${dir}/${filename}`;Logger.info(`read file from ${path}`, TAG)return new Promise((resolve, reject) => {if (fs.accessSync(path)) {const stat = fs.statSync(path);if (stat.size > 0) {const readTextOption: ReadTextOptions = {offset: 1,length: stat.size,encoding: 'utf-8'};fs.readText(path, readTextOption).then((data) => {Logger.info(`read ${data.length} bytes from ${path}`, TAG)resolve(data);}).catch((err: BusinessError) => {Logger.error(`read file failed, ${err.message}`, TAG)reject(err);})} else {resolve("");}} else {reject(`${filename} is not exist!`);}})}
一样采用异步方法的形式进行实现,在处理逻辑中,会先判断文件的存在性,如果不存在则抛错。接着利用 fs.statSync(path)
去获取文件信息,如文件大小等,该 API 的官方说明如下:
而 Stat 对象的组成如下:
- ino:文件标识,通常同设备上的不同文件的INO不同。
- mode:文件权限。
- uid:文件所有者的ID。
- gid:文件所有组的ID。
- size:文件的大小,以字节为单位。仅对普通文件有效。
- atime:上次访问该文件的时间,表示距1970年1月1日0时0分0秒的秒数。
- mtime:上次修改该文件的时间,表示距1970年1月1日0时0分0秒的秒数。
- ctime:最近改变文件状态的时间,表示距1970年1月1日0时0分0秒的秒数。
- location:文件的位置,表示该文件是本地文件或者云端文件。
有了文件的 Stat 信息后,就可以利用其中的 size 字段,去设置 ReadTextOptions,该 option 是 readText 方法所必传的,readText 方法官方说明如下:
成功读取,则将文件内容通过 resolve 回调函数外传。
2.2.2、读取外部文件
外部文件的读取实现,代码如下:
/*** 读取其他应用分享的文件* @param fileUri* @returns*/static async readExternalFile(fileUri: string): Promise<string> {return new Promise((resolve, reject) => {const file = fs.openSync(fileUri, fs.OpenMode.READ_ONLY);if (file) {Logger.info(`success open file: ${file.path}`, TAG);const fileStat = fs.statSync(file.fd);Logger.info(`file size: ${fileStat.size}`, TAG);const buf: ArrayBuffer = new ArrayBuffer(fileStat.size);fs.read(file.fd, buf).then((readLen) => {if (readLen > 0) {const decoder = util.TextDecoder.create('utf-8');const content = decoder.decodeToString(new Uint8Array(buf));resolve(content);} else {reject(new Error("read file failed"))}}).then(() => {reject(new Error("read file failed"))}).finally(() => {fs.closeSync(file);})} else {Logger.error(`open file failed: ${fileUri}`);reject(new Error("open file failed"))}})}
大致上和内部文件的读取实现相同,除了参数只需传入 file uri 和使用 fs.read
API 外。
fs.read 方法读取文件时,会将内容读取到一个 ArrayBuffer 中,所以,在利用 resolve 回调外传文件内容前,需要 ArrayBuffer 进行转码操作,将其转成 string 类型。
3、文件删除
在鸿蒙框架中,文件删除是通过调用 fileIo 的 unlink 或 unlinkSync 实现的,从方法名就可以看出,文件的删除实际上,只是将原本指向文件所在存储区域的指针或者链接,进行摘除和悬空
,并非是将对应的存储区域用二进制零进行覆盖。
四、总结
其他的功能逻辑的封装,基本上跟 FileUtil 的封装大同小异,都是通过一组系统 API 的相互配合,达到功能的实现。