用鸿蒙ArkTs制作仿新闻应用
·
这个应用主要用了主要用了基本的ArkUI,MVVM架构,模块化设计,异步,单例模式,还封装了关系型数据库方法类还有 Http网络请求方法类。
当时我遇到的一个困境就是代码很乱很乱,所以我查官方文档,用模块化设计还有MVVM架构来重写代码。第二个困境就是不知道怎么获得网络的新闻数据,我的尝试是爬虫,结果发现代码量大,而且新闻数据时常变化,又是视频又是文本的数据很难爬取,所以我就想着能否直接把网页html数据投影到UI界面,所以我先获取今日头条的免费api的json,然后用web组件直接加载网页。
这是运行视频
源码我资源绑定了,而且我用的编程软件是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%')
}
}
更多推荐


所有评论(0)