这个应用主要用了主要用了基本的ArkUI,MVVM架构,模块化设计,异步,单例模式,还封装了关系型数据库方法类还有 Http网络请求方法类。

当时我遇到的一个困境就是代码很乱很乱,所以我查官方文档,用模块化设计还有MVVM架构来重写代码。第二个困境就是不知道怎么获得网络的新闻数据,我的尝试是爬虫,结果发现代码量大,而且新闻数据时常变化,又是视频又是文本的数据很难爬取,所以我就想着能否直接把网页html数据投影到UI界面,所以我先获取今日头条的免费api的json,然后用web组件直接加载网页。

这是运行视频

点击跳转B站视频

源码我资源绑定了,而且我用的编程软件是DevEco Studio 6.1.1

以下是结构图,仅供参考,还有这个需要自己去src/main/module.json5里面声明网络权限,还有自己往oh-package.json5里面添加需要的依赖,还有在各个模块的index把需要的方法导出。!

接下来我会按照运行的顺序把代码发出来

RdbUtil.ets 关系型数据库方法的封装

i

import { relationalStore } from '@kit.ArkData';
import { BusinessError } from '@kit.BasicServicesKit';

export class RDBHelper {
  // 使用 Map 管理多个数据库实例,key 为数据库名称
  private static storeMap: Map<string, RDBHelper> = new Map();

  // 实例变量,每个 Helper 对应一个具体的 Store
  private mStore: relationalStore.RdbStore;
  private dbName: string;

  // 构造函数私有化,强制通过 getInstance 获取实例
  private constructor(store: relationalStore.RdbStore, dbName: string) {
    this.mStore = store;
    this.dbName = dbName;
  }

  /**
   * 获取数据库操作实例(异步工厂方法)
   * @param context 上下文
   * @param config 数据库配置
   * @returns 初始化完成的 RDBHelper 实例
   */
  static async getInstance(context: Context, config: relationalStore.StoreConfig): Promise<RDBHelper> {
    // 如果已存在实例,直接返回
    let helper = RDBHelper.storeMap.get(config.name);
    if (helper) {
      return helper;
    }

    // 否则创建新实例并缓存
    let store: relationalStore.RdbStore = await relationalStore.getRdbStore(context, config);
    helper = new RDBHelper(store, config.name);
    RDBHelper.storeMap.set(config.name, helper);
    return helper;
  }

  /**
   * 关闭并移除数据库实例
   */
  static async closeInstance(dbName: string): Promise<void> {
    let helper = RDBHelper.storeMap.get(dbName);
    if (helper) {
      await helper.mStore.close();
      RDBHelper.storeMap.delete(dbName);
    }
  }

  /**
   * 删除数据库文件
   */
  async deleteRdbStore(context: Context): Promise<boolean> {
    try {
      // 先关闭并从 Map 中移除
      await RDBHelper.closeInstance(this.dbName);
      await relationalStore.deleteRdbStore(context, this.dbName);
      console.info(`Delete RdbStore ${this.dbName} successfully.`);
      return true;
    } catch (err) {
      let error = err as BusinessError;
      console.error(`Delete RdbStore failed, code is ${error.code}, message is ${error.message}`);
      return false;
    }
  }

  /**
   * 执行 SQL 语句(建表等)
   */
  executeSql(SQL: string): Promise<boolean> {
    return new Promise((resolve) => {
      this.mStore.executeSql(SQL).then(() => {
        console.info('Execute SQL done.');
        resolve(true);
      }).catch((err: BusinessError) => {
        console.error(`Execute SQL failed, code is ${err.code}, message is ${err.message}`);
        resolve(false);
      });
    });
  }

  /**
   * 插入数据
   */
  insert(table: string, values: relationalStore.ValuesBucket,
    conflict: relationalStore.ConflictResolution = relationalStore.ConflictResolution.ON_CONFLICT_REPLACE): Promise<number> {
    return new Promise((resolve, reject) => {
      try {
        // 使用异步方法,避免 UI 卡死
        this.mStore.insert(table, values, conflict).then((rowId: number) => {
          resolve(rowId);
        }).catch((err: BusinessError) => {
          console.error(`Insert failed, code is ${err.code}, message is ${err.message}`);
          reject(err);
        });
      } catch (err) {
        reject(err);
      }
    });
  }

  /**
   * 更新数据
   */
  update(values: relationalStore.ValuesBucket, predicates: relationalStore.RdbPredicates,
    conflict: relationalStore.ConflictResolution = relationalStore.ConflictResolution.ON_CONFLICT_REPLACE): Promise<number> {
    return new Promise((resolve, reject) => {
      try {
        this.mStore.update(values, predicates, conflict).then((rows: number) => {
          resolve(rows);
        }).catch((err: BusinessError) => {
          console.error(`Update failed, code is ${err.code}, message is ${err.message}`);
          reject(err);
        });
      } catch (err) {
        reject(err);
      }
    });
  }

