Persistencia de Datos en HarmonyOS: Manejo de Archivos, Preferencias y SQLite

Gestión de Archivos en el Sandbox de HarmonyOS

Para la manipulación directa de archvios de texto dentro del sandbox de la aplicación, HarmonyOS provee el módulo @ohos.file.fs. El siguiente componente ilustra cómo capturar texto del usuario, persistirlo en un archivo local y posteriormente recuperarlo para su visualización en la interfaz.

// FileHandler.ets
import promptAction from '@ohos.promptAction'
import fs from '@ohos.file.fs'

@Entry
@Component
struct FileHandler {
  @State userInput: string = ''
  @State displayedContent: string = 'El contenido aparecerá aquí'
  private appContext = getContext(this)
  private storageFile: string = 'datos_usuario.txt'

  build() {
    Column() {
      TextInput({ placeholder: 'Escribe el texto a guardar' })
        .width('85%')
        .height(50)
        .onChange((val: string) => { this.userInput = val })

      Button('Guardar en Archivo')
        .width('85%')
        .margin({ top: 15 })
        .onClick(() => { this.persistData() })

      Button('Leer desde Archivo')
        .width('85%')
        .margin({ top: 15 })
        .onClick(() => { this.fetchData() })

      Text(this.displayedContent)
        .width('85%')
        .margin({ top: 15 })
        .padding(12)
        .backgroundColor('#eeeeee')
        .borderRadius(6)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }

  private async persistData() {
    try {
      const targetPath = this.appContext.filesDir + '/' + this.storageFile
      let fileDescriptor = await fs.open(targetPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNCATE)
      await fs.write(fileDescriptor.fd, this.userInput)
      await fs.close(fileDescriptor.fd)
      promptAction.showToast({ message: 'Escritura exitosa', duration: 1500 })
    } catch (err) {
      promptAction.showToast({ message: 'Error al escribir', duration: 1500 })
    }
  }

  private async fetchData() {
    try {
      const targetPath = this.appContext.filesDir + '/' + this.storageFile
      if (!await fs.access(targetPath)) {
        promptAction.showToast({ message: 'Archivo inexistente', duration: 1500 })
        return
      }
      let fileDescriptor = await fs.open(targetPath, fs.OpenMode.READ_ONLY)
      let fileStats = await fs.stat(fileDescriptor.fd)
      let buffer = new ArrayBuffer(fileStats.size)
      await fs.read(fileDescriptor.fd, buffer)
      await fs.close(fileDescriptor.fd)
      this.displayedContent = String.fromCharCode.apply(null, new Uint8Array(buffer))
    } catch (err) {
      promptAction.showToast({ message: 'Error de lectura', duration: 1500 })
    }
  }
}

Arquitectura de Memos con SQLite y Preferencias

Para una gestión estructurada de datos relacionales, como en una aplicación de notas o memos, se emplea SQLite a través del módulo @ohos.data.relationalStore. Adicionalmente, las configuraciones de interfaz del usuario, como los tamaños de fuente, se preservan eficientemente utilizando el módulo @ohos.data.preferences.

Modelo de Datos

// MemoItem.ets
export class MemoItem {
  recordId?: number
  header: string = ''
  body: string = ''
  creationTime: string = ''
  updateTime: string = ''

  constructor(header: string, body: string, creationTime: string, updateTime: string) {
    this.header = header
    this.body = body
    this.creationTime = creationTime
    this.updateTime = updateTime
  }
}

Gestor de Base de Datos Relacional

Se unifica la lógica de consulta filtrando mediante un parámetro opcional, simplificando las interacciones con la base de datos.

// RdbManager.ets
import relationalStore from '@ohos.data.relationalStore';
import { MemoItem } from './MemoItem';

const DB_SETTINGS = {
  name: 'MemosDB.db',
  securityLevel: relationalStore.SecurityLevel.S1
};

const SQL_INIT = `CREATE TABLE IF NOT EXISTS memos (
  recordId INTEGER PRIMARY KEY AUTOINCREMENT,
  header TEXT NOT NULL,
  body TEXT,
  creationTime TEXT NOT NULL,
  updateTime TEXT NOT NULL
)`;

export class RdbManager {
  private dbInstance: relationalStore.RdbStore | null = null;

  async setupDatabase(ctx: Context): Promise<void> {
    this.dbInstance = await relationalStore.getRdbStore(ctx, DB_SETTINGS);
    await this.dbInstance.executeSql(SQL_INIT);
  }

  async insertMemo(item: MemoItem): Promise<number> {
    if (!this.dbInstance) return -1;
    const dataBucket = { 'header': item.header, 'body': item.body, 'creationTime': item.creationTime, 'updateTime': item.updateTime };
    return await this.dbInstance.insert('memos', dataBucket);
  }

  async modifyMemo(item: MemoItem): Promise<boolean> {
    if (!this.dbInstance || !item.recordId) return false;
    const dataBucket = { 'header': item.header, 'body': item.body, 'updateTime': item.updateTime };
    const condition = new relationalStore.RdbPredicates('memos');
    condition.equalTo('recordId', item.recordId);
    const affectedRows = await this.dbInstance.update(dataBucket, condition);
    return affectedRows > 0;
  }

