[HarmonyOS NEXT 实战案例十三] 音乐播放器网格布局(下)
[HarmonyOS NEXT 实战案例十三] 音乐播放器网格布局(下)
项目已开源,开源地址: https://gitcode.com/nutpi/HarmonyosNextCaseStudyTutorial , 欢迎fork & star
效果演示
1. 概述
在上一篇教程中,我们学习了如何使用HarmonyOS NEXT的GridRow和GridCol组件实现基本的音乐播放器网格布局。本篇教程将在此基础上,深入探讨如何优化和扩展音乐播放器,包括响应式布局设计、动画效果、主题定制和高级交互功能等方面。
本教程将涵盖以下内容:
- 响应式布局设计
- 专辑封面旋转动画
- 播放控制按钮的交互优化
- 进度条的高级定制
- 音乐播放列表的实现
- 主题与样式定制
2. 响应式布局设计
2.1 断点配置
为了使音乐播放器能够适应不同尺寸的设备,我们需要为GridRow组件配置响应式断点:
// 专辑封面
GridRow({
columns: { xs: 1, sm: 1, md: 1, lg: 1 },
gutter: { x: 16, y: 16 }
}) {
GridCol({ span: { xs: 1, sm: 1, md: 1, lg: 1 } }) {
Image($r("app.media.big14"))
.width(this.getAlbumSize())
.height(this.getAlbumSize())
.borderRadius(this.getAlbumSize() / 2)
.margin({ top: 32, bottom: 32 })
}
}
我们添加一个方法来根据屏幕尺寸返回不同的专辑封面大小:
private getAlbumSize(): number {
// 根据屏幕宽度返回不同的尺寸
// 这里仅作示例,实际应用中可以使用媒体查询或其他方式获取屏幕信息
return 200; // 默认尺寸
}
2.2 控制按钮的响应式布局
对于控制按钮区域,我们可以根据屏幕尺寸调整布局:
// 控制按钮
GridRow({
columns: { xs: 3, sm: 5, md: 5, lg: 5 },
gutter: { x: 8, y: 8 }
}) {
// 在小屏幕上隐藏第一个和最后一个按钮
GridCol({ span: { xs: 0, sm: 1, md: 1, lg: 1 } }) {
Image($r('app.media.01'))
.width(24)
.height(24)
}
GridCol({ span: { xs: 1, sm: 1, md: 1, lg: 1 } }) {
Image($r('app.media.02'))
.width(24)
.height(24)
}
GridCol({ span: { xs: 1, sm: 1, md: 1, lg: 1 } }) {
Image(this.isPlaying ? $r('app.media.03') : $r('app.media.01'))
.width(32)
.height(32)
.onClick(() => {
this.isPlaying = !this.isPlaying
if (this.isPlaying) {
this.startRotation()
} else {
this.stopRotation()
}
})
}
GridCol({ span: { xs: 1, sm: 1, md: 1, lg: 1 } }) {
Image($r('app.media.04'))
.width(24)
.height(24)
}
GridCol({ span: { xs: 0, sm: 1, md: 1, lg: 1 } }) {
Image($r('app.media.05'))
.width(24)
.height(24)
}
}
这个配置表示:
- 在极小屏幕(xs)上,只显示三个主要控制按钮
- 在其他屏幕尺寸上,显示全部五个控制按钮
3. 专辑封面旋转动画
为了增强视觉效果,我们可以为专辑封面添加旋转动画,使其在播放音乐时旋转:
3.1 添加动画状态变量
@State rotationAngle: number = 0
@State rotationAnimator: AnimatorResult | null = null
3.2 实现旋转动画方法
private startRotation() {
// 创建旋转动画
this.rotationAnimator = this.createRotationAnimator()
// 启动动画
this.rotationAnimator?.play()
}
private stopRotation() {
// 停止动画
this.rotationAnimator?.pause()
}
private createRotationAnimator(): AnimatorResult {
// 创建一个从0到360度的旋转动画,持续时间为10秒,无限循环
return createAnimator({
duration: 10000, // 10秒
iterations: -1, // 无限循环
curve: Curve.Linear, // 线性变化
playMode: PlayMode.Normal,
onframe: (progress: number) => {
this.rotationAngle = progress * 360
}
})
}
3.3 应用旋转动画到专辑封面
Image($r("app.media.big14"))
.width(this.getAlbumSize())
.height(this.getAlbumSize())
.borderRadius(this.getAlbumSize() / 2)
.margin({ top: 32, bottom: 32 })
.rotate({ z: 1, angle: this.rotationAngle })
4. 播放控制按钮的交互优化
4.1 按钮状态样式
为控制按钮添加状态样式,提升交互体验:
Image(this.isPlaying ? $r('app.media.03') : $r('app.media.01'))
.width(32)
.height(32)
.onClick(() => {
this.isPlaying = !this.isPlaying
if (this.isPlaying) {
this.startRotation()
} else {
this.stopRotation()
}
})
.stateStyles({
normal: {
.opacity(1)
.scale({ x: 1, y: 1 })
},
pressed: {
.opacity(0.8)
.scale({ x: 0.9, y: 0.9 })
},
hover: {
.opacity(1)
.scale({ x: 1.1, y: 1.1 })
}
})
4.2 按钮点击动画
添加点击动画,使按钮点击更有反馈:
private playButtonAnimation() {
// 创建一个缩放动画
const scaleAnimator = createAnimator({
duration: 200,
curve: Curve.FastOutSlowIn,
iterations: 1,
playMode: PlayMode.Normal,
onframe: (progress: number) => {
// 从0.8缩放到1.1再回到1
const scale = 0.8 + (progress < 0.5 ? progress * 0.6 : (1 - progress) * 0.2 + 0.9)
// 应用缩放
// 注意:这里需要更新一个状态变量,然后在UI中使用该变量
this.buttonScale = scale
}
})
scaleAnimator.play()
}
5. 进度条的高级定制
5.1 自定义进度条样式
Slider({
value: this.currentTime,
min: 0,
max: this.duration,
step: 1,
style: SliderStyle.OutSet
})
.width('70%')
.onChange((value) => {
this.currentTime = value
})
.margin({ left: 8, right: 8 })
.trackColor('#E0E0E0') // 轨道颜色
.selectedColor('#1E88E5') // 已选择部分颜色
.showTips(true) // 显示提示
.showSteps(false) // 不显示步进点
5.2 添加播放进度自动更新
@State timer: number = 0
aboutToAppear() {
// 创建定时器,每秒更新一次播放进度
this.timer = setInterval(() => {
if (this.isPlaying && this.currentTime < this.duration) {
this.currentTime += 1
// 如果播放完毕,停止播放
if (this.currentTime >= this.duration) {
this.isPlaying = false
this.stopRotation()
}
}
}, 1000) as number
}
aboutToDisappear() {
// 清除定时器
if (this.timer) {
clearInterval(this.timer)
this.timer = 0
}
// 停止动画
this.stopRotation()
}
6. 音乐播放列表的实现
6.1 定义歌曲数据结构
interface Song {
title: string;
artist: string;
duration: number;
cover: Resource;
}
6.2 准备歌曲列表数据
private songs: Song[] = [
{ title: '夜曲', artist: '周杰伦', duration: 180, cover: $r('app.media.big14') },
{ title: '七里香', artist: '周杰伦', duration: 210, cover: $r('app.media.big14') },
{ title: '晴天', artist: '周杰伦', duration: 200, cover: $r('app.media.big14') },
{ title: '稻香', artist: '周杰伦', duration: 190, cover: $r('app.media.big14') }
]
@State currentSongIndex: number = 0
6.3 添加播放列表UI
// 播放列表(可折叠)
GridRow({ columns: 1 }) {
GridCol({ span: 1 }) {
Column() {
// 播放列表标题
Row() {
Text('播放列表')
.fontSize(16)
.fontWeight(FontWeight.Bold)
Image($r('app.media.01')) // 展开/折叠图标
.width(16)
.height(16)
.margin({ left: 8 })
.onClick(() => {
this.showPlaylist = !this.showPlaylist
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 16, bottom: 8 })
// 播放列表内容(可折叠)
if (this.showPlaylist) {
List() {
ForEach(this.songs, (song: Song, index: number) => {
ListItem() {
Row() {
Text(`${index + 1}. ${song.title}`)
.fontSize(14)
Text(song.artist)
.fontSize(12)
.fontColor('#9E9E9E')
.margin({ left: 8 })
Text(this.formatTime(song.duration))
.fontSize(12)
.fontColor('#9E9E9E')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding(8)
.borderRadius(4)
.backgroundColor(index === this.currentSongIndex ? '#E3F2FD' : 'transparent')
.onClick(() => {
this.changeSong(index)
})
}
})
}
.width('100%')
.height(120)
}
}
.width('100%')
}
}
6.4 实现歌曲切换功能
private changeSong(index: number) {
// 更新当前歌曲索引
this.currentSongIndex = index
// 更新歌曲信息
const song = this.songs[index]
// 重置播放状态
this.currentTime = 0
this.duration = song.duration
// 如果正在播放,重新开始旋转动画
if (this.isPlaying) {
this.stopRotation()
this.startRotation()
}
}
private playPrevious() {
const newIndex = (this.currentSongIndex - 1 + this.songs.length) % this.songs.length
this.changeSong(newIndex)
}
private playNext() {
const newIndex = (this.currentSongIndex + 1) % this.songs.length
this.changeSong(newIndex)
}
7. 主题与样式定制
7.1 定义主题颜色
// 主题颜色
private themeColor: string = '#1E88E5' // 默认蓝色主题
// 可选主题颜色列表
private themeColors: string[] = [
'#1E88E5', // 蓝色
'#43A047', // 绿色
'#E53935', // 红色
'#FB8C00', // 橙色
'#5E35B1' // 紫色
]
7.2 添加主题选择器
// 主题选择器
GridRow({ columns: 5 }) {
ForEach(this.themeColors, (color: string) => {
GridCol({ span: 1 }) {
Circle({ width: 20, height: 20 })
.fill(color)
.stroke(this.themeColor === color ? '#000000' : 'transparent')
.strokeWidth(2)
.margin(4)
.onClick(() => {
this.themeColor = color
})
}
})
}
.margin({ top: 16 })
7.3 应用主题颜色
// 应用主题颜色到进度条
Slider({
value: this.currentTime,
min: 0,
max: this.duration,
step: 1,
style: SliderStyle.OutSet
})
.width('70%')
.onChange((value) => {
this.currentTime = value
})
.margin({ left: 8, right: 8 })
.trackColor('#E0E0E0')
.selectedColor(this.themeColor) // 使用主题颜色
8. 完整优化代码
下面是整合了上述优化的完整代码:
// 音乐播放器网格布局(优化版)
interface Song {
title: string;
artist: string;
duration: number;
cover: Resource;
}
@Component
export struct MusicPlayerGridAdvanced {
@State isPlaying: boolean = false
@State currentTime: number = 0
@State duration: number = 180 // 3分钟
@State rotationAngle: number = 0
@State rotationAnimator: AnimatorResult | null = null
@State buttonScale: number = 1.0
@State showPlaylist: boolean = false
@State timer: number = 0
@State currentSongIndex: number = 0
@State themeColor: string = '#1E88E5' // 默认蓝色主题
private songs: Song[] = [
{ title: '夜曲', artist: '周杰伦', duration: 180, cover: $r('app.media.big14') },
{ title: '七里香', artist: '周杰伦', duration: 210, cover: $r('app.media.big14') },
{ title: '晴天', artist: '周杰伦', duration: 200, cover: $r('app.media.big14') },
{ title: '稻香', artist: '周杰伦', duration: 190, cover: $r('app.media.big14') }
]
private themeColors: string[] = [
'#1E88E5', // 蓝色
'#43A047', // 绿色
'#E53935', // 红色
'#FB8C00', // 橙色
'#5E35B1' // 紫色
]
aboutToAppear() {
// 创建定时器,每秒更新一次播放进度
this.timer = setInterval(() => {
if (this.isPlaying && this.currentTime < this.duration) {
this.currentTime += 1
// 如果播放完毕,停止播放
if (this.currentTime >= this.duration) {
this.isPlaying = false
this.stopRotation()
}
}
}, 1000) as number
}
aboutToDisappear() {
// 清除定时器
if (this.timer) {
clearInterval(this.timer)
this.timer = 0
}
// 停止动画
this.stopRotation()
}
build() {
Column() {
// 专辑封面
GridRow({
columns: { xs: 1, sm: 1, md: 1, lg: 1 },
gutter: { x: 16, y: 16 }
}) {
GridCol({ span: { xs: 1, sm: 1, md: 1, lg: 1 } }) {
Image(this.songs[this.currentSongIndex].cover)
.width(this.getAlbumSize())
.height(this.getAlbumSize())
.borderRadius(this.getAlbumSize() / 2)
.margin({ top: 32, bottom: 32 })
.rotate({ z: 1, angle: this.rotationAngle })
}
}
// 歌曲信息
GridRow({ columns: 1 }) {
GridCol({ span: 1 }) {
Column() {
Text(this.songs[this.currentSongIndex].title)
.fontSize(24)
.fontWeight(FontWeight.Bold)
Text(this.songs[this.currentSongIndex].artist)
.fontSize(16)
.fontColor('#9E9E9E')
.margin({ top: 8 })
}
}
}
// 进度条
GridRow({ columns: 1 }) {
GridCol({ span: 1 }) {
Row() {
Text(this.formatTime(this.currentTime))
.fontSize(12)
.fontColor('#9E9E9E')
Slider({
value: this.currentTime,
min: 0,
max: this.duration,
step: 1,
style: SliderStyle.OutSet
})
.width('70%')
.onChange((value) => {
this.currentTime = value
})
.margin({ left: 8, right: 8 })
.trackColor('#E0E0E0')
.selectedColor(this.themeColor)
.showTips(true)
.showSteps(false)
Text(this.formatTime(this.duration))
.fontSize(12)
.fontColor('#9E9E9E')
}
.width('100%')
.margin({ top: 24, bottom: 24 })
}
}
// 控制按钮
GridRow({
columns: { xs: 3, sm: 5, md: 5, lg: 5 },
gutter: { x: 8, y: 8 }
}) {
GridCol({ span: { xs: 0, sm: 1, md: 1, lg: 1 } }) {
Image($r('app.media.01'))
.width(24)
.height(24)
.onClick(() => {
this.playPrevious()
})
}
GridCol({ span: { xs: 1, sm: 1, md: 1, lg: 1 } }) {
Image($r('app.media.02'))
.width(24)
.height(24)
.onClick(() => {
this.playPrevious()
})
}
GridCol({ span: { xs: 1, sm: 1, md: 1, lg: 1 } }) {
Image(this.isPlaying ? $r('app.media.03') : $r('app.media.01'))
.width(32 * this.buttonScale)
.height(32 * this.buttonScale)
.onClick(() => {
this.isPlaying = !this.isPlaying
if (this.isPlaying) {
this.startRotation()
} else {
this.stopRotation()
}
this.playButtonAnimation()
})
.stateStyles({
normal: {
.opacity(1)
.scale({ x: 1, y: 1 })
},
pressed: {
.opacity(0.8)
.scale({ x: 0.9, y: 0.9 })
},
hover: {
.opacity(1)
.scale({ x: 1.1, y: 1.1 })
}
})
}
GridCol({ span: { xs: 1, sm: 1, md: 1, lg: 1 } }) {
Image($r('app.media.04'))
.width(24)
.height(24)
.onClick(() => {
this.playNext()
})
}
GridCol({ span: { xs: 0, sm: 1, md: 1, lg: 1 } }) {
Image($r('app.media.05'))
.width(24)
.height(24)
.onClick(() => {
this.playNext()
})
}
}
.margin({ top: 16, bottom: 32 })
// 主题选择器
GridRow({ columns: 5 }) {
ForEach(this.themeColors, (color: string) => {
GridCol({ span: 1 }) {
Circle({ width: 20, height: 20 })
.fill(color)
.stroke(this.themeColor === color ? '#000000' : 'transparent')
.strokeWidth(2)
.margin(4)
.onClick(() => {
this.themeColor = color
})
}
})
}
.margin({ top: 16 })
// 播放列表(可折叠)
GridRow({ columns: 1 }) {
GridCol({ span: 1 }) {
Column() {
// 播放列表标题
Row() {
Text('播放列表')
.fontSize(16)
.fontWeight(FontWeight.Bold)
Image($r('app.media.01')) // 展开/折叠图标
.width(16)
.height(16)
.margin({ left: 8 })
.onClick(() => {
this.showPlaylist = !this.showPlaylist
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 16, bottom: 8 })
// 播放列表内容(可折叠)
if (this.showPlaylist) {
List() {
ForEach(this.songs, (song: Song, index: number) => {
ListItem() {
Row() {
Text(`${index + 1}. ${song.title}`)
.fontSize(14)
Text(song.artist)
.fontSize(12)
.fontColor('#9E9E9E')
.margin({ left: 8 })
Text(this.formatTime(song.duration))
.fontSize(12)
.fontColor('#9E9E9E')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding(8)
.borderRadius(4)
.backgroundColor(index === this.currentSongIndex ? '#E3F2FD' : 'transparent')
.onClick(() => {
this.changeSong(index)
})
}
})
}
.width('100%')
.height(120)
}
}
.width('100%')
}
}
}
.width('100%')
.height('100%')
}
private formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs < 10 ? '0' + secs : secs}`
}
private getAlbumSize(): number {
// 根据屏幕宽度返回不同的尺寸
// 这里仅作示例,实际应用中可以使用媒体查询或其他方式获取屏幕信息
return 200; // 默认尺寸
}
private startRotation() {
// 创建旋转动画
this.rotationAnimator = this.createRotationAnimator()
// 启动动画
this.rotationAnimator?.play()
}
private stopRotation() {
// 停止动画
this.rotationAnimator?.pause()
}
private createRotationAnimator(): AnimatorResult {
// 创建一个从0到360度的旋转动画,持续时间为10秒,无限循环
return createAnimator({
duration: 10000, // 10秒
iterations: -1, // 无限循环
curve: Curve.Linear, // 线性变化
playMode: PlayMode.Normal,
onframe: (progress: number) => {
this.rotationAngle = progress * 360
}
})
}
private playButtonAnimation() {
// 创建一个缩放动画
const scaleAnimator = createAnimator({
duration: 200,
curve: Curve.FastOutSlowIn,
iterations: 1,
playMode: PlayMode.Normal,
onframe: (progress: number) => {
// 从0.8缩放到1.1再回到1
const scale = 0.8 + (progress < 0.5 ? progress * 0.6 : (1 - progress) * 0.2 + 0.9)
// 应用缩放
this.buttonScale = scale
}
})
scaleAnimator.play()
}
private changeSong(index: number) {
// 更新当前歌曲索引
this.currentSongIndex = index
// 更新歌曲信息
const song = this.songs[index]
// 重置播放状态
this.currentTime = 0
this.duration = song.duration
// 如果正在播放,重新开始旋转动画
if (this.isPlaying) {
this.stopRotation()
this.startRotation()
}
}
private playPrevious() {
const newIndex = (this.currentSongIndex - 1 + this.songs.length) % this.songs.length
this.changeSong(newIndex)
}
private playNext() {
const newIndex = (this.currentSongIndex + 1) % this.songs.length
this.changeSong(newIndex)
}
}
9. GridRow和GridCol的高级应用
9.1 嵌套网格布局
在复杂的UI中,我们可以嵌套使用GridRow和GridCol来创建更灵活的布局:
GridRow({ columns: 2 }) {
GridCol({ span: 1 }) {
// 左侧内容
GridRow({ columns: 1 }) {
GridCol({ span: 1 }) {
// 嵌套内容
}
}
}
GridCol({ span: 1 }) {
// 右侧内容
}
}
9.2 不同断点下的列偏移
使用offset属性可以在不同断点下设置列偏移:
GridRow({ columns: 12 }) {
GridCol({
span: { xs: 12, sm: 8, md: 6, lg: 4 },
offset: { xs: 0, sm: 2, md: 3, lg: 4 }
}) {
// 内容
}
}
这个配置表示:
- 在极小屏幕(xs)上,内容占据12列,无偏移
- 在小屏幕(sm)上,内容占据8列,偏移2列
- 在中等屏幕(md)上,内容占据6列,偏移3列
- 在大屏幕(lg)上,内容占据4列,偏移4列
9.3 列顺序调整
使用order属性可以调整列的显示顺序:
GridRow({ columns: 3 }) {
GridCol({
span: 1,
order: { xs: 3, sm: 1 }
}) {
// 内容A
}
GridCol({
span: 1,
order: { xs: 1, sm: 2 }
}) {
// 内容B
}
GridCol({
span: 1,
order: { xs: 2, sm: 3 }
}) {
// 内容C
}
}
这个配置表示:
- 在极小屏幕(xs)上,显示顺序为:B -> C -> A
- 在小屏幕(sm)及以上,显示顺序为:A -> B -> C
10. 响应式布局最佳实践
10.1 移动优先设计
在设计响应式布局时,应采用移动优先的策略,先为最小屏幕设计布局,然后逐步扩展到更大的屏幕:
GridRow({
columns: { xs: 1, sm: 2, md: 3, lg: 4 }
}) {
// 内容
}
10.2 内容优先级
根据内容的重要性,在不同屏幕尺寸下显示或隐藏内容:
GridCol({
span: { xs: 1, sm: 1, md: 1, lg: 1 }
}) {
// 核心内容,在所有屏幕尺寸下都显示
}
GridCol({
span: { xs: 0, sm: 1, md: 1, lg: 1 }
}) {
// 次要内容,在小屏幕上隐藏
}
10.3 断点选择策略
在选择断点时,应考虑常见设备的屏幕尺寸:
断点 | 适用设备 |
---|---|
xs (< 320vp) | 小型手机 |
sm (≥ 320vp) | 标准手机 |
md (≥ 520vp) | 大型手机/小型平板 |
lg (≥ 840vp) | 平板 |
xl (≥ 1080vp) | 大型平板/小型桌面 |
xxl (≥ 1280vp) | 桌面 |
11. 总结
本教程深入探讨了如何优化和扩展音乐播放器的网格布局,包括:
- 响应式布局设计:通过配置断点和列数,使布局能够适应不同屏幕尺寸
- 专辑封面旋转动画:添加旋转动画,增强视觉效果
- 播放控制按钮的交互优化:添加状态样式和点击动画,提升交互体验
- 进度条的高级定制:自定义进度条样式,添加播放进度自动更新
- 音乐播放列表的实现:添加可折叠的播放列表,支持歌曲切换
- 主题与样式定制:添加主题选择器,支持多种颜色主题
- GridRow和GridCol的高级应用:嵌套网格布局、列偏移和列顺序调整
- 响应式布局最佳实践:移动优先设计、内容优先级和断点选择策略
通过这些优化,音乐播放器不仅在视觉上更加吸引人,而且在功能和用户体验上也得到了显著提升。这些技术和方法不仅适用于音乐播放器,也可以应用到其他需要网格布局的场景中。
在实际应用中,可以根据具体需求和设计风格,选择性地应用这些优化方法,打造出既美观又实用的用户界面。
- 0回答
- 3粉丝
- 0关注
- [HarmonyOS NEXT 实战案例十三] 音乐播放器网格布局(上)
- [HarmonyOS NEXT 实战案例:音乐播放器] 基础篇 - 水平分割布局打造音乐播放器界面
- [HarmonyOS NEXT 实战案例:音乐播放器] 进阶篇 - 交互式音乐播放器的状态管理与控制
- HarmonyOS NEXT 应用开发实战:音乐播放器的完整实现
- [HarmonyOS NEXT 实战案例四] 天气应用网格布局(下)
- [HarmonyOS NEXT 实战案例六] 餐饮菜单网格布局(下)
- [HarmonyOS NEXT 实战案例七] 健身课程网格布局(下)
- [HarmonyOS NEXT 实战案例九] 旅游景点网格布局(下)
- [HarmonyOS NEXT 实战案例十] 电子书网格布局(下)
- [HarmonyOS NEXT 实战案例十四] 任务管理看板网格布局(下)
- [HarmonyOS NEXT 实战案例十七] 设置选项列表网格布局(下)
- [HarmonyOS NEXT 实战案例三] 音乐专辑网格展示(下)
- [HarmonyOS NEXT 实战案例五] 社交应用照片墙网格布局(下)
- [HarmonyOS NEXT 实战案例一] 电商首页商品网格布局(下)
- [HarmonyOS NEXT 实战案例八] 电影票务网格布局(下)