  /**
   * 删除数据
   */
  delete(predicates: relationalStore.RdbPredicates): Promise<number> {
    return new Promise((resolve, reject) => {
      try {
        this.mStore.delete(predicates).then((rows: number) => {
          resolve(rows);
        }).catch((err: BusinessError) => {
          console.error(`Delete failed, code is ${err.code}, message is ${err.message}`);
          reject(err);
        });
      } catch (err) {
        reject(err);
      }
    });
  }

  /**
   * 查询数据
   */
  query(predicates: relationalStore.RdbPredicates, columns?: Array<string>): Promise<relationalStore.ResultSet> {
    return new Promise((resolve, reject) => {
      try {
        this.mStore.query(predicates, columns).then((resultSet: relationalStore.ResultSet) => {
          resolve(resultSet);
        }).catch((err: BusinessError) => {
          console.error(`Query failed, code is ${err.code}, message is ${err.message}`);
          reject(err);
        });
      } catch (err) {
        reject(err);
      }
    });
  }

  /**
   * 开始事务
   */
  beginTransaction(): void {
    this.mStore.beginTransaction();
  }

  /**
   * 提交事务
   */
  commit(): void {
    this.mStore.commit();
  }

  /**
   * 回滚事务
   */
  rollBack(): void {
    this.mStore.rollBack();
  }

  /**
   * 备份数据库
   */
  backup(destName: string): Promise<boolean> {
    return new Promise((resolve) => {
      this.mStore.backup(destName).then(() => {
        console.info('Backup success.');
        resolve(true);
      }).catch((err: BusinessError) => {
        console.error(`Backup failed, code is ${err.code}, message is ${err.message}`);
        resolve(false);
      });
    });
  }

  /**
   * 恢复数据库
   */
  restore(srcName: string): Promise<boolean> {
    return new Promise((resolve) => {
      this.mStore.restore(srcName).then(() => {
        console.info('Restore success.');
        resolve(true);
      }).catch((err: BusinessError) => {
        console.error(`Restore failed, code is ${err.code}, message is ${err.message}`);
        resolve(false);
      });
    });
  }
}

NetWorkUtil.ets http网络请求方法的封装
import { http } from '@kit.NetworkKit';

export class NetWorkUtil {
  private static async request(
    url: string,
    method: http.RequestMethod,
    data?: Object
  ): Promise<string> {
    let httpRequest = http.createHttp();

    return new Promise<string>((resolve, reject) => {
      httpRequest.on('headersReceive', (header: Object) => {
        console.info('响应头: ' + JSON.stringify(header));
      });

      let requestParams: http.HttpRequestOptions = {
        method: method,
        header: {
          // 关键:伪装成浏览器
          'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
          'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
          'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
          'Accept-Encoding': 'gzip, deflate, br',
          'Connection': 'keep-alive',
          'Cache-Control': 'no-cache',
        },
        expectDataType: http.HttpDataType.STRING,
        connectTimeout: 60000,
        readTimeout: 60000
      };

      if (method === http.RequestMethod.POST && data) {
        requestParams.extraData = JSON.stringify(data);
      }

      httpRequest.request(url, requestParams, (err: Error, httpResponse: http.HttpResponse) => {
        if (!err) {
          console.info('响应码: ' + httpResponse.responseCode);
          if (httpResponse.responseCode === 200) {
            // 修复 result 处理
            const result = httpResponse.result;
            const resultStr = Array.isArray(result) ? result.join('') : result.toString();
            console.info('获取成功,数据长度: ' + resultStr.length);
            resolve(resultStr);
          } else {
            console.error('响应码异常: ' + httpResponse.responseCode);
            reject(new Error(`请求失败,响应码: ${httpResponse.responseCode}`));
          }
        } else {
          console.error('请求失败: ' + JSON.stringify(err));
          reject(err);
        }

        httpRequest.off('headersReceive');
        httpRequest.destroy();
      });
    });
  }

  static async get(url: string): Promise<string> {
    return NetWorkUtil.request(url, http.RequestMethod.GET);
  }

  static async post(url: string, data: Object): Promise<string> {
    return NetWorkUtil.request(url, http.RequestMethod.POST, data);
  }
}

SplashPage.ets

import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';

// 日志标签,用于标识当前页面的日志来源
const TAG: string = 'SplashPage';

@Entry
@ComponentV2
struct SplashPage {

  /**
   * 跳转到广告页面
   * 延迟 1 秒后执行路由跳转,用于展示启动页(Splash Screen)效果
   * 跳转失败时通过 hilog 记录错误码和错误信息
   */
  jumpAdPage() {
    setTimeout(() => {
      this.getUIContext().getRouter().replaceUrl(
        { url: 'pages/AdvertisingPage' }  // 目标页面路径:广告页面
      ).catch((err: BusinessError) => {
        // 跳转失败时记录错误日志,包含错误码和错误信息,便于排查问题
        hilog.error(0x0000, TAG, `跳转advertising失败, code is ${err.code}, message is ${err.message}`);
      });
    }, 1000); // 延迟 1000 毫秒(1 秒)
  }

  /**
   * 组件即将出现时的生命周期回调
   * 在此处触发页面跳转逻辑
   */
  aboutToAppear(): void {
    this.jumpAdPage();
  }