  async removeMemo(id: number): Promise<boolean> {
    if (!this.dbInstance) return false;
    const condition = new relationalStore.RdbPredicates('memos');
    condition.equalTo('recordId', id);
    const deletedRows = await this.dbInstance.delete(condition);
    return deletedRows > 0;
  }

  async fetchMemos(filterTerm: string = ''): Promise<Array<MemoItem>> {
    if (!this.dbInstance) return [];
    const condition = new relationalStore.RdbPredicates('memos');
    if (filterTerm) {
      condition.contains('header', filterTerm);
    }
    const resultSet = await this.dbInstance.query(condition, ['recordId', 'header', 'body', 'creationTime', 'updateTime']);
    const itemList: Array<MemoItem> = [];
    while (resultSet.goToNextRow()) {
      const memo = new MemoItem(
        resultSet.getString(resultSet.getColumnIndex('header')),
        resultSet.getString(resultSet.getColumnIndex('body')),
        resultSet.getString(resultSet.getColumnIndex('creationTime')),
        resultSet.getString(resultSet.getColumnIndex('updateTime'))
      );
      memo.recordId = resultSet.getLong(resultSet.getColumnIndex('recordId'));
      itemList.push(memo);
    }
    resultSet.close();
    return itemList;
  }
}

export const rdbMgr = new RdbManager();

Gestor de Preferencias de Usuario

// SettingsManager.ets
import preferences from '@ohos.data.preferences';

const PREF_FILE = 'app_settings';
const HEADER_FONT_SIZE_KEY = 'headerSize';
const BODY_FONT_SIZE_KEY = 'bodySize';
const DEFAULT_FONT_SIZE = 16;

export class SettingsManager {
  private settingsInstance: preferences.Preferences | null = null;

  async setupPreferences(ctx: Context): Promise<void> {
    this.settingsInstance = await preferences.getPreferences(ctx, PREF_FILE);
  }

  async getHeaderFontSize(): Promise<number> {
    if (!this.settingsInstance) return DEFAULT_FONT_SIZE;
    const val = await this.settingsInstance.get(HEADER_FONT_SIZE_KEY, DEFAULT_FONT_SIZE);
    return typeof val === 'number' ? val : DEFAULT_FONT_SIZE;
  }

  async setHeaderFontSize(size: number): Promise<void> {
    if (!this.settingsInstance) return;
    await this.settingsInstance.put(HEADER_FONT_SIZE_KEY, size);
    await this.settingsInstance.flush();
  }

  async getBodyFontSize(): Promise<number> {
    if (!this.settingsInstance) return DEFAULT_FONT_SIZE;
    const val = await this.settingsInstance.get(BODY_FONT_SIZE_KEY, DEFAULT_FONT_SIZE);
    return typeof val === 'number' ? val : DEFAULT_FONT_SIZE;
  }

  async setBodyFontSize(size: number): Promise<void> {
    if (!this.settingsInstance) return;
    await this.settingsInstance.put(BODY_FONT_SIZE_KEY, size);
    await this.settingsInstance.flush();
  }
}

export const settingsMgr = new SettingsManager();

Interfaz Principal: Listado y Búsqueda

La pantalla de inicio despliega los registros usando un diseño basado en Stack para superponer el botón de eliminación y habliitar el gesto de deslizamiento lateral.

// MemoList.ets
import router from '@ohos.router';
import { rdbMgr } from './RdbManager';
import { MemoItem } from './MemoItem';
import { settingsMgr } from './SettingsManager';

@Entry
@Component
struct MemoList {
  @State memoList: Array<MemoItem> = [];
  @State filterTerm: string = '';
  @State headerSize: number = 16;
  @State bodySize: number = 16;
  @State activeSwipeRow: number = -1;
  @State dragOffset: number = 0;

  aboutToAppear() {
    this.initializeApp();
  }

  onPageShow() {
    this.loadMemos();
  }

  async initializeApp() {
    await rdbMgr.setupDatabase(getContext(this));
    await settingsMgr.setupPreferences(getContext(this));
    this.headerSize = await settingsMgr.getHeaderFontSize();
    this.bodySize = await settingsMgr.getBodyFontSize();
    this.loadMemos();
  }

  async loadMemos() {
    this.memoList = await rdbMgr.fetchMemos(this.filterTerm);
  }

