#HarmonyOS 鸿蒙Next 体验官#开发一个灵感速记APP
#HarmonyOS 鸿蒙Next 体验官#开发一个灵感速记APP
<markdown _ngcontent-scm-c237="" class="markdownPreContainer">
1 简介
生活中的零碎信息太容易忘记,「灵感速记」作为记录琐碎的小帮手,时刻记录用户关心的内容,分类整理,高效编辑,快捷分享。Less is More,借助HarmonyOS NEXT丰富的原生能力,一步操作完成各种所想,抓住每一刻灵感。 视频简介:灵感速记https://ost.51cto.com/show/29442
- 目标用户:学生、记者、产品经理、艺术创作者
本篇将介绍如何使用HarmonyOS NEXT原生能力开发灵感速记APP。效果为:
- 化繁为简,便捷高效
- 聊天式一键添加备忘、关联日程、强提醒消息、私密消息等轻量笔记。
- 可分类、搜索、分享、删除等管理记录的每条内容。
- 统一生态,有趣好用:
- 可共享消息到生态互联设备(灵动看板)。
- 支持多端布局。
2 环境搭建
我们首先需要完成HarmonyOS开发环境搭建,可参照如下步骤进行。
软件要求
- DevEco Studio版本:DevEco Studio NEXT Developer Preview2及以上。
- HarmonyOS SDK版本:HarmonyOS NEXT Developer Preview2 SDK及以上。
硬件要求
- 设备类型:华为手机。
- HarmonyOS系统:HarmonyOS NEXT Developer Preview2及以上。
环境搭建
- 安装DevEco Studio,详情请参考下载和安装软件。
- 设置DevEco Studio开发环境,DevEco Studio开发环境需要依赖于网络环境,需要连接上网络才能确保工具的正常使用,详情请参考配置开发环境。
- 开发者可以参考以下链接,完成设备调试的相关配置:
3 代码结构解读
本篇文档只对核心代码进行讲解,对于完整代码,开源后提供下载链接。
.
|-- common
| |-- BreakPointSystem.ets //断点-多端布局
| |-- CommonConstant.ets //常量
| |-- DateUtil.ets //时间日期工具
| |-- InspiDataModule.ets //灵感数据模型
| |-- Logger.ets //日志工具
| |-- calendar.ets //日历日程工具
| |-- database //RDB数据库,实现持久化存储
| | |-- InspirationTable.ets
| | |-- RDB.ets
| | `-- inspirationData.ets
| |-- messageManager //后台提醒、日程消息
| | |-- BackgroundReminder.ets
| | `-- CalenderMeaasge.ets
| |-- microphone //语音输入
| | `-- Recorder.ets
| `-- shareTool.ets //分享工具
|-- entryability
| `-- EntryAbility.ts
|-- entryformability
| `-- EntryFormAbility.ets
|-- pages
| |-- Index.ets //入口页面
| `-- SettingPage.ets //设置页面
|-- subview
| |-- AddPanel.ets //添加笔记组件
| |-- InspiComponent.ets //笔记列表组件
| |-- InspirationItemView.ets //单条笔记组件
| |-- InspirationTotalView.ets//笔记分类视图
`-- widget22
`-- pages
`-- Widget22Card.ets //2*2桌面卡片
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 4px; right: 8px; font-size: 14px;">复制</button>
4 构建应用主界面
灵感速记应用程序遵循多端、极简页面、功能一触即达的设计理念。以手机端为例,如下图所示,首页布局了95%的功能,自下而上依次是添加和共享笔记、笔记查看、管理笔记的三大功能区。
来分配布局,例如在手机端首页只展示消息列表视图,在折叠屏或平板上屏幕右侧新增了分类视图。整体的布局框架如下:
build() {
Stack() {
Row() {
Column() {
// menu
Row(){ ... }
// inspiration内容
// 配合List组件,循环渲染,可滚动内容
List({ space: 6, scroller: this.listScroller }) {
ForEach(this.Inspirations : this.searchInspirations,
(item: InspirationData, index: number) => {
ListItem() {
InspirationItemView({
InspiItem: item,
...
})
}
<span class="hljs-keyword"><span class="hljs-keyword">if</span></span> (!<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.isSearchPageShow && !<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.isInsert && !<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.isEditMode) {
<span class="hljs-comment"><span class="hljs-comment">// 添加新内容+按钮</span></span>
Image($r(<span class="hljs-string"><span class="hljs-string">'app.media.add_filled'</span></span>))
<span class="hljs-comment"><span class="hljs-comment">// 共享到灵动看板桌面 弹窗</span></span>
Image($r(<span class="hljs-string"><span class="hljs-string">'app.media.panel_icon'</span></span>))
.onClick(() => {
<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.isShowDeskPanel = <span class="hljs-literal"><span class="hljs-literal">true</span></span>
})
}
.width(<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.MycurrentBreakpoint != <span class="hljs-string"><span class="hljs-string">'sm'</span></span> ? <span class="hljs-string"><span class="hljs-string">'50%'</span></span> : <span class="hljs-string"><span class="hljs-string">'100%'</span></span>)
<span class="hljs-comment"><span class="hljs-comment">// 断点来实现多端布局</span></span>
<span class="hljs-keyword"><span class="hljs-keyword">if</span></span> (<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.MycurrentBreakpoint != <span class="hljs-string"><span class="hljs-string">'sm'</span></span>) {
Column() {
<span class="hljs-comment"><span class="hljs-comment">// 内容按颜色整理的详情页</span></span>
InspirationTotalView({
Inspirations: <span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.Inspirations
})
}
.width(<span class="hljs-string"><span class="hljs-string">'50%'</span></span>)
.height(<span class="hljs-string"><span class="hljs-string">'100%'</span></span>)
}
}.width(<span class="hljs-string"><span class="hljs-string">'100%'</span></span>).height(<span class="hljs-string"><span class="hljs-string">'100%'</span></span>)
}
}
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 4px; right: 8px; font-size: 14px;">复制</button>
5 笔记数据管理
数据定义
应用开发与数据结构息息相关,在进行其他功能开发前,规划如下灵感速记的数据,主要包含笔记文本内容、记录时间、类别、完成情况等。
export default class InspirationData {
id: number = -1;
inspirationName: string = '';
updateTime: string = '';
progressValue: number = 0;
inspirationType:number = InspirationType.CREATE_IDEA;
inspirationColor:string = CommonConstants.CREATE_COLOR;
inspirationDone: boolean = false;
inspirationAlarm: boolean = false;
inspirationPic: string = '';
inspirationPicIndex: number = 0;
inspirationIcon: string ='app.media.create_idea_filled';
constructor() {
...
}
}
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 4px; right: 8px; font-size: 14px;">复制</button>
数据持久化
用户记录的笔记希望能持久化保存,便于用户随时查找翻阅笔记。 HarmonyOS NEXT中的ArkData (方舟数据管理)为开发者提供数据存储、数据管理和数据同步能力。其中,数据存储提供通用数据持久化能力,根据数据特点,分为用户首选项、键值型数据库和关系型数据库。灵感速记的数据较为复杂,选择关系型数据库(RDB)进行存储。
- 首先获取一个RdbStore,其中包括建库、建表、升降级等操作。
import { relationalStore } from '@kit.ArkData';
export default class Rdb {
private rdbStore: relationalStore.RdbStore | null = null;
private tableName: string;
private sqlCreateTable: string;
private columns: Array<string>;
constructor(tableName: string, sqlCreateTable: string, columns: Array<string>) {
this.tableName = tableName;
this.sqlCreateTable = sqlCreateTable;
this.columns = columns;
}
getRdbStore(callback: Function = () => {
}) {
let context: Context = getContext(this) as Context;
relationalStore.getRdbStore( AppStorage.get(‘context’), CommonConstants.STORE_CONFIG, (err, rdb) => {
…
this.rdbStore = rdb;
this.rdbStore.executeSql(this.sqlCreateTable);
});
}
insertData(data: relationalStore.ValuesBucket, callback: Function = () => {
}) {
<span class="hljs-keyword"><span class="hljs-keyword">let</span></span> resFlag: boolean = <span class="hljs-literal"><span class="hljs-literal">false</span></span>;
<span class="hljs-keyword"><span class="hljs-keyword">const</span></span> valueBucket: relationalStore.ValuesBucket = data;
<span class="hljs-keyword"><span class="hljs-keyword">if</span></span> (<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.rdbStore) {
<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.rdbStore.insert(<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.tableName, valueBucket, (err, ret) => {
...
});
}
}
deleteData(predicates: relationalStore.RdbPredicates, callback: Function = () => {
}) {
let resFlag: boolean = false;
if (this.rdbStore) {
this.rdbStore.delete(predicates, (err, ret) => {
…
});
}
}
updateData(predicates: relationalStore.RdbPredicates, data: relationalStore.ValuesBucket, callback: Function = () => {
}) {
<span class="hljs-keyword"><span class="hljs-keyword">let</span></span> resFlag: boolean = <span class="hljs-literal"><span class="hljs-literal">false</span></span>;
<span class="hljs-keyword"><span class="hljs-keyword">const</span></span> valueBucket: relationalStore.ValuesBucket = data;
<span class="hljs-keyword"><span class="hljs-keyword">if</span></span> (<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.rdbStore) {
<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.rdbStore.update(valueBucket, predicates, (err, ret) => {
...
});
}
}
query(predicates: relationalStore.RdbPredicates, callback: Function = () => {
}) {
<span class="hljs-keyword"><span class="hljs-keyword">if</span></span> (<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.rdbStore) {
<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.rdbStore.query(predicates, <span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.columns, (err, resultSet) => {
..
});
}
}
}
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 4px; right: 8px; font-size: 14px;">复制</button>
- 其次创建灵感速记笔记的RDB数据表,赋予数据的增删改查能力。
import { relationalStore } from '@kit.ArkData';
import Rdb from './RDB';
export class InspirationTable {
private accountTable = new Rdb(CommonConstants.INSPIRATION_TABLE.tableName, CommonConstants.INSPIRATION_TABLE.sqlCreate,
CommonConstants.INSPIRATION_TABLE.columns);
constructor(callback: Function = () => {
}) {
this.accountTable.getRdbStore(callback);
}
getRdbStore(callback: Function = () => {
}) {
this.accountTable.getRdbStore(callback);
}
insertData(inspiration: InspirationData, callback: Function) {
…
this.accountTable.insertData(valueBucket, callback);
}
deleteData(inspiration: InspirationData, callback: Function) {
…
this.accountTable.deleteData(predicates, callback);
}
updateData(inspiration: InspirationData, callback: Function) {
…
this.accountTable.updateData(predicates, valueBucket, callback);
}
InspirationTable()
export default new InspirationTable() ;
function generateBucket(inspiration: InspirationData): relationalStore.ValuesBucket {
let obj: relationalStore.ValuesBucket = {};
obj.inspirationName = inspiration.inspirationName;
obj.updateTime = inspiration.updateTime;
…
return obj;
}
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 4px; right: 8px; font-size: 14px;">复制</button>
6 笔记功能开发
展示的每条笔记都是一个List元素,默认是一条高度固定的消息条。在用户单击后Item后,通过条件渲染,启用如下编辑选项:选择类别、可编辑内容、复制与华为分享、插入图片。
- 选择类别 每种笔记类别都有专属的图标和颜色,根据用户的选择实时渲染整条List Item,如下所示为用户选择默认紫色灵感类别时,临时渲染的内容做相应的渲染,当用户选择√后,再做持久化保存。
// idea type button
if (this.isExpanded) {
Row({ space: 10 }) {
Button({ type: ButtonType.Circle, stateEffect: true }) {
Image($r(CommonConstants.CREATE_IMG))
.fancyImage(20)
}.fancyButton(CommonConstants.CREATE_COLOR)
.onClick(() => {
this.InspiItem.inspirationColor = CommonConstants.CREATE_COLOR
this.InspiItem.inspirationType = InspirationType.CREATE_IDEA
this.InspiItem.inspirationIcon = CommonConstants.CREATE_IMG
})
...
}.justifyContent(FlexAlign.Center).width(this.edite_width)
.position({ x: 0, y: -15 })
}
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 4px; right: 8px; font-size: 14px;">复制</button>
如果用户选择了强提醒类别,会触发通知时间设置,到点自动发送通知和震动响铃。
if (this.InspiItem.inspirationType === InspirationType.ATTENTION_IDEA) {
DatePickerDialog.show({
start: this.startDate,
end: new Date("2100-12-31"),
selected: this.selectedDate,
showTime: true,
useMilitaryTime: true,
disappearTextStyle: {
color: CommonConstants.ATTENTION_COLOR,
font: { size: '14fp', weight: FontWeight.Bold }
},
textStyle: {
color: CommonConstants.ATTENTION_COLOR,
font: { size: '18fp', weight: FontWeight.Normal }
},
selectedTextStyle: { color: '#ff182431', font: { size: '22fp', weight: FontWeight.Regular } },
onDateAccept: (value: Date) => {
// 通过Date的setFullYear方法设置按下确定按钮时的日期,这样当弹窗再次弹出时显示选中的是上一次确定的日期
this.selectedDate = value
BackgroundReminder.publishReminder(this.InspiItem, value);
if (this.updateItem !== undefined) {
console.info('inspitest-点击修改内容')
this.updateItem(this.isInsert, this.InspiItem)
this.isInsert = false
}
// this.isExpanded = false;
this.animationClick(false);
}
}
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 4px; right: 8px; font-size: 14px;">复制</button>
如果用户选择了日程类别,会触发日程时间选择,自动关联到系统日历。
else if (this.InspiItem.inspirationType === InspirationType.TODO_IDEA) {
CalendarPickerDialog.show({
selected: this.selectedDate,
onAccept: (value) => {
CalenderMessage.publishCalenderEvent(this.InspiItem, value);
if (this.updateItem !== undefined) {
console.info('inspitest-点击修改内容')
this.updateItem(this.isInsert, this.InspiItem)
this.isInsert = false
}
// this.isExpanded = false;
this.animationClick(false);
console.info("calendar onAccept:" + JSON.stringify(value))
}
})
}
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 4px; right: 8px; font-size: 14px;">复制</button>
- 可编辑内容 笔记内容包括最近编辑时间(Text)、可编辑笔记文本(InpuText)、选择的图片(Image)。这部分功能使用基础组件即可实现。
- 复制、华为分享
- 使用pasteboard接口实现复制。
// copy
Column() {
Image($r('app.media.ic_public_copy')).fancyImage(25)
}
.width('20%')
.onClick(() => {
if (this.TempInspiName === '') {
promptAction.showToast({
message: '内容为空',
duration: 1500
})
return;
}
let text: string = this.TempInspiName;
let pasteData: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
let systemPasteBoard: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
systemPasteBoard.setData(pasteData).catch((err: BusinessError) => {
console.error(`Failed to set pastedata. Code: ${err.code}, message: ${err.message}`);
});
let subText = this.TempInspiName.substring(0, 4);
if (subText.length < this.TempInspiName.length) { //只提示部分内容
subText = subText + '...'
}
})
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 4px; right: 8px; font-size: 14px;">复制</button>
- 使用systemShare接口即可实现华为分享。
// share
Column() {
Image($r('app.media.ic_public_share')).fancyImage(25)
}.width('20%')
.onClick(() => {
let ShareDta: systemShare.SharedData = new systemShare.SharedData({
utd: utd.UniformDataType.PLAIN_TEXT,
content: this.TempInspiName
});
// 构建ShareController
let ShareController: systemShare.ShareController = new systemShare.ShareController(ShareDta);
// 获取UIAbility上下文对象
let ShareContext: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
// 注册分享面板关闭监听
ShareController.on('dismiss', () => {
console.log('Share panel closed');
// 分享结束,可处理其他业务。
});
// 进行分享面板显示
ShareController.show(ShareContext, {
previewMode: systemShare.SharePreviewMode.DETAIL,
selectionMode: systemShare.SelectionMode.SINGLE
});
})
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 4px; right: 8px; font-size: 14px;">复制</button>
- 插入图片 通过picker.PhotoViewPicker访问用户相机和相册,保护用户隐私,使用也快捷方便。
//add camera or photoPicker
Column() {
Image($r('app.media.ic_public_camera'))
.fancyImage(25)
}
.width('16%')
.onClick(() => {
if (this.TempInspiPicIndex < 5) {
const photoSelectOptions = new picker.PhotoSelectOptions();
photoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
photoSelectOptions.maxSelectNumber = 5 - this.TempInspiPicIndex;
const photoViewPicker = new picker.PhotoViewPicker();
//调用select()接口拉起图库界面进行文件选择,文件选择成功后,返回PhotoSelectResult结果集
photoViewPicker.select(photoSelectOptions)
.then(async (photoSelectResult: picker.PhotoSelectResult) => {
//用一个全局变量存储返回的uri
this.selectedImage = photoSelectResult.photoUris
for (let i = 0; i < photoSelectResult.photoUris.length; i += 1) {
this.TempInspiPic += photoSelectResult.photoUris[i] + '|';
this.TempInspiPicIndex += 1;
}
console.info('photoViewPicker.select to file succeed and uris are:' + this.TempInspiPic);
})
.catch((err: BusinessError) => {
console.error(`Invoke photoViewPicker.select failed, code is ${err.code}, message is ${err.message}`);
})
} else {
promptAction.showToast({
message: '图片超出限制',
duration: 1500
})
}
})
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 4px; right: 8px; font-size: 14px;">复制</button>
通过开发上述编辑选项功能,实现的内容编辑效果如下图所示:
7 动效开发
APP的开发离不开动画,HarmonyOS NEXT ArkUI中提供多种动画接口(属性动画、转场动画等),灵感速记中使用了转场动画、弹簧效果等动效,给用户舒适的视觉体验。 通过基础的组件转场接口transition与TransitionEffect的组合使用,定义出一镜到底的操作效果。如笔记内容在展开或收起时,加入下动画修饰:
.transition(TransitionEffect.asymmetric(
TransitionEffect.opacity(0)
.animation({ curve: curves.cubicBezierCurve(0.33, 0, 0.67, 1), duration: 200, delay: 150 }),
TransitionEffect.opacity(0)
.animation({ curve: curves.cubicBezierCurve(0.33, 0, 0.67, 1), duration: 200 }),
))
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 4px; right: 8px; font-size: 14px;">复制</button>
笔记展开、收起动画效果立竿见影:
8 共享消息与灵动看板
灵感速记的共享消息功能,是将APP记录的内容分享到生态硬件(灵动看板),在灵动开板上点击完成,APP也有相应的记录。共享消息功能将笔记“用起来”,挖掘更多应用场景。共享消息功能主要使用了IoT技术实现,技术思路如下图:
在APP内置了本地html网页,用于实现配置mqtt参数以及收发数据,该过程涉及应用侧与前端页面的交互,HarmonyOS NEXT的ArkWeb(方舟Web)提供了能力运行前端JavaScript函数、数据双向传输等能力。 这里讲解如何实现html与ArkTS之间数据交互。 首先,在启动app时,要在Index.ets的aboutToAppear()中创建一个和H5页面通信的消息通道,实现如下:
TryWebPort(): void {
try {
// 1、创建两个消息端口。
this.ports = this.webviewController.createWebMessagePorts();
// 2、在应用侧的消息端口(如端口1)上注册回调事件。
this.ports[1].onMessageEvent((result: web_webview.WebMessage) => {
let msg = 'Got msg from HTML:';
...
})
<span class="hljs-comment"><span class="hljs-comment">// 3、将另一个消息端口(如端口0)发送到HTML侧,由HTML侧保存并使用。</span></span>
<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.webviewController.postMessage(<span class="hljs-string"><span class="hljs-string">'__init_port__'</span></span>, [<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.ports[<span class="hljs-number"><span class="hljs-number">0</span></span>]], <span class="hljs-string"><span class="hljs-string">'*'</span></span>);
} <span class="hljs-keyword"><span class="hljs-keyword">catch</span></span> (error) {
promptAction.showToast({duration:<span class="hljs-number"><span class="hljs-number">2000</span></span>,message:<span class="hljs-string"><span class="hljs-string">'发送失败'</span></span>})
<span class="hljs-keyword"><span class="hljs-keyword">let</span></span> e: business_error.BusinessError = error as business_error.BusinessError;
console.error(`ErrorCode: ${e.code}, Message: ${e.message}`);
}
}
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 4px; right: 8px; font-size: 14px;">复制</button>
其次,需要在本地src/main/resources/rawfile/index.html 中创建一个用于接收的监听端口,具体实现如下:
// 页面
var h5Port;
var output = document.querySelector('.output');
window.addEventListener('message', function (event) {
if (event.data === '__init_port__') {
if (event.ports[0] !== null) {
h5Port = event.ports[0]; // 1. 保存从ets侧发送过来的端口
h5Port.onmessage = function (event) {
// 2. 接收ets侧发送过来的消息.
var msg = 'Got message from ets:';
var result = event.data;
if (typeof(result) === 'string') {
console.info(`received string message from html5, string is: ${result}`);
msg = result;
} else if (typeof(result) === 'object') {
if (result instanceof ArrayBuffer) {
console.info(`received arraybuffer from html5, length is: ${result.byteLength}`);
msg = msg + 'lenght is ' + result.byteLength;
} else {
console.info('not support');
}
} else {
console.info('not support');
}
// this.PositionName = msg.toString();
}
}
}
})
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 4px; right: 8px; font-size: 14px;">复制</button>
本地的H5可以通过与ets建立的消息通道,直接发送数据到用户页面,这个通道也可以用来接收H5发送回来的数据.
// 使用h5Port往ets侧发送消息.
function PostMsgToEts(data) {
console.info('H5 to Ets data:'+data);
if (h5Port) {
h5Port.postMessage(data);
} else {
console.error('h5Port is null, Please initialize first');
}
}
// 调用接口发送数据到ets用户页面,便于存储和展示
this.PostMsgToEts(jsonObjTaskNumber.toString()+jsonObjTaskChecked.toString());
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 4px; right: 8px; font-size: 14px;">复制</button>
</markdown>关于#HarmonyOS 鸿蒙Next 体验官#开发一个灵感速记APP的问题,您也可以访问:https://www.itying.com/category-93-b0.html 联系官网客服。
期待HarmonyOS能在未来带来更多创新的技术和理念。