  /**
   * 组件即将消失时的生命周期回调
   * 清除定时器,避免页面已销毁但定时器仍触发导致的内存泄漏或异常
   */
  aboutToDisappear(): void {
    clearTimeout(); // 清除所有未执行的定时器任务
  }

  /**
   * 构建页面 UI
   * 启动页布局:居中显示应用图标和标题文字,背景使用蓝色背景图全覆盖
   */
  build() {
    Column() {
      // 应用图标:宽 80vp,等比缩放,内容完全填充容器
      Image($r('app.media.startIcon'))
        .width(80)                     // 图标宽度
        .aspectRatio(1)               // 保持宽高比 1:1
        .objectFit(ImageFit.Contain)  // 图片等比缩放,完整显示在容器内(塞满)

      // 应用标题:"新闻"
      Text('新闻')
        .fontWeight(FontWeight.Bold)   // 字体加粗
        .fontColor(Color.White)        // 白色字体
        .fontSize(40)                  // 字号 40vp
        .letterSpacing(4)              // 字间距 4vp,增强视觉效果
        .margin({ top: 24 })           // 上边距 24vp,与图标保持适当间距
    }
    .width('100%')                     // 容器宽度占满父组件
    .height('100%')                    // 容器高度占满父组件
    .justifyContent(FlexAlign.Center)    // 子组件在主轴(垂直方向)居中
    .alignItems(HorizontalAlign.Center)  // 子组件在交叉轴(水平方向)居中
    .backgroundImage($r('app.media.blueB'))   // 背景图片:蓝色背景图
    .backgroundImageSize(ImageSize.Cover)     // 背景图等比缩放,覆盖整个容器,可能裁剪
  }
}

AdvertisingPage.ets
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';

const TAG: string = 'AdvertisingPage';

@Entry
@ComponentV2
struct AdvertisingPage {
  @Local duration: number = 5;
  private intervalId: number = -1;

  goToHomePage() {
    clearInterval(this.intervalId);
    this.getUIContext().getRouter().replaceUrl({ url: 'pages/Index' }).catch((err: BusinessError) => {
      hilog.error(0X0000,TAG, `跳转Index失败,code is ${err.code}, message is ${err.message}`);
    });
  }

  aboutToAppear(): void {
    this.intervalId = setInterval(() => {
      if (this.duration > 0) {
        this.duration -= 1;
      } else {
        this.goToHomePage();
      }
    },1000)
  }
  build() {
    Column() {
      // ==================== 顶部区域:跳过按钮 ====================
      Row() {
        // 跳过广告按钮,显示倒计时秒数,例如 "跳过 5"
        Text(`跳过 ${this.duration}`) // this.duration 是倒计时秒数,如 5、4、3...
          .fontSize(12)               // 字号12vp
          .fontColor(Color.White)     // 白色文字
          .borderRadius(16)           // 圆角16vp
          .letterSpacing(1)           // 字间距1vp
          .height(36)                 // 高度36vp
          .backgroundColor('rgba(0,0,0,0.20)') // 半透明黑底
          .border({
            color: Color.White,       // 白色边框
            width: 1                  // 边框宽度1vp
          })
          .margin({ top: 36 })        // 距顶部36vp
          .padding(8)                 // 内边距8vp(左右文字留白)
          .onClick(() => this.goToHomePage()) // 点击跳转主页
      }
      .width('90%')                  // 宽度占屏幕90%
      .justifyContent(FlexAlign.End) // 内容靠右对齐

      // ==================== 底部区域:Logo + 应用名 + 描述 ====================
      Row() {
        // 应用图标
        Image($r('app.media.startIcon'))
          .width(56)                  // 宽度56vp
          .height(56)                 // 高度56vp
          .objectFit(ImageFit.Contain) // 保持比例完整显示

        // 应用名称和描述文字,垂直排列,间距4vp
        Column({ space: 4 }) {
          // 应用名称
          Text('新闻')
            .fontFamily('HarmonyHeiTi-Bold') // 鸿蒙黑体加粗(需确保设备支持,否则用默认字体)
            .fontWeight(FontWeight.Bold)     // 加粗
            .fontColor(Color.White)          // 白色(原代码用的是主题主色,这里统一白色更保险)
            .fontSize(26)                    // 字号26vp
            .letterSpacing(1)               // 字间距1vp

          // 应用描述
          Text('你的随身新闻助手')           // 替换成你自己的副标题
            .fontFamily('HarmonyHeiTi')     // 鸿蒙黑体常规
            .fontWeight(FontWeight.Normal)  // 正常字重
            .fontColor(Color.White)         // 白色
            .fontSize(16)                   // 字号16vp
            .letterSpacing(0.34)            // 字间距0.34vp(原值34太大,疑为3.4或0.34,这里取0.34)
            .opacity(0.6)                   // 60%透明度,让文字不那么突兀
        }
        .alignItems(HorizontalAlign.Start)  // 内容左对齐
        .margin({ left: 12 })              // 距离左侧图标12vp
      }
      .height(100)                         // 行高100vp
      .width('100%')                       // 宽度占满
      .justifyContent(FlexAlign.Center)    // 内容居中
    }
    .width('100%')                         // 根布局宽度100%
    .height('100%')                        // 根布局高度100%
    .backgroundImage($r('app.media.blueB'))       // 广告背景图
    .backgroundImagePosition({ x: 0, y: 0 })         // 背景图从左上角(0,0)开始摆放
    .backgroundImageSize({ width: '100%', height: '100%' }) // 背景图拉伸铺满全屏
    .justifyContent(FlexAlign.SpaceBetween)  // 上下两端对齐(跳过按钮在上,Logo在下)
    .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) // 避让系统状态栏和导航栏
  }
}