  build() {
    Column() {
      Search({ value: this.filterTerm, placeholder: 'Filtrar por encabezado' })
        .onChange((val: string) => { this.filterTerm = val; this.loadMemos(); })
        .width('90%')
        .margin(10)

      List({ space: 12 }) {
        ForEach(this.memoList, (item: MemoItem, idx: number) => {
          ListItem() {
            Stack({ alignContent: Alignment.End }) {
              Button('Eliminar')
                .type(ButtonType.Normal)
                .backgroundColor(Color.Red)
                .height('100%')
                .width(70)
                .onClick(async () => { await rdbMgr.removeMemo(item.recordId!); this.loadMemos(); this.activeSwipeRow = -1; })

              Row() {
                Column() {
                  Text(item.header).fontSize(this.headerSize).fontWeight(FontWeight.Bold)
                  Text(item.body).fontSize(this.bodySize).maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text(`Creado: ${item.creationTime} | Modificado: ${item.updateTime}`).fontSize(11).opacity(0.6).margin({ top: 4 })
                }.padding(10).width('100%')
              }
              .backgroundColor(Color.White)
              .borderRadius(8)
              .shadow({ radius: 3, color: '#cccccc', offsetX: 1, offsetY: 1 })
              .offset({ x: this.activeSwipeRow === idx ? this.dragOffset : 0 })
              .onClick(() => {
                if (this.activeSwipeRow !== -1) { this.activeSwipeRow = -1; this.dragOffset = 0; return; }
                router.pushUrl({ url: 'pages/MemoEditor', params: { editMode: 'modify', recordId: item.recordId?.toString() } });
              })
              .gesture(
                PanGesture({ direction: PanDirection.Horizontal })
                  .onActionStart(() => { if (this.activeSwipeRow !== idx) this.activeSwipeRow = idx; })
                  .onActionUpdate((e: GestureEvent) => {
                    if (this.activeSwipeRow === idx && e.offsetX < 50 && e.offsetX > -120) this.dragOffset = e.offsetX;
                  })
                  .onActionEnd(() => {
                    this.dragOffset = this.dragOffset < -30 ? -80 : 0;
                    if (this.dragOffset === 0) this.activeSwipeRow = -1;
                  })
              )
            }.height(90).width('100%').clip(true)
          }
        }, (item: MemoItem) => item.recordId?.toString() ?? '')
      }.layoutWeight(1).width('100%')

      Button('Nuevo Memo')
        .onClick(() => { router.pushUrl({ url: 'pages/MemoEditor', params: { editMode: 'create' } }); })
        .width('90%').margin(10)
    }.width('100%').height('100%')
  }
}

Interfaz de Edición y Configuración

Esta vista permite crear o actualizar un registro, e incluye controles deslizantes que guardan las preferencias tipográficas del usuario de manera instantánea.

// MemoEditor.ets
import router from '@ohos.router';
import { rdbMgr } from './RdbManager';
import { settingsMgr } from './SettingsManager';
import { MemoItem } from './MemoItem';

@Entry
@Component
struct MemoEditor {
  @State header: string = '';
  @State body: string = '';
  @State headerSize: number = 16;
  @State bodySize: number = 16;
  private operationMode: string = 'create';
  private targetRecordId: number = 0;

  aboutToAppear() {
    const navParams = router.getParams() as Record<string, string>;
    this.operationMode = navParams?.editMode ?? 'create';
    if (this.operationMode === 'modify' && navParams?.recordId) {
      this.targetRecordId = parseInt(navParams.recordId);
      this.populateFields();
    }
    this.loadSettings();
  }

  async loadSettings() {
    this.headerSize = await settingsMgr.getHeaderFontSize();
    this.bodySize = await settingsMgr.getBodyFontSize();
  }

  async populateFields() {
    const items = await rdbMgr.fetchMemos();
    const target = items.find(m => m.recordId === this.targetRecordId);
    if (target) { this.header = target.header; this.body = target.body; }
  }

  async commitMemo() {
    if (!this.header.trim()) return;
    const timestamp = new Date().toLocaleString();
    const currentMemo = new MemoItem(this.header, this.body, timestamp, timestamp);

    if (this.operationMode === 'create') {
      await rdbMgr.insertMemo(currentMemo);
    } else {
      currentMemo.recordId = this.targetRecordId;
      await rdbMgr.modifyMemo(currentMemo);
    }
    router.back();
  }

  build() {
    Column() {
      TextInput({ text: this.header, placeholder: 'Encabezado' })
        .onChange((v: string) => { this.header = v; })
        .fontSize(this.headerSize).margin(10).width('90%')

      TextArea({ text: this.body, placeholder: 'Cuerpo del memo' })
        .onChange((v: string) => { this.body = v; })
        .fontSize(this.bodySize).margin(10).width('90%').height(180)

      Button('Guardar')
        .onClick(() => this.commitMemo())
        .width('90%').margin(10)

      Row() {
        Text('Fuente Título:').fontSize(13)
        Slider({ value: this.headerSize, min: 12, max: 30, step: 1, style: SliderStyle.OutSet })
          .onChange((v: number) => { this.headerSize = v; settingsMgr.setHeaderFontSize(v); })
          .width('55%')
      }.margin(10).width('90%').justifyContent(FlexAlign.SpaceBetween)

      Row() {
        Text('Fuente Cuerpo:').fontSize(13)
        Slider({ value: this.bodySize, min: 12, max: 30, step: 1, style: SliderStyle.OutSet })
          .onChange((v: number) => { this.bodySize = v; settingsMgr.setBodyFontSize(v); })
          .width('55%')
      }.margin(10).width('90%').justifyContent(FlexAlign.SpaceBetween)
    }.width('100%').height('100%')
  }
}

Etiquetas: HarmonyOS ArkTS SQLite DataPersistence ArkUI

Publicado el 10-12 18:42