HarmonyOS NEXT 初级
快捷键:
- 两次Shift启动快速查找
- Option+Command+L: 格式化
- Option+Shift+Enter: 自动修复、自动补全
//@formatter:off/on语句对可以声明不要格式化
- Command+7:打开代码结构树
- Control+H: 查看接口/类的层次结构
- Option+F7: 后点击不同图标可查看变量赋值位置和引用位置
- 输入
/**+回车键,快速生成注释信息。- C++文件支持用
“//!+回车键生成注释信息
- C++文件支持用
- Control+Option+O可以整理imports(比如清除和排序) ->
Optimize ImportsSettings > Editor > Code Style > Imports设置优化时是否需合并来自同一模块的import
- Ctrl+O: Override Methods
- Command+N:
- Constructor: 生成构造器
- Getter and Setter: 生成读写方法
- Declarations: 按住Ctrl+选中项。即可在模块的Index.ets文件中批量生成相应的声明信息。
Random data
import { util } from '@kit.ArkTS';
let key: string = util.generateRandomUUID(true);
let key2: string = Math.random().toString(36).substr(2, 9);
依赖机制
同node.js一样,用package.json管理(具体是oh-package.json5):
{
"dependencies": {
"my-utils": "file:../my_utils" // 指向同工程的 HAR 模块
"@ohos/lodash": "^1.0.0" // 远程ohpm包
}
}
- 对于普通模块,路径即依赖,按路径
import即能发现 - 对于
har包,hsp包,需要先声明依赖,引用的时候,直接用包名引用,而非路径
Context
文件路径
基类Context提供了获取应用文件路径的能力,ApplicationContext、AbilityStageContext、UIAbilityContext和ExtensionContext均继承该能力。
ApplicationContext可以获取应用级的文件路径。该路径用于存放应用全局信息,路径下的文件会跟随应用的卸载而删除。UIAbilityContext:可以获取UIAbility所在Module的文件路径。ExtensionContext:可以获取ExtensionAbility所在Module的文件路径。AbilityStageContext:由于AbilityStageContext创建时机早于UIAbilityContext和ExtensionContext,通常用于在AbilityStage中获取文件路径。
路由
import { BusinessError } from '@kit.BasicServicesKit';
let uiContext: UIContext = this.getUIContext();
// const uiContext: UIContext | undefined = AppStorage.get('uiContext');
// 上面这个叫联合类型
let router = uiContext.getRouter();
router.pushUrl({ url: 'pages/Second' })
.then(() => {
console.info('Succeeded in jumping to the second page.')
}).catch((err: BusinessError) => {
console.error(`Failed to jump to the second page. Code is ${err.code}, message is ${err.message}`)
})
toast
const uiContext: UIContext | undefined = AppStorage.get('uiContext');
uiContext!.getPromptAction().showToast({
message: content,
duration: CommonConstants.TOAST_DURATION
});
mediaquery
private smListener?: mediaquery.MediaQueryListener;
this.smListener = uiContext!.getMediaQuery().matchMediaSync(CommonConstants.WIDTH_CONDITION_SM);
this.smListener.on('change', this.isDeviceSizeSM);
this.smListener?.off('change', this.isDeviceSizeSM);
private isDeviceSizeSM = (mediaQueryResult: mediaquery.MediaQueryResult) => {
if (mediaQueryResult.matches) {
this.updateCurrentDeviceSize(CommonConstants.SM);
}
}
private updateCurrentDeviceSize(deviceSize: string): void {
if (this.currentDeviceSize !== deviceSize) {
this.currentDeviceSize = deviceSize;
AppStorage.set<string>('currentDeviceSize', this.currentDeviceSize);
}
}
文件路径
import { common } from '@kit.AbilityKit';
let context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
let filename = `${context.filesDir}/fileName.txt`;
图标库(SymbolGlyph)
import { SymbolGlyphModifier } from '@kit.ArkUI';
let symbolModifier1: SymbolGlyphModifier = new SymbolGlyphModifier($r('sys.symbol.ohos_wifi'));
示例2
SymbolGlyph('sys.symbol.person_crop_circle_fill_1')
.renderingStrategy(SymbolRenderingStrategy.MULTIPLE_OPACITY)
.symbolEffect(new BounceSymbolEffect(EffectScope.WHOLE, EffectDirection.UP),
this.currentIndex === index ? true : false)
.fontSize('24fp')
.fontColor(this.currentIndex === index ? ['#0a59f7'] : ['#66000000'])
ForEach
foreach的key尽量用id(即尽量实现keyGenerator),避免使用index
- 因为如果你往数据源插了数据,插入位置的索引是曾经存在过的,系统会认为key没有变,就不会为这个索引重新渲染组件,最后新增的索引,读到的数据其实仍然是老数组的最后一个数据。(1,2,3)你插入一个4,期望是(1,4,2,3),其实得到的是(1,2,3,3),因为只有最后一个索引变了
如果不指定 keyGenerator,默认是index_JSON.stringify(item),这样会导致性能问题
- 如果你插入了一个元素,后面的元素其实没变,但因为带着索引,那么后续的key全部变了,导致全部生成后面的所有组件
尽量使用箭头函数
箭头函数内可以使用this
在组件里写方法
// 1. 不需要加`function`
private myfunc() {
console.log('111');
}
// 2. builder
@Builder
myfunc2() {
Text('aaa');
}
// 3. 写到组件外(全局),这个时候要加`function`
@Builder
function myfunc3() {
Button("aaa")
}
Export
export * from xxx这是中转,但这个文件本身仍然没有把xxx给import进来,所以如果自己也要用,也需要import
Resource目录
有固定结构
src/main/resources/
├── base
│ ├── element/...
│ │ ├── color.json
│ │ ├── float.json
│ │ └── string.json
│ ├── media/...
│ └── profile/...
├── en_US/...
├── zh_CN/...
└── rawfile
├── icon.png <--- 文件在根目录
├── data
│ └── city_list.txt <--- 文件在子目录
└── configs
└── app_settings.json
$r('app.media.myicon')中,app指向的是resource目录,与之相对的是sys,它用于引用系统预置的资源media特指media目录,但element下的color,float,string则直接用app.color.xxxx引用,xxxx指的是对应的json文件里的键rawfile文件夹下的,以其为相对路径起点使用$rawfile('icon.png'), $rawfile('data/city_list.txt')引用
能用
app.color.xxx,app.string.xxx,不是文件名的原因,而是json文件里的键名,存了color,string这样的键名。所以说上面的结构只是人为创建的,本质上除了media是在特定目录里找文件,其它都是在所有json文件里读键值对,我们人为分为数字,颜色,字符串等文件而已。
json文件格式
// zh_CN/stringarray.json
{
"strarray": [
{
"name": "available_things",
"value": [
{
"value": "读书"
},
{
"value": "运动"
},
{
"value": "旅游"
},
{
"value": "听音乐"
},
{
"value": "看电影"
},
{
"value": "唱歌"
}
]
}
]
}
假定我要用资源文件存一个字符串数组,就得如上格式,键名是strarray,值是一个对象数组,数组里每个元素有一个value键,值是字符串。而不是能["读书", "运动", "旅游", "听音乐", "看电影", "唱歌"]这样的字符串数组(会报错:期望一个对象,实际得到一个字符串)
取值:
const s: string[]|undefined = this.getUIContext().getHostContext()?.resourceManager.getStringArrayValueSync($r('app.strarray.available_things').id);
先取UI上下文,再取Ability上下文,然后再取到资源管理器,然后取格式化的值。这就有类似苹果系统取plist,如果是取纯文件读字符串,这个getStringArrayValue系列的方法就不适用了,自己读成二进制再解析吧(getRawFileContent)。
import { buffer } from '@kit.ArkTS'; // 用于转换数据
import { common } from '@kit.AbilityKit';
// 在您的组件方法或事件回调中(例如按钮的onClick事件里):
Button('读取RawFile内容')
.onClick(() => {
// 1. 获取Ability上下文,这是关键且不可省略的一步
const abilityContext: common.UIAbilityContext | undefined = this.getUIContext().getHostContext();
if (abilityContext) {
const fileName: string = '你的文件.txt'; // 请替换为rawfile目录下的实际文件名
// 2. 使用同步方式读取文件内容(适合小文件)
try {
const fileData: Uint8Array = abilityContext.resourceManager.getRawFileContentSync(fileName);
// 3. 将二进制数据(Uint8Array)转换为字符串
const fileContent: string = buffer.from(fileData.buffer).toString();
console.info('文件内容:', fileContent);
// 接下来你可以使用 fileContent 了
// 也可以用decoder:
const textDecoder = new util.TextDecoder();
const string = textDecoder.decodeToString(fileData);
} catch (error) {
console.error(`读取文件失败: ${JSON.stringify(error)}`);
}
} else {
console.error('无法获取Ability上下文');
}
})
图片
图片可以设置解码尺寸(不同于布局尺寸)
Image($r('app.media.example'))
.sourceSize({
width: 40,
height: 40
})
图片默认异步加载,需要手动设置同步加载
Image($r('app.media.icon'))
.syncLoad(true)
加载成功读取尺寸
Image($r('app.media.ic_img_2'))
.width(200)
.height(150)
.onComplete(msg => {
if(msg){
this.widthValue = msg.width;
this.heightValue = msg.height;
this.componentWidth = msg.componentWidth;
this.componentHeight = msg.componentHeight;
}
})
Text
span
span是子组件,但会覆盖构造器里的text
Text('我是Text') {
Span('我是Span')
}
使用场景:
Text() {
Span('我是Span1,').fontSize(16).fontColor(Color.Grey)
.decoration({ type: TextDecorationType.LineThrough, color: Color.Red })
Span('我是Span2').fontColor(Color.Blue).fontSize(16)
.fontStyle(FontStyle.Italic)
.decoration({ type: TextDecorationType.Underline, color: Color.Black })
Span(',我是Span3').fontSize(16).fontColor(Color.Grey)
.decoration({ type: TextDecorationType.Overline, color: Color.Green })
}
.borderWidth(1)
.padding(10)
可见主要是用来给文字分组控制
span不支持设置尺寸
跑马灯
超长文字原生支持跑马灯,并能传入option
.textOverflow({ overflow: TextOverflow.MARQUEE })
.marqueeOptions({
start: true,
fromStart: true,
step: 6,
loop: -1,
delay: 0,
fadeout: false,
marqueeStartPolicy: MarqueeStartPolicy.DEFAULT
})
自适应大小
Text('我的最大字号为30,最小字号为5,宽度为250,maxLines为1')
.width(250)
.maxLines(1)
.maxFontSize(30)
.minFontSize(5)
原生支持复制粘贴
.copyOption(CopyOptions.InApp)
原生支持数字翻牌效果
从api 20开始
Text(this.number + "")
.borderWidth(1)
.fontSize(40)
.contentTransition(this.numberTransition)
其它
.lineSpacing(LengthMetrics.px(20), { onlyBetweenLines: true }), 配置了行间距对首行和尾行无效enableAutoSpacing(true)中英文自动加空格- 渐变色:
@State linearGradientOptions: LinearGradientOptions =
{
direction: GradientDirection.LeftTop,
colors: [[Color.Red, 0.0], [Color.Blue, 0.3], [Color.Green, 0.5]],
repeating: true,
};
Text('aaa').shaderStyle(this.linearGradientOptions)
- 垂直居中
.textVerticalAlign(TextVerticalAlign.CENTER) - 选中后的菜单
.copyOption(CopyOptions.InApp) - 自适应行数
.heightAdaptivePolicy(TextHeightAdaptivePolicy.LAYOUT_CONSTRAINT_FIRST),这样不同的字体下能显示的最大行数不同,比手动设置最大行数要灵活
输入框
- 包含
TextInput和TextArea TextInput().type(InputType.Password)输入框类型TextInput().style(TextContentStyle.DEFAULT)样式,default就是聚焦时加光标,inline则会显示一个小输入框.contentType(ContentType.EMAIL_ADDRESS)通过contentType能设置自动填充类型TextArea({text: '...'}).lineSpacing(LengthMetrics.px(20), { onlyBetweenLines: true }),又一个忽略首尾的行间距设定
控制选中文字后的菜单:
import { TextMenuController } from '@kit.ArkUI';
@Component
struct Index {
aboutToAppear(): void {
// 禁用所有系统服务菜单项
// 只留下标准的“复制,剪切,全选”
TextMenuController.disableSystemServiceMenuItems(true)
// 或者只禁用特定选项,比如搜索和翻译
TextMenuController.disableMenuItems([TextMenuItemId.SEARCH, TextMenuItemId.TRANSLATE])
}
aboutToDisappear(): void {
// 页面消失时恢复系统服务菜单项
TextMenuController.disableSystemServiceMenuItems(false)
}
//...
TextInput({text: 'hello world'})
.editMenuOptions({
// 需要确认如果只是隐藏部分选项,是否需要在这里返回一下
onCreateMenu: (menuItems: Array<TextMenuItem>) => {
// menuItems不包含搜索和翻译
return menuItems;
},
onMenuItemClick: (menuItem: TextMenuItem, textRange: TextRange) => {
return false
}
})
}
扩展组件
一般是标准化封装一些常用的样式,不然每个控件需要写一样的属性
@Extend(TextInput)
function inputStyle() {
.placeholderColor('#99182431')
.height('45vp')
.fontSize('18fp')
.backgroundColor('#F1F3F5')
.width('100%')
.margin({ top: 12 })
}
// 使用
TextInput({ placeholder: '账号' })
.type(InputType.Number)
.maxLength(11)
.inputStyle()
TextInput({ placeholder: '密码' })
.type(InputType.Password)
.maxLength(8)
.inputStyle()
Select
Select(this.durationSelectOptions)
.value('时长')
.margin({ right: '25vp' })
Select([{ value: $r('app.string.ALL') },
{ value: $r('app.string.Completed') },
{ value: $r('app.string.Uncompleted') }])
.value('状态')
.margin({ right: '25vp' })
构造函数里传入的参数是SelectOption[],value更像是label, 即当前select显示的名称(未选中时显示), 主要是手机显示区域有限, 不像web一样一个label跟一个select, 等于把label直接显示在了未选择时的select上
lists
-
.listDirection(Axis.Horizontal)更改默认方向 -
.alignListItem(ListItemAlign.Center)交叉向对齐 -
.justifyContent(JustifyContent.SpaceBetween)主轴对齐 -
.stackFromEnd(true),从底部往上滚动,比如直播的评论区(不改变组件排列,而是系统优化了初始锚点,默认显示列表底部) -
.edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true, effectEdge: EffectEdge.START })设置边缘是否回弹以及哪个边缘能回弹(默认两边全部回弹) -
使用ForEach创建ListItem,所有ListItem将会被创建。
- 显示区域内的ListItem在首帧进行布局,
- 预加载范围内的ListItem在空闲时完成布局。
- 预加载范围之外的ListItem仅创建ListItem自身,ListItem其内部的子组件不会被创建。
当List组件滑动时,进入预加载及显示区域的ListItem将会创建其内部的子组件并完成布局,而滑出预加载及显示区域的ListItem将不会被销毁。
-
使用LazyForEach创建ListItem
- 显示区域中的ListItem会被创建与布局。
- 预加载范围内的ListItem在空闲时创建与布局,但是不会被挂载到组件树上。
- 预加载范围外的ListItem则不会被创建。
当List组件滑动时,进入预加载及显示区域的ListItem将被创建与布局,创建ListItem过程中,若ListItem内部包含@Reusable标记的自定义组件,则会优先从缓存池中复用。滑出预加载及显示区域的ListItem将被销毁,其内部若含@Reusable标记的自定义组件,则会被回收并加入缓存池。
-
使用Repeat创建ListItem
- 使用
virtualScroll* 显示区域内的ListItem将被创建和布局。 * 预加载范围内的ListItem在空闲时创建和布局,并且**挂载至组件树上**。 * 预加载范围外的ListItem则不会被创建。当List组件滑动时,进入预加载及显示区域的ListItem,将从缓存池中获取ListItem并复用及布局,若缓存池中无ListItem,则会新创建并布局。滑出预加载及显示区域的ListItem将被回收至缓存池。
- 不使用
virtualScroll* 所有ListItem均被创建(**包含子组件**)。显示区域内的ListItem在首帧完成布局, * 预加载范围内的ListItem在空闲时完成布局。 * 预加载范围外的ListItem不会进行布局。当List组件滑动时,进入预加载及显示区域的ListItem将进行布局。滑出预加载及显示区域的ListItem不会销毁。(最不环保的一种)
- 使用
自适应多列
struct EgLanes {
@State egLanes: LengthConstrain = { minLength: 200, maxLength: 300 };
build() {
List() {
// ...
}
.lanes(this.egLanes)
}
}
上例中,如果List组件宽度只有300, 仍然只显示1列,因为不够显示两列,但是有400的话,就能显示成两列
reusable
// 使用@Reusable装饰器标记这个组件是可复用的
@Reusable
@Component
struct MessageItem {
// 使用@Prop装饰器接收外部传入的消息内容,当复用时,aboutToReuse会更新这个值
@Prop messageContent: string;
// 当组件被复用时,框架会调用这个生命周期函数
aboutToReuse(params: Record<string, Object>): void {
// params就是组件构建时的入参
this.messageContent = params['messageContent'] as string;
}
build() {
Row() {
Text(this.messageContent)
.fontSize(16)
.padding(10)
}
}
}
标题吸顶
List() {
// 懒加载ListItemGroup,contactsGroups为多个分组联系人contacts和标题title的数据集合
LazyForEach(contactsGroupsDataSource, (itemGroup: ContactsGroup) => {
ListItemGroup({ header: this.itemHead(itemGroup.title) }) {
// 懒加载ListItem
if (itemGroup.contacts) {
// 这里是例子包了一层IDataSource,所以它在解子数组的时候,把child用同样的接口包了一层,目的可能是为了使用IDataSource的特性,比如监听?
LazyForEach(new ContactsGroupDataSource(itemGroup.contacts), (item: Contact) => {
ListItem() {
// ...
}
}, (item: Contact) => JSON.stringify(item))
}
}
}, (itemGroup: ContactsGroup) => JSON.stringify(itemGroup))
}
.sticky(StickyStyle.Header)
.listDirection(Axis.Vertical)
.edgeEffect(EdgeEffect.Spring)
如果用静态数组来forEach,数据更新时需要重新赋值整个数组(如 this.data = [...newData])才能触发UI刷新,性能开销大。IDataSource可调用特定方法(如 onDataAdd, onDataChange)通知框架进行最小范围的UI更新,性能更优。
LazyForEach要求其数据源必须实现 IDataSource接口。
字母表组件
const alphabets = ['#', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K',
'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
@Entry
@Component
struct ContactsList {
@State selectedIndex: number = 0;
private listScroller: Scroller = new Scroller();
build() {
Stack({ alignContent: Alignment.End }) {
List({ scroller: this.listScroller }) {}
.onScrollIndex((firstIndex: number) => {
// 根据列表滚动到的索引值,重新计算对应联系人索引栏的位置this.selectedIndex
})
// 字母表索引组件
AlphabetIndexer({ arrayValue: alphabets, selected: 0 })
.selected(this.selectedIndex)
.onSelect((index: number) => {
this.listScroller.scrollToIndex(index);
})
}
}
}
可见一个通过controller, 一个通过selected,实现双向绑定
计算索引值时,ListItemGroup作为一个整体占一个索引值,不计算ListItemGroup内部ListItem的索引值。
滑动删除
@Builder itemEnd(index: number) {
// 构建尾端滑出组件
Button({ type: ButtonType.Circle }) {
Image($r('app.media.ic_public_delete_filled'))
.width(20)
.height(20)
}
.onClick(() => {
// this.messages为列表数据源,可根据实际场景构造。点击后从数据源删除指定数据项。
this.messages.splice(index, 1);
})
}
@Entry
@Component
struct SwipeAction {
build() {
List() {
Repeat({ count: 10 }) { index =>
ListItem() {
Row() {
Text(`Item ${index}`)
.fontSize(20)
.width('100%')
.height('100%')
.margin({ right: 20 })
}
.swipeAction({
// 左滑第一个为end
// 右滑第一个为start
end: {
builder: () => { this.itemEnd(index) },
}
})
}
}
}
}
}
角标
原理是装饰器模式,比如一个图片需要角标,那么就给图片包一层(Badge),也就是说本质上,你的组件由图片变成了角标,而不是给图片加上了角标。
ListItem() {
Badge({
count: 1,
position: BadgePosition.RightTop,
style: { badgeSize: 16, badgeColor: '#FA2A2D' }
}) {
Image($r('app.media.avatar'))
}
}
滚轮弹窗(Picker)
Text('+') //提供新增列表项入口,即给新增按钮添加点击事件
.onClick(() => {
this.getUIContext().showTextPickerDialog({
range: this.availableThings, // ["a", "b", "c"...]
onAccept: (value: TextPickerResult) => {
let arr = Array.isArray(value.index) ? value.index : [value.index];
for (let i = 0; i < arr.length; i++) {
this.toDoData.push(new ToDo(this.availableThings[arr[i]])); // 新增列表项数据toDoData(可选事项)
}
},
})
})
自定义弹窗
// 1. 实现一个builder
@CustomDialog
export struct SliderCustomDialog {
// 这里是演示一个步进器弹窗, 值存在preference里, 但读出来后放到appstorage里,方便双向绑定
@StorageLink('fontSizeOffset') fontSizeOffset: number = 4;
@State currentValue: number = 0;
// 2. 需要传入一个CustomDialogController
SliderCustomDialogController?: CustomDialogController;
build() {
...
xbutton
.onClick(() => {
// 3. 取消和确认按钮,绑定close事件
this.SliderCustomDialogController?.close();
})
}
}
// 4. UI里
import { SliderCustomDialog } from './SliderCustomDialog';
@Component
export default struct Setting {
@StorageLink('fontSizeOffset') fontSizeOffset: number = 0;
SliderCustomDialogController: CustomDialogController = new CustomDialogController({
builder: SliderCustomDialog({}),
alignment: DialogAlignment.Bottom,
customStyle: true,
offset: { dx: 0, dy: -25 }
});
...
}
以上官方示例里, SliderCustomDialog需要一个CustomDialogController, 而CustomDialogController则是在new的时候需要一个SliderCustomDialog, 写法上是循环依赖了, 虽然ArkTS用依赖注入机制解决了这个问题, new的时候传的builder是SliderCustomDialog并不是个实例(在open的时候才会实例化),所以时机上错开了.但仍然让人困惑,更好的实践是传入确认和取消的回调函数,也就是说,这个弹窗其实只需要负责UI就行了,不需要弹绑定弹窗controller
右箭头和展开动画
@State expandedItems: boolean[] = Array(this.routes.length).fill(false);
@State selection: string | null = null;
build() {
Column() {
// ...
List({ space: 10 }) {
ForEach(this.routes, (itemGroup: ItemGroupInfo) => {
ListItemGroup({
header: this.ListItemGroupHeader(itemGroup),
style: ListItemGroupStyle.CARD,
}) {
if (this.expandedItems[itemGroup.index] && itemGroup.children) {
ForEach(itemGroup.children, (item: ItemInfo) => {
ListItem({ style: ListItemStyle.CARD }) {
Row() {
Text(item.name)
Blank()
if (item.type === 'Image') {
Image(item.label)
.height(20)
.width(20)
} else {
Text(item.label)
}
Image($r('sys.media.ohos_ic_public_arrow_right'))
.fillColor($r('sys.color.ohos_id_color_fourth'))
.height(30)
.width(30)
}
.width("100%")
}
.width("100%")
.animation({ curve: curves.interpolatingSpring(0, 1, 528, 39) })
})
}
}.clip(true)
})
}
.width("100%")
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Start)
.backgroundColor($r('sys.color.ohos_id_color_sub_background'))
}
@Builder
ListItemGroupHeader(itemGroup: ItemGroupInfo) {
Row() {
Text(itemGroup.label)
Blank()
Image($r('sys.media.ohos_ic_public_arrow_down'))
.fillColor($r('sys.color.ohos_id_color_fourth'))
.height(30)
.width(30)
.rotate({ angle: !!itemGroup.children.length ? (this.expandedItems[itemGroup.index] ? 0 : 180) : 180 })
.animation({ curve: curves.interpolatingSpring(0, 1, 228, 22) })
}
.width("100%")
.padding(10)
.animation({ curve: curves.interpolatingSpring(0, 1, 528, 39) })
.onClick(() => {
if (itemGroup.children.length) {
this.getUIContext()?.animateTo({ curve: curves.interpolatingSpring(0, 1, 528, 39) }, () => {
this.expandedItems[itemGroup.index] = !this.expandedItems[itemGroup.index];
})
}
})
}
- 右箭头的图标和颜色用的
sys开头的资源 - ListItemGroup的
clip - animation的
interpolating方式(mass, stiffness, damping, initialVelocity),即质量,刚性,阻尼和初速度。刚性和阻尼越大,震幅越小震动过程越短
添加删除的动画
演示todolist从未完成到已完成两个区域间的切换
addAchieveData() {
this.toDoItem.isCompleted = !this.toDoItem.isCompleted;
this.UIContext.animateTo({ duration: STYLE_CONFIG.ANIMATION_DURATION }, () => {
if (this.toDoItem.isCompleted) {
let tempData = this.toDoData.filter(item => item.key !== this.toDoItem.key);
this.toDoData = tempData;
this.achieveData.push(this.toDoItem);
} else {
let tempData = this.achieveData.filter(item => item.key !== this.toDoItem.key);
this.achieveData = tempData;
this.toDoData.push(this.toDoItem);
}
});
}
嵌套滚动
用来模拟列表向下滚动时,有一个横向的tab列表(也可以滚动)会根据列表滚动的位置高亮当前的tab项
v-scroll
card(未分类的内容)
h-scroll(用来作分类标签)
list(v-scroll)(内容列表)
cards
List({ scroller: this.scroller }) {
ForEach(this.classifyList, (item: ClassifyModel, index: number) => {
ListItem() {
CardItemComponent({ classifyModel: item })
}
}, (item: ClassifyModel, _index: number) => JSON.stringify(item.classifyName))
}
.nestedScroll({
scrollForward: NestedScrollMode.PARENT_FIRST,
scrollBackward: NestedScrollMode.SELF_FIRST
})
// 通知当前分类变化s
.onScrollIndex((start: number) => this.classifyChangeAction(start, false))
// 确定当前分类的方法
classifyChangeAction(startIndex: number, isClassify: boolean) {
if (this.currentClassify !== startIndex) {
this.currentClassify = startIndex;
if (isClassify) {
this.scroller.scrollToIndex(startIndex);
} else {
this.classifyScroller.scrollToIndex(startIndex);
}
}
}
上面顺便演示了两个列表滚动位置的互相通知,真正要演示的是嵌套滚动的优先级:PARENT_FIRST表示父列表优先滚动,SELF_FIRST表示子列表优先滚动
Flex
Column和Row封装了一些Flex的布局属性成为了属性方法,还重命名了,可以用回Flex使用回原生的属性,特别如:Flex({ justifyContent: FlexAlign.SpaceBetween, alignItems: ItemAlign.Center })这两个轴向和交叉向的属性
- flex默认水平,不折行
Swiper
private swiperController: SwiperController = new SwiperController();
Swiper(this.swiperController) {
// ...
}
.loop(true)
.autoPlay(true)
.interval(1000)
.vertical(false) // 轮播方向
.displayCount(2) // 每页显示个数
.displayArrow(true, false) // 显示箭头
// 个性化箭头
.displayArrow({
showBackground: true,
isSidebarMiddle: true,
backgroundSize: 24,
backgroundColor: Color.White,
arrowSize: 18,
arrowColor: Color.Blue
}, false)
.indicator(true) // 导航条
.indicator( // 个性化
Indicator.dot() // 或:
// new DotIndicator()
// new DigitIndicator()
.left(0)
.itemWidth(15)
.itemHeight(15)
.selectedItemWidth(30)
.selectedItemHeight(15)
.color(Color.Red)
.selectedColor(Color.Blue)
.space(LengthMetrics.vp(3)) // 导航点间距
.bottom(LengthMetrics.vp(0), true) // 底部贴边(bottom为零+忽略本身大小)
)
.curve(curves.interpolatingSpring(-1, 1, 328, 34)) // 动画曲线
// 事件
// 通常被controller触发,强制显示到哪一页
.onSelected((index: number) => {
console.info("onSelected:" + index);
this.currentIndex = index;
this.tabsController.changeIndex(index);
})
// 用户可滑动触发,也可被controller触发
.onChange((index) => {
//
})
用controller控制:
this.swiperController.showNext();// showPreviousthis.swiperController.changeIndex(this.targetIndex, this.animationMode);- mode:
SwiperAnimationMode.NO_ANIMATION, DEFAULT_ANIMATION, FAST_ANIMATION
自定义导航动画,主要从proxy取数据:
Swiper() {
// ...
}
.customContentTransition({
timeout: 1000,
transition: (proxy: SwiperContentTransitionProxy) => {
if (proxy.position <= proxy.index % this.DISPLAY_COUNT || proxy.position >= this.DISPLAY_COUNT + proxy.index % this.DISPLAY_COUNT) {
// 同组页面完全滑出视窗外时,重置属性值
this.opacityList[proxy.index] = 1.0;
this.scaleList[proxy.index] = 1.0;
this.translateList[proxy.index] = 0.0;
this.zIndexList[proxy.index] = 0;
} else {
// 同组页面未滑出视窗外时,对同组中左右两个页面,逐帧根据position修改属性值
if (proxy.index % this.DISPLAY_COUNT === 0) {
this.opacityList[proxy.index] = 1 - proxy.position / this.DISPLAY_COUNT;
this.scaleList[proxy.index] = this.MIN_SCALE + (1 - this.MIN_SCALE) * (1 - proxy.position / this.DISPLAY_COUNT);
this.translateList[proxy.index] = - proxy.position * proxy.mainAxisLength + (1 - this.scaleList[proxy.index]) * proxy.mainAxisLength / 2.0;
} else {
this.opacityList[proxy.index] = 1 - (proxy.position - 1) / this.DISPLAY_COUNT;
this.scaleList[proxy.index] = this.MIN_SCALE + (1 - this.MIN_SCALE) * (1 - (proxy.position - 1) / this.DISPLAY_COUNT);
this.translateList[proxy.index] = - (proxy.position - 1) * proxy.mainAxisLength - (1 - this.scaleList[proxy.index]) * proxy.mainAxisLength / 2.0;
}
this.zIndexList[proxy.index] = -1;
}
}
})
产生如下效果:

网格布局Grid
Grid() {
// ...
}
.rowsTemplate('1fr 1fr 1fr')
.columnsTemplate('1fr 2fr 1fr')
创建了一个三行三列的网格,其中第二列占50%宽
如果要实现跨行或跨列,用GridLayoutOptions
layoutOptions: GridLayoutOptions = {
regularSize: [1, 1],
onGetRectByIndex: (index: number) => {
if (index == key1) { // key1是“0”按键对应的index
return [6, 0, 1, 2];
} else if (index == key2) { // key2是“=”按键对应的index
return [5, 3, 2, 1];
}
// ...
// 这里需要根据具体布局返回其他item的位置
}
}
Grid(undefined, this.layoutOptions) {
// ...
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr')
这里,0和=按键跨了两个单元格,
- 0按键在(6,0)位置,大小是(1行,2列),
- =按钮在(5,3)位置,大小是(2行,1列)
- 所以,通过
onGetRectByIndex返回的值就是(行,列,行数,列数)
其它属性:
Grid() {
// ...
}
.maxCount(3)
.layoutDirection(GridDirection.Row) // Row, Column
.columnsGap(10)
.rowsGap(15)
// 只设一个方向,比如下面的例子,那就固定了行数,列数无限延展
.rowsTemplate('1fr 1fr')
.rowsGap(15)
// 代码控制滚动:
Column({ space: 5 }) {
Grid(this.scroller) {
}
.columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr')
Row({ space: 20 }) {
Button('上一页')
.onClick(() => {
this.scroller.scrollPage({
next: false
});
})
Button('下一页')
.onClick(() => {
this.scroller.scrollPage({
next: true
});
})
}
}
Tabs
Tabs({ barPosition: BarPosition.End }) {
TabContent() {
Text('首页的内容').fontSize(30)
}
// 生命周期事件
.onWillShow(() => {
console.info('tab 1 show');
})
.onWillHide(() => {
console.info('tab 1 hide');
})
.tabBar('首页')
TabContent() {
Text('推荐的内容').fontSize(30)
}
// 自定义样式
.tabBar(new BottomTabBarStyle({
normal: this.symbolModifier4,
},'推荐'))
TabContent() {
Text('发现的内容').fontSize(30)
}
.tabBar('发现')
TabContent() {
Text('我的内容').fontSize(30)
}
.tabBar("我的")
}
.vertical(false)
.scrollable(true)
.barMode(BarMode.Fixed) // 固定导航栏,见一般APP的tab首页
.barMode(BarMode.Scrollable) // 新闻类APP的tab分类,可以滑动
// 主动切换事件
.onChange((index:number)=>{
console.info(index.toString());
})
// 被动切换事件
.onSelected((index: number) => {
this.selectIndex = index;
})
- 底部导航栏可通过设置TabContent的
BottomTabBarStyle来实现底部页签样式
侧边导航
Tabs({ barPosition: BarPosition.Start }) {
// TabContent的内容:首页、发现、推荐、我的
// ...
}
.vertical(true)
.barWidth(100)
.barHeight(200)
Tab套Tab,需要限制其中一个的滚动切换:
Tabs({ barPosition: BarPosition.End }) {
TabContent(){
Column(){
Tabs(){
// 顶部导航栏内容
// ...
}
}
.backgroundColor('#ff08a8f1')
.width('100%')
}
.tabBar('首页')
// 其他TabContent内容:发现、推荐、我的
// ...
}
.scrollable(false) // 最外层不能滑动切换Tab
系统默认情况下采用了下划线标识当前活跃的页签,而自定义导航栏需要自行实现相应的样式,用于区分当前活跃页签和未活跃页签。
@State currentIndex: number = 0;
// 把状态与样式绑定,动态更改图标和颜色
@Builder tabBuilder(title: string, targetIndex: number, selectedImg: Resource, normalImg: Resource) {
Column() {
Image(this.currentIndex === targetIndex ? selectedImg : normalImg)
.size({ width: 25, height: 25 })
Text(title)
.fontColor(this.currentIndex === targetIndex ? '#1698CE' : '#6B6B6B')
}
.width('100%')
.height(50)
.justifyContent(FlexAlign.Center)
}
// 使用
TabContent() {
Column(){
Text('我的内容')
}
.width('100%')
.height('100%')
.backgroundColor('#007DFF')
}
.tabBar(this.tabBuilder('我的', 0, $r('app.media.mine_selected'), $r('app.media.mine_normal')))
编程切换index:
Button('changeIndex').width('50%').margin({ top: 20 })
.onClick(()=>{
let index = (this.currentIndex + 1) % 4;
this.controller.changeIndex(index);
})
- 开发者可以通过Tabs组件的
onContentWillChange接口,设置自定义拦截回调函数。拦截回调函数在下一个页面即将展示时被调用,如果回调返回true,新页面可以展示;如果回调返回false,新页面不会展示,仍显示原来页面 - 默认情况下Tabs创建时会
一次性预加载所有TabContent,而且已加载的页面不会释放,可能会带来性能内存问题。- 此时可以设置cachedMaxCount属性控制缓存的页面数量,设置此属性后不会进行页面预加载,使用懒加载机制
Navigation
Navigation() {
// ...
}
.mode(NavigationMode.Auto) // auto就是stack,即页面栈,否则就是split分屏
自带有menu和toolbar,都是用一个对象定义:
@Entry
@Component
struct NavigationExample {
@State toolTmp: ToolbarItem = {
'value': "func",
'icon': "./image/ic_public_highlights.svg", // 当前目录image文件夹下的图标资源
'action': () => {}
};
// api 10以后都用navPathStack
@Provide('navPathStack') navPathStack: NavPathStack = new NavPathStack();
@Builder
pageMap(name: string) {
if (name === "NavDestinationTitle1") {
pageOneTmp();
} else if (name === "NavDestinationTitle2") {
pageTwoTmp();
} else if (name === "NavDestinationTitle3") {
pageThreeTmp();
}
}
build() {
Column() {
Navigation(this.navPathStack) {
TextInput({ placeholder: 'search...' })
.width("90%")
.height(40)
.backgroundColor('#FFFFFF')
List({ space: 12 }) {
ForEach(this.arr, (item: number) => {
ListItem() {
Text("Page" + item)
.onClick(() => {
this.navPathStack.pushPath({ name: 'NavDestinationTitle' + item });
})
}
}, (item: number) => item.toString())
}
}
.title("主标题")
.mode(NavigationMode.Split)
.titleMode(NavigationTitleMode.Mini) // mini类似iOS的正常标题,Full则是大标题
.navDestination(this.pageMap)
// 竖屏最多3个,横屏最多5个,多余的会放到一个自动生成的"更多“图标里
.menus([
{
value: "", icon: "./image/ic_public_search.svg", action: () => {
}
},
{
value: "", icon: "./image/ic_public_add.svg", action: () => {
}
}, ...
])
.toolbarConfiguration([this.toolTmp, this.toolTmp, this.toolTmp])
}
}
}
子页面demo:
@Component
struct Page01 {
pathStack: NavPathStack | undefined = undefined;
pageParam: string = '';
build() {
NavDestination() {
// ...
}.title('Page01')
.mode(NavDestinationMode.DIALOG) // 默认是STANDARD 即页面
// 首次创建时有此事件
.onReady((context: NavDestinationContext) => {
this.pathStack = context.pathStack;
this.pageParam = context.pathInfo.param as string;
})
// 任意层级都可以接onResult,只要它的子页是通过:
// navpathstack.popResult({result: new NavParam()})弹出的
.onResult((param: Object) => {
// param 就是子页面 B 传来的 { result: new NavParam() } 中的 result
if (param instanceof NavParam) {
console.info('收到子页面返回的数据:', param.desc);
}
})
}
}
- 每个
Navigation都需要创建并传入一个NavPathStack对象 - 菜单和工具栏的图标不是用的
$r机制,所以需要写物理路径,以下两种,一个在自定义目录,一个在资源目录,但访问方式是一致的,即全路径:- 'ets/pages/navigation/template1/image/ic_public_add.svg',
- 'resources/base/media/ic_public_add.svg',
- 工具栏实现出来默认样式就跟tabbar一样
- 子页面必须是
NavDestination为要有的组件- 返回按钮的拦截需要在
NavDestination的onBackPressed中处理
- 返回按钮的拦截需要在
页面跳转
- 跳转:
pushPath、pushPathByName、pushDestination、pushDestinationByName- 替换:
replacePath、replacePathByName、replaceDestination
- 替换:
- 返回:
pop、popToName、popToIndex、clear(还有个popResult吧) - 删除:
removeByName、removeByIndexes、removeByNavDestinationId,一般用在不能返回的场景,比如支付,登录,操作成功后不让返回上一页,就可以直接删除 - 移动:
moveToTop、moveIndexToTop
其它:
// 获取栈中所有页面name集合
this.pageStack.getAllPathName();
// 获取索引为1的页面参数
this.pageStack.getParamByIndex(1);
// 获取PageOne页面的参数
this.pageStack.getParamByName("PageOne");
// 获取PageOne页面的索引集合
this.pageStack.getIndexByName("PageOne");
页面跳转其实只是进行了压栈操作,你还得自己配置NavDestination进行真实页面的跳转(所以如果只是简单跳页,还不是用route模块)
import router from '@ohos.router';
// 跳转到指定页面(按页面路径)
router.pushUrl({
url: 'pages/PageTwo' // 对应 resources/base/profile/main_pages.json 中的路径
});
如果用pagePathStack方案也要一句话跳转,则需要配置路由表
路由表
- 在跳转目标模块的配置文件
src/main/module.json5添加路由表配置:
{
"module" : {
"routerMap": "$profile:route_map"
}
}
- 添加完路由配置文件地址后,需要在工程
resources/base/profile中创建route_map.json文件。添加如下配置信息:
{
"routerMap": [
{
"name": "PageOne",
"pageSourceFile": "src/main/ets/pages/PageOne.ets",
"buildFunction": "PageOneBuilder",
"data": {
"description" : "this is PageOne"
}
}
]
}
- 在真实页面(即
pageSourceFile)里实现配置里的buildFunction
// 跳转页面入口函数
@Builder
export function PageOneBuilder() {
PageOne();
}
@Component
struct PageOne {
pathStack: NavPathStack = new NavPathStack();
build() {
NavDestination() {
}
.title('PageOne')
.onReady((context: NavDestinationContext) => {
this.pathStack = context.pathStack;
})
}
}
- 现在就可以一句话跳转了
this.pageStack.pushPathByName("PageOne", null, false);
拦截
有willShow, didShow, modeChange:
this.pageStack.setInterception({
willShow: (from: NavDestinationContext | "navBar", to: NavDestinationContext | "navBar",
operation: NavigationOperation, animated: boolean) => {
if (typeof to === "string") { // 其实只可能是"navBar"
console.info("target page is navigation home page.");
return;
}
// 将跳转到PageTwo的路由重定向到PageOne
let target: NavDestinationContext = to as NavDestinationContext;
if (target.pathInfo.name === 'PageTwo') {
target.pathStack.pop();
target.pathStack.pushPathByName('PageOne', null);
}
}
})
转场
// 全局关闭
aboutToAppear(): void {
this.pageStack.disableAnimation(true);
}
// 单次关闭
this.pageStack.pushPath({ name: "PageOne" }, false);
this.pageStack.pop(false);
模态转场
打开模态窗口也算一个转场,用bindSheet
@State isShowSheet: boolean = false;
@Builder
mySheet() {
Image($r('app.media.bg_transition'))
}
...
// 弹窗UI
@Builder
mySheet() {
Image($r('app.media.bg_transition'))
}
// 页面UI
build() {
NavDestination() {
Column() {
// 触发状态变更即可
Image($r('app.media.bg_transition')).onClick(() => {
this.isShowSheet = true;
})
}
// 注册到页面根组件即可,关联状态和被弹窗组件
.bindSheet($$this.isShowSheet, this.mySheet(), {
height: SheetSize.MEDIUM
})
}
}
从ArkUI组件树层级上来看,Overlay浮层、弹窗、模态、带Order的Overlay浮层都挂载在Root节点下。
后退
- 后退(点按钮,手机后退按钮,侧滑返回)会触发`onBackPressed``,可以在这里处理返回逻辑,比如拦截返回,或者返回到指定页面
- 编辑触发后退:
- 原生router:
router.back({url: 'pages/home})不传参数就是纯后退 - 在 NavDestination中调
this.navPageInfos.pop();
- 原生router:
- 隐藏返回按钮:
- 在 NavDestination中隐藏
NavDestination() {}.hideBackButton(true) - 或配置:
- 在 NavDestination中隐藏
@Component
struct MyPage {
aboutToAppear() {
// 获取页面上下文
let context = getContext(this) as common.UIAbilityContext;
let windowClass = context.getWindow();
// 隐藏导航栏
windowClass.setWindowSystemBarEnable(['status']); // 只显示状态栏
// 或完全隐藏
// windowClass.setWindowSystemBarEnable([]);
}
}
串一下
// 在A页面跳转子页面:
.onClick(() => {
// 可见传了两个参数
this.navPathStack.pushPathByName(`PageOne`, '详情页面参数');
})
// PageOne的构造
// 对应src/main/resources/base/profile/router_map.json里的配置
// 即有哪些页面(pageSourceFile),构造方法是什么(buildFunction)
// 所以:
// pageSourceFile: PageOne.ets
// buildFunction: PageOneBuilder
@Builder
export function PageOneBuilder(name: string, param: string) {
// 这里定义了两个参数,一个name,一个param,param类型也是自定义的
// 注意,这里的name只是页面需要,并不是路由系统里注册的name
// 路由系统的name由route_map.json的routeMap.name定义
PageOne({ name: name, value: param });
}
@Component
export struct PageOne {
navPathStack: NavPathStack = new NavPathStack();
// 两个属性,即使是@State直接也直接标就行,不要理解为是传入的
name: string = '';
@State value: string = '';
build() {
NavDestination() {
// my view
}
}
}
// 整个大前提是在模块的src/main/module.json5里配置好:
// "routerMap": "$profile:router_map",
UIAbility
UIAbility是系统调度的最小单元。在设备内的功能模块之间跳转时,会涉及到启动特定的UIAbility,包括应用内的其他UIAbility、或者其他应用的UIAbility(例如启动三方支付UIAbility)。
- 任务视图中看到一个任务,使用“一个UIAbility+多个页面”的方式,可以避免不必要的资源加载。
- 在任务视图中看到多个任务,或者需要同时开启多个窗口,建议使用多个UIAbility实现不同的功能。
例如,即时通讯类应用中的消息列表与音视频通话采用不同的UIAbility进行开发,既可以方便地切换任务窗口,又可以实现应用的两个任务窗口在一个屏幕上分屏显示。
生命周期
UIAbility可以通过UIAbilityContext.startAbilityByCall()接口启动到后台,系统会依次触发onCreate()、onBackground()(不会执行onWindowStageCreate()生命周期回调)生命周期回调。当用户将UIAbility拉到前台,系统会依次触发onNewWant()、onWindowStageCreate()、onForeground()生命周期回调。
- 在首次创建UIAbility实例时,系统触发
onCreate()回调。(生命周期仅此一次) - 在进入前台之前,系统会创建一个WindowStage。WindowStage创建完成后会进入
onWindowStageCreate()回调- 在onWindowStageCreate()回调中通过
loadContent()方法设置应用要加载的页面 (比如应用入口的页面就是在这里设置的) - 并根据需要调用on('windowStageEvent')方法订阅WindowStage的事件(获焦/失焦、切到前台/切到后台、前台可交互/前台不可交互)。
- 在onWindowStageCreate()回调中通过
- UI可见之前,系统触发
onForeground回调。 >> 比如可以开启定位 - 在UIAbility的UI完全不可见之后,系统触发
onBackground回调,将UIAbility实例切换至后台状态。开发者可以在该回调中释放UI不可见时的无用资源 - 在UIAbility实例销毁之前,系统触发
onWindowStageWillDestroy()回调。该回调在WindowStage销毁前执行,此时WindowStage可以使用。开发者可以在该回调中释放通过WindowStage获取的资源、注销WindowStage事件订阅等。- 比如注销事件订阅
this.windowStage.off('windowStageEvent');
- 比如注销事件订阅
- WindowStage销毁后,系统触发
onWindowStageDestroy()回调,开发者可以在该回调中释放UI资源。 - 在UIAbility实例销毁之前,系统触发
onDestroy回调。该回调是UIAbility接收到的最后一个生命周期回调,开发者可以在onDestroy()回调中进行系统资源的释放、数据的保存等操作。 - 当应用的UIAbility实例已创建,再次调用方法启动该UIAbility实例时,系统触发该UIAbility的
onNewWant()回调。开发者可以在该回调中更新要加载的资源和数据等,用于后续的UI展示。
订阅WindowStage事件:
import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
const DOMAIN_NUMBER: number = 0xFF00;
export default class EntryAbility extends UIAbility {
// ...
onWindowStageCreate(windowStage: window.WindowStage): void {
// 设置WindowStage的事件订阅(获焦/失焦、切到前台/切到后台、前台可交互/前台不可交互)
try {
windowStage.on('windowStageEvent', (data) => {
let stageEventType: window.WindowStageEventType = data;
switch (stageEventType) {
case window.WindowStageEventType.SHOWN: // 切到前台
hilog.info(DOMAIN_NUMBER, 'testTag', `windowStage foreground.`);
break;
case window.WindowStageEventType.ACTIVE: // 获焦状态
hilog.info(DOMAIN_NUMBER, 'testTag', `windowStage active.`);
break;
case window.WindowStageEventType.INACTIVE: // 失焦状态
hilog.info(DOMAIN_NUMBER, 'testTag', `windowStage inactive.`);
break;
case window.WindowStageEventType.HIDDEN: // 切到后台
hilog.info(DOMAIN_NUMBER, 'testTag', `windowStage background.`);
break;
case window.WindowStageEventType.RESUMED: // 前台可交互状态
hilog.info(DOMAIN_NUMBER, 'testTag', `windowStage resumed.`);
break;
case window.WindowStageEventType.PAUSED: // 前台不可交互状态
hilog.info(DOMAIN_NUMBER, 'testTag', `windowStage paused.`);
break;
default:
break;
}
});
} catch (exception) {
hilog.error(DOMAIN_NUMBER, 'testTag',
`Failed to enable the listener for window stage event changes. Cause: ${JSON.stringify(exception)}`);
}
hilog.info(DOMAIN_NUMBER, 'testTag', `%{public}s`, `Ability onWindowStageCreate`);
// 设置UI加载
windowStage.loadContent('pages/Index', (err, data) => {
// ...
});
}
}
启动模式
在module.json5配置文件中的launchType字段可以设置UIAbility的启动模式,包括单实例模式(singleton)和多实例模式(multiton)。默认为单实例模式。还有一个指定实例模式(specified)
- 单实例模式每次调用
startAbility()方法时,如果应用进程中该类型的UIAbility实例已经存在,则复用系统中的UIAbility实例。- 触发
onNewWant()回调
- 触发
- 多实例模式,每次调用startAbility()方法时,都会在应用进程中创建一个新的该类型UIAbility实例。即在最近任务列表中可以看到有多个该类型的UIAbility实例。
- 指定实例模式, 根据传入的key, 判断是走onNewWant()还是onCreate().
- 假如一个文档应用,主页面AAbility启动文档BAbility, "新建"按钮每次给一个不同的key, 那么每次都会走onCreate(), "打开"按钮每次给一个相同的key, 那么每次都会走onNewWant()
- 系统在拉起SpecifiedAbility之前,会先进入对应的AbilityStage的onAcceptWant()生命周期回调,获取用于标识目标UIAbility的Key值。
// 新建按钮
Button()
.onClick(() => {
let context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
// context为调用方UIAbility的UIAbilityContext;
let want: Want = {
deviceId: '', // deviceId为空表示本设备
bundleName: 'com.samples.stagemodelabilitydevelop',
abilityName: 'SpecifiedFirstAbility',
moduleName: 'entry', // moduleName非必选
// 参数, 在目标Ability的onCreate()或者onNewWant()生命周期回调文件中接收
parameters: {
// 自定义信息
instanceKey: this.KEY_NEW
}
};
context.startAbility(want).then(() => {
hilog.info(DOMAIN_NUMBER, TAG, 'Succeeded in starting SpecifiedAbility.');
}).catch((err: BusinessError) => {
hilog.error(DOMAIN_NUMBER, TAG, `Failed to start SpecifiedAbility. Code is ${err.code}, message is ${err.message}`);
})
this.KEY_NEW = this.KEY_NEW + 'a'; // 保证key总是新的
})
启动Ability
上面例子演示了一个startAbility, 如果需要接返回值:
context.startAbilityForResult(want).then((data) => {
if (data?.resultCode === RESULT_CODE) {
// 解析被调用方UIAbility返回的信息
let info = data.want?.parameters?.info;
hilog.info(DOMAIN_NUMBER, TAG, JSON.stringify(info) ?? '');
if (info !== null) {
this.getUIContext().getPromptAction().showToast({
message: JSON.stringify(info)
});
}
}
}
一般是在terminate里返回的:
.onClick(() => {
let context = this.getUIContext().getHostContext() as common.UIAbilityContext; // UIAbilityContext
const RESULT_CODE: number = 1001;
// 构造一个result对象
let abilityResult: common.AbilityResult = {
resultCode: RESULT_CODE,
want: {
bundleName: 'com.samples.stagemodelabilitydevelop',
moduleName: 'entry', // moduleName非必选
abilityName: 'FuncAbilityB',
parameters: {
info: '来自FuncAbility Index页面'
},
},
};
context.terminateSelfWithResult(abilityResult, (err) => {
if (err.code) {
hilog.error(DOMAIN_NUMBER, TAG, `Failed to terminate self with result. Code is ${err.code}, message is ${err.message}`);
return;
}
});
})
上下文
import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// 获取UIAbility实例的上下文
let context = this.context;
// ...
}
}
通过UIAbilityContext可以获取UIAbility的相关配置信息,如包代码路径、Bundle名称、Ability名称和应用程序需要的环境状态等属性信息,以及可以获取操作UIAbility实例的方法(如startAbility()、connectServiceExtensionAbility()、terminateSelf()等)。
调用terminateSelf()方法停止当前UIAbility实例时,默认会保留该实例的快照(Snapshot),即在最近任务列表中仍然能查看到该实例对应的任务。如不需要保留该实例的快照,可以在其对应UIAbility的module.json5配置文件中,将abilities标签的removeMissionAfterTerminate字段配置为true。
在实例中获取:
import { common, Want } from '@kit.AbilityKit';
@Entry
@Component
struct Page_EventHub {
private context = this.getUIContext().getHostContext() as common.UIAbilityContext;
startAbilityTest(): void {
let want: Want = {
// Want参数信息
};
this.context.startAbility(want);
}
// 页面展示
build() {
// ...
}
}
获取拉取方信息:
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// 调用方无需手动传递parameters参数,系统会自动向Want对象中传递调用方信息。
console.info(`onCreate, callerPid: ${want.parameters?.['ohos.aafwk.param.callerPid']}.`);
console.info(`onCreate, callerBundleName: ${want.parameters?.['ohos.aafwk.param.callerBundleName']}.`);
console.info(`onCreate, callerAbilityName: ${want.parameters?.['ohos.aafwk.param.callerAbilityName']}.`);
}
UIAbility组件与UI的数据同步
使用EventHub进行数据通信
// onCreate的时候订阅
let eventhub = this.context.eventHub;
// 执行订阅操作
eventhub.on('event1', this.eventFunc);
eventhub.on('event1', (data: string) => {
// 触发事件,完成相应的业务操作
});
// UI中emit
private context = this.getUIContext().getHostContext() as common.UIAbilityContext;
this.context.eventHub.emit('event1', 2, 'test');
this.context.eventHub.off('event1');
上例中eventHub的流向也可以反过来,比如要记录生命周期, 可以在UIAbility中emit, 在UI中订阅(.on)
使用AppStorage/LocalStorage进行数据同步:
- AppStorage是一个全局的状态管理器,适用于多个UIAbility共享同一状态数据的情况;
- LocalStorage则是一个局部的状态管理器,适用于单个UIAbility内部使用的状态数据。
启动指定页面
调用方UIAbility指定被调用方启动页面, 在want里配置
let want = {...
parameters: { // 自定义参数传递页面信息
router: 'funcA'
}
}
接收:
- 目标是冷启动
在目标Ability的onCreate回调中获取Want`并保存在onWindowStageCreate中从want中取出参数, 确定启动页面,再windowStage.loadContent(url, (err, data) => {}` - 热启动
- 在
onNewWant()中解析参数进行处理。
从示例代码来看,热启动只是存了路径,并没有跳转的机制, 而是在启动到Index页面的时候取出保存的路径自行跳转(这就有个问题, 热启动拉起的页面未必是在Index吧?),比如进行路由跳转或添加到导航栈
网络
- 声明接口调用所需要的权限:
ohos.permission.INTERNET,ohos.permission.GET_NETWORK_INFO。 import { connection } from '@kit.NetworkKit';- 获取所有注册网络
connection.getAllNets().then((data: connection.NetHandle[]) => {} - 解析域名
connection.getAddressesByName("xxxx").then((data: connection.NetAddress[]) => {} - 获取默认网络
connection.getDefaultNet().then((data:connection.NetHandle) => {}connection.getNetCapabilities(data).then((data: connection.NetCapabilities) => {}data.bearerTypes网络类型, 如蜂窝,wifi等data.networkCap网络能力,如是能访问网络,VPN等connection.NetCap.NET_CAPABILITY_VALIDATED即为可上网
connection.getConnectionProperties(netHandleInfo)获取网络属性
// entry/src/main/module.json5
"requestPermissions": [
{
"name": "ohos.permission.INTERNET",
"reason": "$string:Internet",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.GET_NETWORK_INFO",
"reason": "$string:network_info",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
}
]
let netSpecifier: connection.NetSpecifier = {
netCapabilities: {
// 假设当前默认网络是WiFi,需要创建蜂窝网络连接,可指定网络类型为蜂窝网。
bearerTypes: [connection.NetBearType.BEARER_CELLULAR],
// 指定网络能力为Internet。
networkCap: [connection.NetCap.NET_CAPABILITY_INTERNET]
},
};
// 指定超时时间为10s(默认值为0)。
let timeout = 10 * 1000;
// 创建NetConnection对象。
let conn = connection.createNetConnection(netSpecifier, timeout);
// 订阅事件,如果当前指定网络可用,通过on_netAvailable通知用户。
conn.on('netAvailable', ((data: connection.NetHandle) => {
console.info("net is available, netId is " + data.netId);
}));
// 订阅事件,如果当前指定网络不可用,通过on_netUnavailable通知用户。
conn.on('netUnavailable', ((data: void) => {
console.info("net is unavailable, data is " + JSON.stringify(data));
}));
// 上述订阅的成立条件是要先注册?
// 订阅指定网络状态变化的通知。
conn.register((err: BusinessError, data: void) => {
console.info(JSON.stringify(err));
});
其它用法见官网
数据请求
- HttpRequest.request
- HttpRequest.requestInstream -> 上传,下载,或关心进度
- 请求头: 当请求方式为"POST" "PUT" "DELETE" 或者""时,默认{'content-Type': 'application/json'}, 否则默认{'content-Type': 'application/x-www-form-urlencoded'}。
发起普通请求
import { http } from '@kit.NetworkKit';
let httpRequest = http.createHttp();
// 头比response先返回
httpRequest.on('headersReceive', (header) => {
console.info('header: ' + JSON.stringify(header));
});
httpRequest.request(
"EXAMPLE_URL",
{
method: http.RequestMethod.POST, // 默认GET
header: {
'Content-Type': 'application/json'
},
// 当使用POST请求时此字段用于传递请求体内容,具体格式与服务端协商确定。
extraData: "data to send",
expectDataType: http.HttpDataType.STRING,
...
}, (err: BusinessError, data: http.HttpResponse) => {
if (!err) {
// data.result为HTTP响应内容,可根据业务需要进行解析。
console.info('Result:' + JSON.stringify(data.result));
console.info('code:' + JSON.stringify(data.responseCode));
// data.header为HTTP响应头,可根据业务需要进行解析。
console.info('header:' + JSON.stringify(data.header));
console.info('cookies:' + JSON.stringify(data.cookies)); // 8+
// 当该请求使用完毕时,调用destroy方法主动销毁。
httpRequest.destroy();
} else {
console.error('error:' + JSON.stringify(err));
// 取消订阅HTTP响应头事件。
httpRequest.off('headersReceive');
// 当该请求使用完毕时,调用destroy方法主动销毁。
httpRequest.destroy();
}
}
);
发起流式请求
HTTP流式传输是指在处理HTTP响应时,可以一次只处理响应内容的一小部分,而不是一次性将整个响应加载到内存,这对于处理大文件、实时数据流等场景非常有用。
正常发起请求, 异步接收数据
httpRequest.requestInStream("EXAMPLE_URL", {options}).then((data: number) => {
console.info('ResponseCode :' + JSON.stringify(data));
this.destroyRequest(httpRequest);
}).catch((err: Error) => {
console.error("requestInStream ERROR : err = " + JSON.stringify(err));
this.destroyRequest(httpRequest);
});
// 订阅
let res = new ArrayBuffer(0);
httpRequest.on('dataReceive', (data: ArrayBuffer) => {
const newRes = new ArrayBuffer(res.byteLength + data.byteLength);
const resView = new Uint8Array(newRes);
resView.set(new Uint8Array(res));
resView.set(new Uint8Array(data), res.byteLength);
res = newRes;
console.info('res length: ' + res.byteLength);
});
// 用于订阅HTTP流式响应数据接收完毕事件。
httpRequest.on('dataEnd', () => {
console.info('No more data in response, data receive end');
});
// 订阅HTTP流式响应数据接收进度事件,下载服务器的数据时,可以通过该回调获取数据下载进度。
httpRequest.on('dataReceiveProgress', (data: http.DataReceiveProgressInfo) => {
console.info("dataReceiveProgress receiveSize:" + data.receiveSize + ", totalSize:" + data.totalSize);
});
// 订阅HTTP流式响应数据发送进度事件,向服务器上传数据时,可以通过该回调获取数据上传进度。
httpRequest.on('dataSendProgress', (data: http.DataSendProgressInfo) => {
console.info("dataSendProgress receiveSize:" + data.sendSize + ", totalSize:" + data.totalSize);
});
public destroyRequest(httpRequest: http.HttpRequest) {
// 取消订阅HTTP流式响应数据接收事件。
httpRequest.off('dataReceive');
// 取消订阅HTTP流式响应数据发送进度事件。
httpRequest.off('dataSendProgress');
// 取消订阅HTTP流式响应数据接收进度事件。
httpRequest.off('dataReceiveProgress');
// 取消订阅HTTP流式响应数据接收完毕事件。
httpRequest.off('dataEnd');
// 当该请求使用完毕时,调用destroy方法主动销毁。
httpRequest.destroy();
}
有on过的,必须要有对应的off
配置证书校验
可以通过预置应用级证书,或者预置证书公钥哈希值的方式来进行证书锁定,即只有开发者特别指定的证书才能正常建立HTTPS连接。两种方式都是在配置文件中配置的,配置文件在APP中的路径是:src/main/resources/base/profile/network_config.json。
预置应用级证书
直接把证书原文件预置在APP中。目前支持crt和pem格式的证书文件。配置:
{
"network-security-config": {
"base-config": {
"trust-anchors": [
{
"certificates": "/etc/security/certificates"
}
]
},
"domain-config": [
{
"domains": [
{
"include-subdomains": true,
"name": "example.com"
}
],
"trust-anchors": [
{
"certificates": "/data/storage/el1/bundle/entry/resources/resfile"
}
]
}
]
}
}
预置证书公钥哈希值
通过在配置中指定域名证书公钥的哈希值,只允许使用公钥哈希值匹配的域名证书访问此域名。计算方法:
# 从证书中提取出公钥
openssl x509 -in www.example.com.pem -pubkey -noout > www.example.com.pubkey.pem
# 将pem格式的公钥转换成der格式
openssl asn1parse -noout -inform pem -in www.example.com.pubkey.pem -out www.example.com.pubkey.der
# 计算公钥的SHA256并转换成base64编码
openssl dgst -sha256 -binary www.example.com.pubkey.der | openssl base64
配置
{
"network-security-config": {
"domain-config": [
{
"domains": [
{
"include-subdomains": true,
"name": "*.server.com"
}
],
"pin-set": {
"expiration": "2024-11-08",
"pin": [
{
"digest-algorithm": "sha256",
"digest": "FEDCBA987654321"
}
]
}
}
]
}
}
配置不信任用户安装的CA证书
{
"network-security-config": {
... ...
},
"trust-global-user-ca": false, // 配置是否信任企业MDM系统或设备管理员用户手动安装的CA证书,默认为true
"trust-current-user-ca" : false // 配置是否信任当前用户安装的CA证书,默认为true
}
配置明文访问权限
// src/main/resources/base/profile/network_config.json
{
"network-security-config": {
"base-config": {
"cleartextTrafficPermitted": true // 可选,自API 20开始支持该属性。
},
"domain-config": [
{
"domains": [
{
"include-subdomains": true,
"name": "example.com"
}
],
"cleartextTrafficPermitted": false // 可选,自API 20开始支持该属性。
}
],
"component-config": {
"Network Kit": true, // 可选,自API 20开始支持该属性。
"ArkWeb": false // 可选,自API 20开始支持该属性。
}
}
}
注意cleartextTrafficPermitted
RCP
@hms.collaboration.rcp(后续简称RCP)指的是远程通信平台(remote communication platform),RCP提供了网络数据请求功能,相较于Network Kit中HTTP请求能力,RCP更具易用性,且拥有更多的功能。
一个Patch的例子(只更改特定字段,而不是post整个对象):
// 定义请求头
let headers: rcp.RequestHeaders = {
'accept': 'application/json'
};
// 定义要修改的内容
let modifiedContent: UserInfo = {
'userName': 'xxxxxx'
};
const securityConfig: rcp.SecurityConfiguration = {
tlsOptions: {
tlsVersion: 'TlsV1.3'
}
};
// 创建通信会话对象
const session = rcp.createSession({ requestConfiguration: { security: securityConfig } });
// 定义请求对象rep
let req = new rcp.Request('http://example.com/fetch', 'PATCH', headers, modifiedContent);
// 发起请求
session.fetch(req).then((response) => {
Logger.info(`Request succeeded, message is ${JSON.stringify(response)}`);
}).catch((err: BusinessError) => {
Logger.error(`err: err code is ${err.code}, err message is ${JSON.stringify(err)}`);
});
- rcp创建一个session
- rcp创建一个request
- session.fetch(request)
公共配置
用来设置基地址, 请求头等:
// 定义sessionConfig对象
const sessionConfig: rcp.SessionConfiguration = {
baseAddress: 'http://api.example.com',
headers: {
'authorization': 'Bearer YOUR_ACCESS_TOKEN',
'content-type': 'application/json'
},
requestConfiguration:{
security:{
tlsOptions: {
tlsVersion: 'TlsV1.3'
}
}
}
};
// 创建通信会话对象,并传入sessionConfig
const session = rcp.createSession(sessionConfig);
更多内容见文档,如双向证书校验,请求和响应的拦截等
存储/持久化
首先项(Preferences)
- 用户首选项(Preferences)为应用提供Key-Value键值型的数据处理能力,支持应用持久化轻量级数据,并对其修改和查询。
- 底层是
XML文件(从API version 18开始,可选择GSKV存储模式。)- xml, 数据操作发生在内存中,通过
flush进行持久化, 适用简单,单进程场景 - gskv, 二进制存储, 实时落盘,支持多进程并发读写,不支持跨平台
- xml, 数据操作发生在内存中,通过
- key长度不超过1024字节, xml value不超过50M
import { preferences } from '@kit.ArkData';
import { UIAbility } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { window } from '@kit.ArkUI';
let dataPreferences: preferences.Preferences | null = null;
class EntryAbility extends UIAbility {
onWindowStageCreate(windowStage: window.WindowStage) {
// 需要先判断是否支持gskv
let isGskvSupported = preferences.isStorageTypeSupported(preferences.StorageType.GSKV);
let options: preferences.Options = { name: 'myStore', storageType: preferences.StorageType.GSKV };
dataPreferences = preferences.getPreferencesSync(this.context, options);
}
}
写
import { util } from '@kit.ArkTS';
if (dataPreferences.hasSync('startup')) {
console.info("The key 'startup' is contained.");
} else {
console.info("The key 'startup' does not contain.");
// 此处以此键值对不存在时写入数据为例
dataPreferences.putSync('startup', 'auto');
// 在XML模式下,当字符串包含非UTF-8格式的字符时,需要将字符串转为Uint8Array类型再存储,长度均不超过16 * 1024 * 1024个字节。
let uInt8Array1 = new util.TextEncoder().encodeInto("~!@#¥%……&*()——+?");
dataPreferences.putSync('uInt8', uInt8Array1);
}
读
let val = dataPreferences.getSync('startup', 'default');
console.info("The 'startup' value is " + val);
let uInt8Array2 : preferences.ValueType = dataPreferences.getSync('uInt8', new Uint8Array(0));
// 将获取到的Uint8Array转换为字符串
let textDecoder = util.TextDecoder.create('utf-8');
val = textDecoder.decodeToString(uInt8Array2 as Uint8Array);
console.info("The 'uInt8' value is " + val);
kvo
let observer = (key: string) => {
console.info('The key' + key + 'changed.');
}
dataPreferences.on('change', observer);
SQLite
- 单次查询数据量不超过5000条。
- 在
TaskPool中查询。(任务池@ohos.taskpool) - 数据库中常驻有4个读连接和1个写连接。读连接会动态扩充,写连接不会动态扩充(等待)
- ArkTS侧支持的基本数据类型:number、string、二进制类型数据、boolean。
- 为保证插入并读取数据成功,建议一条数据不要超过2M。超出该大小,插入成功,读取失败。
更多见文档