Index.ets

import {HomePage} from 'feature';

@Entry
@ComponentV2
struct Index {
  build() {
    Column() {
      HomePage();
    }
    .width('100%')
    .height('100%')
  }
}

NewsModel.ets

// NewsModel.ets 类似java的构造对象
export class NewsModel {
  Title: string;
  Url: string;
  HotValue: number;
  Label: string;
  Image: string;

  constructor(Image: string, Title: string, Url: string, HotValue: number, Label: string) {
    this.Image = Image;
    this.Title = Title;
    this.Url = Url;
    this.HotValue = HotValue;
    this.Label = Label;
  }
}

NewsViewModel.ets

import { NetWorkUtil } from 'common'
import { NewsModel } from '../model/NewsModel';

/**
 * 头条新闻图片接口
 * 头条API返回的Image字段为对象类型,而非字符串
 */
interface ToutiaoImage {
  url: string;  // 图片地址
}

/**
 * API 返回的外层响应结构
 * 数据包裹在 data 数组中
 */
interface NewsResponse {
  data: NewsRawData[];  // 新闻原始数据数组
}

/**
 * API 返回的单条新闻原始数据结构
 * 对应后端返回的原始字段(首字母大写)
 */
interface NewsRawData {
  Image: ToutiaoImage;   // 图片对象,包含 url 字段(非字符串类型)
  Title: string;         // 新闻标题
  Url: string;           // 新闻详情链接
  HotValue: number;      // 热度值
  Label: string;         // 标签(如"热"、"新"等)
}

/**
 * 新闻视图模型
 * 负责从网络获取新闻数据、解析JSON、转换为前端使用的数据模型
 */
export class NewsViewModel {
  /** HTML内容(用于某些直接加载网页的场景) */
  html: string = ``;

  /** 网络请求地址 */
  url: string;

  /** 新闻列表(转换为前端使用的数据模型) */
  newsList: NewsModel[] = [];

  /**
   * 构造函数
   * @param url - 网络请求的API地址
   */
  constructor(url: string) {
    this.url = url;
  }

  /**
   * 获取头条热榜新闻列表
   * 从API接口获取JSON数据,解析后转换为NewsModel对象数组
   * 成功时更新 this.newsList,失败时输出错误日志
   */
  async getNews(): Promise<void> {
    try {
      // 发起网络请求获取原始JSON字符串
      const resultStr = await NetWorkUtil.get(this.url)
      console.info(`获得头条热榜成功,数据长度 ${resultStr.length}`);

      // 第一层解析:将JSON字符串转为外层响应对象
      const response: NewsResponse = JSON.parse(resultStr);
      // 提取 data 数组,若为空则使用空数组兜底
      const rawList: NewsRawData[] = response.data || [];

      // 第二层转换:将API原始数据映射为前端使用的NewsModel对象
      // NewsModel 构造参数顺序:Image, Title, Url, HotValue, Label
      this.newsList = rawList.map((item: NewsRawData) => {
        return new NewsModel(
          item.Image?.url || '',   // 从图片对象中提取 url 字段,不存在则返回空字符串
          item.Title || '',        // 新闻标题,兜底为空字符串
          item.Url || '',          // 新闻链接,兜底为空字符串
          item.HotValue || 0,      // 热度值,兜底为 0
          item.Label || ''         // 标签,兜底为空字符串
        );
      });
      console.info(`解析完成,共${this.newsList.length} 条新闻`);

    } catch (err) {
      // 捕获网络请求失败或JSON解析异常
      console.error('获取头条热榜失败: ' + JSON.stringify(err));
    }
  }

  /**
   * 获取网页HTML内容
   * 直接将网络请求结果保存到 this.html(不做JSON解析)
   * 用于需要展示完整网页内容的场景(如WebView加载)
   */
  async getHtml(): Promise<void> {
    try {
      // 直接将返回的HTML字符串赋值给 html 属性
      this.html = await NetWorkUtil.get(this.url);
      console.info(`获得Html成功`);
    } catch (err) {
      console.info(`获得Html失败`);
    }
  }

  /**
   * 清空新闻列表
   * 在页面销毁或需要重置数据时调用
   */
  async setNewList(): Promise<void> {
    this.newsList = [];  // 重置为空数组,释放旧数据引用
  }
}

NewsView.ets

import { NewsViewModel } from '../viewModel/NewsViewModel';
import { NewsModel } from '../model/NewsModel';
import { router } from '@kit.ArkUI'

/**
 * 新闻列表组件
 * 展示某一组新闻(每组5条),支持点击跳转到详情页
 */
@ComponentV2
export struct NewsView {
  /** 外部传入的API请求地址(必传) */
  @Require @Param url: string;

  /** 外部传入的起始索引/页码(必传),决定从第几组开始取数据 */
  @Require @Param startIndex: number;

  /** 当前组件展示的新闻列表(组件内部状态) */
  @Local newsList: NewsModel[] = [];

  /** 新闻视图模型实例,负责网络请求和数据获取 */
  private NewsVM: NewsViewModel = new NewsViewModel(this.url);

  /**
   * 组件即将出现时的生命周期回调
   * 触发数据加载
   */
  aboutToAppear(): void {
    this.loadData();
  }

  /**
   * 加载并分页处理新闻数据
   * 从NewsViewModel获取完整列表后,按 startIndex 截取当前组的数据(每组5条)
   */
  async loadData(): Promise<void> {
    try {
      // 获取完整新闻列表
      await this.NewsVM.getNews();
      const fullList = this.NewsVM.newsList;

      const pageSize = 5;  // 每组固定展示5条新闻
      const start = this.startIndex * pageSize;  // 计算当前组的起始位置

      // 边界检查:起始位置超出数组长度时,返回空列表并警告
      if (start >= fullList.length) {
        console.warn(`startIndex ${this.startIndex} 超出范围,数组长度 ${fullList.length}`);
        this.newsList = [];
        return;
      }

      // 从 start 位置截取最多 pageSize 条数据
      this.newsList = fullList.slice(start, start + pageSize);
      console.info(`第${this.startIndex}组,取第${start}~${start + this.newsList.length - 1}条,共${this.newsList.length}条`);
      // 教学调试日志:打印第一条新闻的URL
      console.info(`教学${this.newsList[start].Url}`);
    } catch (err) {
      console.error(`加载数据失败: ` + JSON.stringify(err));
    }
  }

  /**
   * 构建页面UI
   * 使用List组件展示新闻列表,每条新闻包含缩略图、标题和标签
   */
  build() {
    List() {
      // 循环渲染新闻列表
      ForEach(this.newsList, (item: NewsModel) => {
        ListItem() {
          Row() {
            // 新闻缩略图:80x80,覆盖填充
            Image(item.Image)
              .width(80)                     // 宽度 80vp
              .height(80)                    // 高度 80vp
              .objectFit(ImageFit.Cover)     // 图片等比缩放覆盖容器,可能裁剪

            Column() {
              // 新闻标题:黑色,字号 16vp
              Text(item.Title)
                .fontColor(Color.Black)
                .fontSize(16)

              // 新闻标签(如"热"、"新"等):绿色,字号 10vp
              Text(item.Label)
                .fontColor(Color.Green)
                .fontSize(10)
                .margin({ top: 10 })         // 与标题间距 10vp
            }
            .margin({ left: 10 })            // 与图片间距 10vp
          }
          .onClick(() => {
            // 点击整行跳转到新闻详情页
            console.info('点击了新闻,url:', item.Url);
            try {
              // 使用 router.pushUrl 跳转,携带新闻数据作为路由参数
              router.pushUrl({
                url: 'pages/ContentPage',    // 目标页面路径:内容详情页
                params: {
                  Url: item.Url,             // 新闻链接(用于加载网页内容)
                  Title: item.Title,         // 新闻标题
                  Image: item.Image,         // 新闻图片
                  HotValue: item.HotValue,   // 热度值
                  Label: item.Label          // 标签
                }
              });
              console.info('跳转成功');
            } catch (err) {
              // 跳转失败时记录错误信息,便于排查
              console.error('跳转失败:', JSON.stringify(err));
            }
          })
        }
        .backgroundColor('#FAFAFA')          // 列表项背景色:浅灰
        .align(Alignment.Start)              // 内容左对齐
        .width('100%')                       // 列表项宽度占满
        .margin({ bottom: 10 })              // 列表项底部间距 10vp(项与项之间的间隔)

      })
    }
    .width('100%')                           // 列表容器宽度占满
    .height(400)                             // 列表容器固定高度 400vp
    .margin({ top: 20 })                     // 列表顶部间距 20vp
  }
}

  SavesViewModel.ets

import { RDBHelper } from 'common';
import { NewsModel } from '../model/NewsModel';
import { relationalStore } from '@kit.ArkData';

/** 数据库表名:收藏新闻表 */
const TABLE_NAME = 'saved_news';

/** 建表SQL语句:如果表不存在则创建,url字段设置为UNIQUE防止重复收藏 */
const CREATE_TABLE_SQL = `
  CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (
    id INTEGER PRIMARY KEY AUTOINCREMENT,  -- 自增主键
    title TEXT,                            -- 新闻标题
    url TEXT UNIQUE,                       -- 新闻链接(唯一约束,避免重复收藏)
    hot_value INTEGER,                     -- 热度值
    label TEXT,                            -- 标签(如"热"、"新")
    image TEXT                             -- 图片URL
  )
`;

/**
 * 收藏功能视图模型(单例模式)
 * 负责新闻收藏的增删查操作,底层使用关系型数据库存储
 *
 * 使用方式:
 *   1. 异步初始化:await SavesViewModel.getInstance(context)
 *   2. 同步获取(已初始化后):SavesViewModel.getInstanceSync()
 */
export class SavesViewModel {
  /** 单例实例(静态私有) */
  private static instance: SavesViewModel | null = null;

  /** 数据库操作助手 */
  private helper: RDBHelper | null = null;

  /** 初始化Promise,用于确保只初始化一次 */
  private initPromise: Promise<void> | null = null;

  /**
   * 私有构造函数
   * 外部不能通过 new 创建实例,必须通过 getInstance 获取
   */
  private constructor() {}

  /**
   * 获取单例实例(异步,自动初始化)
   * 首次调用时会初始化数据库连接并创建表
   *
   * @param context - 应用上下文(数据库初始化需要)
   * @returns 初始化完成的 SavesViewModel 实例
   */
  static async getInstance(context: Context): Promise<SavesViewModel> {
    // 如果实例不存在,创建并开始初始化
    if (!SavesViewModel.instance) {
      SavesViewModel.instance = new SavesViewModel();
      SavesViewModel.instance.initPromise = SavesViewModel.instance.init(context);
    }
    // 等待初始化完成(首次调用等待,后续调用立即返回)
    await SavesViewModel.instance.initPromise;
    return SavesViewModel.instance;
  }

  /**
   * 同步获取单例实例(不初始化)
   * 前提:必须已通过 getInstance(context) 初始化过,否则返回 null
   *
   * @returns 已初始化的实例,或 null(未初始化时)
   */
  static getInstanceSync(): SavesViewModel | null {
    return SavesViewModel.instance;
  }

  /**
   * 初始化数据库连接和表结构
   * 创建 news_app.db 数据库,并执行建表语句
   *
   * @param context - 应用上下文
   */
  private async init(context: Context): Promise<void> {
    // 数据库配置
    const config: relationalStore.StoreConfig = {
      name: 'news_app.db',                           // 数据库文件名
      securityLevel: relationalStore.SecurityLevel.S1 // 安全级别 S1(低安全,本地使用)
    };
    // 获取数据库操作助手实例
    this.helper = await RDBHelper.getInstance(context, config);
    // 执行建表语句(IF NOT EXISTS 保证重复执行不会报错)
    await this.helper.executeSql(CREATE_TABLE_SQL);
  }

  /**
   * 添加收藏
   * 将新闻信息插入到 saved_news 表中
   *
   * @param news - 要收藏的新闻数据模型
   * @returns true 表示收藏成功,false 表示收藏失败(通常因为url重复或数据库未初始化)
   */
  async addFavorite(news: NewsModel): Promise<boolean> {
    // 数据库未初始化时返回 false
    if (!this.helper) return false;

    // 将 NewsModel 转换为数据库存储的键值对
    const values: relationalStore.ValuesBucket = {
      'title': news.Title,        // 标题
      'url': news.Url,            // 链接(UNIQUE约束,重复会插入失败)
      'hot_value': news.HotValue, // 热度值
      'label': news.Label,        // 标签
      'image': news.Image         // 图片地址
    };

    // 执行插入操作
    const rowId = await this.helper.insert(TABLE_NAME, values);
    // rowId 为 -1 表示插入失败(如url重复)
    return rowId !== -1;
  }

  /**
   * 取消收藏(按URL删除)
   * 根据新闻链接删除对应的收藏记录
   *
   * @param url - 新闻链接(作为删除条件)
   * @returns true 表示删除成功(至少删除了1条),false 表示未找到或数据库未初始化
   */
  async removeFavorite(url: string): Promise<boolean> {
    // 数据库未初始化时返回 false
    if (!this.helper) return false;

    // 构建删除条件:url 等于指定值
    const predicates = new relationalStore.RdbPredicates(TABLE_NAME);
    predicates.equalTo('url', url);  // WHERE url = ?

    // 执行删除操作
    const rows = await this.helper.delete(predicates);
    // rows > 0 表示至少删除了1条记录
    return rows > 0;
  }

  /**
   * 获取全部收藏列表
   * 查询 saved_news 表中所有记录,转换为 NewsModel 数组
   *
   * @returns 收藏的新闻列表(数据库未初始化或查询失败时返回空数组)
   */
  async getFavorites(): Promise<NewsModel[]> {
    // 数据库未初始化时返回空数组
    if (!this.helper) return [];

    // 构建查询条件:查询全部数据(无过滤条件)
    const predicates = new relationalStore.RdbPredicates(TABLE_NAME);

    // 执行查询,返回结果集
    const resultSet = await this.helper.query(predicates);
    const newsList: NewsModel[] = [];

    try {
      // 遍历结果集的每一行
      while (resultSet.goToNextRow()) {
        // 将数据库行数据转换为 NewsModel 对象
        // 构造参数顺序:Image, Title, Url, HotValue, Label
        newsList.push(new NewsModel(
          resultSet.getString(resultSet.getColumnIndex('image')),      // 图片URL
          resultSet.getString(resultSet.getColumnIndex('title')),      // 标题
          resultSet.getString(resultSet.getColumnIndex('url')),        // 链接
          resultSet.getLong(resultSet.getColumnIndex('hot_value')),    // 热度值
          resultSet.getString(resultSet.getColumnIndex('label'))       // 标签
        ));
      }
    } catch (error) {
      // 遍历或转换过程中出错时,记录日志(TODO:可添加 hilog 记录)
      // TODO: Implement error handling.
    }

    // 关闭结果集,释放资源(重要!避免内存泄漏)
    resultSet.close();
    return newsList;
  }

  /**
   * 判断某条新闻是否已收藏
   * 根据URL查询 saved_news 表中是否存在对应记录
   *
   * @param url - 新闻链接
   * @returns true 表示已收藏,false 表示未收藏或数据库未初始化
   */
  async isFavorite(url: string): Promise<boolean> {
    // 数据库未初始化时返回 false
    if (!this.helper) return false;

    // 构建查询条件:url 等于指定值
    const predicates = new relationalStore.RdbPredicates(TABLE_NAME);
    predicates.equalTo('url', url);  // WHERE url = ?

    // 执行查询
    const resultSet = await this.helper.query(predicates);
    // 获取结果行数
    const count = resultSet.rowCount;
    // 关闭结果集
    resultSet.close();

    // 行数大于0表示已收藏
    return count > 0;
  }
}

SavesView.ets

import { NewsModel } from '../model/NewsModel';
import { SavesViewModel } from '../viewModel/SavesViewModel';
import { router } from '@kit.ArkUI';

/**
 * 收藏列表组件
 * 展示用户收藏的新闻列表,支持定时刷新、点击查看详情、删除收藏
 */
@ComponentV2
export struct SavesView {
  /** 收藏新闻列表数据 */
  @Local newsList: NewsModel[] = [];

  /** 加载状态标识:true 表示正在加载中 */
  @Local isLoading: boolean = true;

  /** 定时器ID,用于定时刷新列表,-1 表示未启动 */
  private timer: number = -1;

  /**
   * 组件即将出现时的生命周期回调
   * 首次加载数据并启动定时刷新
   */
  aboutToAppear(): void {
    this.loadData();      // 首次加载收藏数据
    this.startPolling();  // 启动定时轮询(每2秒刷新一次)
  }

  /**
   * 组件即将消失时的生命周期回调
   * 停止定时刷新,避免页面销毁后仍执行无效请求
   */
  aboutToDisappear(): void {
    this.stopPolling();   // 离开页面时停止定时器
  }

  /**
   * 启动定时轮询
   * 每2秒自动刷新一次收藏列表,用于同步其他页面的收藏变更
   */
  startPolling(): void {
    this.timer = setInterval(() => {
      this.loadData();  // 定时刷新列表
    }, 2000);            // 间隔 2000 毫秒(2 秒)
  }

  /**
   * 停止定时轮询
   * 清除定时器并重置 timer 为 -1
   */
  stopPolling(): void {
    // 检查定时器是否已启动(timer !== -1 表示已启动)
    if (this.timer !== -1) {
      clearInterval(this.timer);  // 清除定时器
      this.timer = -1;            // 重置标识为未启动状态
    }
  }

  /**
   * 加载收藏数据
   * 从 SavesViewModel 获取全部收藏列表,更新到 newsList
   */
  async loadData(): Promise<void> {
    try {
      // 同步获取 SavesViewModel 单例(前提:应用启动时已初始化)
      const savesVM = SavesViewModel.getInstanceSync();
      if (savesVM) {
        // 从数据库查询全部收藏数据
        this.newsList = await savesVM.getFavorites();
        console.info(`加载收藏数据,共 ${this.newsList.length} 条`);
      }
    } catch (err) {
      console.error(`加载收藏失败: ` + JSON.stringify(err));
    } finally {
      // 无论成功或失败,加载完成后都将 isLoading 设为 false
      this.isLoading = false;
    }
  }

  /**
   * 构建页面UI
   * 根据状态显示不同内容:
   *   1. isLoading=true → 加载中提示
   *   2. newsList为空   → 空状态提示
   *   3. 有数据         → 收藏列表
   */
  build() {
    // 状态一:正在加载中
    if (this.isLoading) {
      Column() {
        LoadingProgress().width(40).height(40)          // 加载动画
        Text('加载中...').fontSize(14).fontColor('#999').margin({ top: 10 })  // 加载提示文字
      }
      .width('100%').height(400).justifyContent(FlexAlign.Center)  // 垂直居中显示

    // 状态二:数据为空(无收藏)
    } else if (this.newsList.length === 0) {
      Column() {
        Text('暂无收藏')
          .fontSize(16)
          .fontColor('#999')
      }
      .width('100%').height(400).justifyContent(FlexAlign.Center)  // 垂直居中显示

    // 状态三:有收藏数据,展示列表
    } else {
      List() {
        // 循环渲染每条收藏新闻
        ForEach(this.newsList, (item: NewsModel) => {
          ListItem() {
            Row() {
              // 新闻缩略图:80x80,覆盖填充
              Image(item.Image)
                .width(80)
                .height(80)
                .objectFit(ImageFit.Cover)  // 图片等比缩放覆盖容器

              Column() {
                // 新闻标题:黑色,字号16,最多显示2行,超出省略号
                Text(item.Title)
                  .fontColor(Color.Black)
                  .fontSize(16)
                  .maxLines(2)                                // 限制最多2行
                  .textOverflow({ overflow: TextOverflow.Ellipsis })  // 超出部分显示省略号

                // 新闻标签(如"热"、"新"):绿色,字号10
                Text(item.Label)
                  .fontColor(Color.Green)
                  .fontSize(10)
                  .margin({ top: 10 })  // 与标题间距10vp
              }
              .margin({ left: 10 })   // 与图片间距10vp
              .layoutWeight(1)         // 占据剩余空间,将删除按钮挤到最右侧

              // 删除收藏按钮
              Button('删除')
                .fontSize(12)
                .fontColor(Color.White)
                .backgroundColor('#FF4444')  // 红色背景
                .borderRadius(4)              // 圆角4vp
                .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                .margin({ left: 10 })         // 与文字区域间距10vp
                .onClick(async () => {
                  // 点击删除:从数据库移除,并同步更新本地列表
                  const savesVM = SavesViewModel.getInstanceSync();
                  if (savesVM) {
                    await savesVM.removeFavorite(item.Url);  // 从数据库删除
                    // 从本地列表中过滤掉当前项,实现即时UI更新(无需等待下次轮询)
                    this.newsList = this.newsList.filter(n => n.Url !== item.Url);
                    console.info('删除收藏成功');
                  }
                })
            }
            // 点击整行跳转到新闻详情页
            .onClick(() => {
              router.pushUrl({
                url: 'pages/ContentPage',
                params: {
                  Url: item.Url,         // 新闻链接
                  Title: item.Title,     // 新闻标题
                  Image: item.Image,     // 新闻图片
                  HotValue: item.HotValue, // 热度值
                  Label: item.Label      // 标签
                }
              });
            })
          }
          .backgroundColor('#FAFAFA')    // 列表项背景色:浅灰
          .align(Alignment.Start)        // 内容左对齐
          .width('100%')                 // 列表项宽度占满
          .margin({ bottom: 10 })        // 列表项底部间距10vp
        })
      }
      .width('100%')
      .height(400)       // 列表固定高度400vp
      .margin({ top: 20 })  // 列表顶部间距20vp
    }
  }
}

 homePage.ets   主页面UI设计

import { SavesView } from '../view/SavesView';
import { NewsView } from '../view/NewsView';
@Entry
@ComponentV2
export struct HomePage {
  @Local currentIndex: number = 0;
  private tabList: string[] = ['收藏', '第一页', '第二页', '第三页', '第四页', '第五页', '第六页', '第七页', '第八页', '第九页', '第十页'];

  aboutToAppear(): void {

  }

  build() {
    Tabs({
      barPosition: BarPosition.Start,
      index: this.currentIndex
    }) {
      ForEach(this.tabList,(name: string,index: number) => {
        TabContent() {
          if (index === 0) {
            SavesView();
          } else {
            NewsView({
              url: "https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc",
              startIndex: index-1
            });
          }

        }
        .tabBar(name)
      })
    }
    .barMode(BarMode.Scrollable)
    .onChange((index: number) => {
      this.currentIndex = index;
    })
    .width('100%')
    .height('100%')
  }
}

 ContentPage.ets

// ContentPage.ets  显示点击的新闻内容,还有收藏功能
import { router } from '@kit.ArkUI';
import { webview } from '@kit.ArkWeb';
import { NewsModel } from 'feature';

import { SavesViewModel } from 'feature';



@Entry
@ComponentV2
struct ContentPage {
  @Local pageUrl: string = '';
  @Local saveItem: NewsModel = new NewsModel(``,``,``,0,``);


  aboutToAppear(): void {
    const params = router.getParams() as Record<string, string>;
    this.pageUrl = params['Url'] || '';
    this.saveItem.Url = params['Url'] || '';
    this.saveItem.Title = params['Title'] || '';
    this.saveItem.HotValue = Number(params['HotValue']) || 0;
    this.saveItem.Image = params['Image'] || '';
    this.saveItem.Label = params['Label'] || '';
    console.info(`saveItem ${this.saveItem.Title}`)
  }

  build() {
    Column() {
      Row() {
        Button('← 返回')
          .fontSize(16)
          .backgroundColor('#FAFAFA')
          .fontColor(Color.Black)
          .onClick(() => router.back())

        Button('收藏')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .layoutWeight(1)
          .onClick(async () => {
            const savesVM = SavesViewModel.getInstanceSync();
            if (savesVM) {
              await savesVM.addFavorite(this.saveItem);

            }
          })

      }
      .width('100%')
      .padding(15)

      // Web 组件直接加载网页
      Web({ src: this.pageUrl, controller: new webview.WebviewController() })
        .width('100%')
        .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
  }
}

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