项目地址:github 开源地址,码云开源地址
项目背景:刚刚结束的暑假小学期中,和我的两个神仙队友WYX,XPF(我的队友超级棒!)共同完成的第一个安卓开发项目——俄罗斯方块游戏。
框架和语言:kotlin+ jetpack compose (compose 是谷歌最新推出的开发框架,AndroidStudio刚出了支持compose的稳定版本)
参考项目:参考了https://github.com/leavesC/compose_tetris
在此基础上增加了许多丰富的功能(如切换游戏难度,切换背景音乐,记录最高分…)
应用名称:鹅螺蛳方块
应用类型:休闲游戏
小组成员:WYX、ZYW、XPF
开发时间:2021年7月26日 - 2021年8月14日
本组使用 Android studio 作为集成式开发环境,完全自主学习 Kotlin 编程语言和 Jetpack Compose 框架,编写了一个功能齐全、具有动态界面的俄罗斯方块安卓游戏。
主要工作包括:搭建 Compose Activity 项目环境,全栈开发实现俄罗斯方块的后端游戏逻辑、界面交互、前端界面设计、音效播放、动画效果等功能(详见“设计需求”一节)。
学习使用全新的 Jetpack Compose 框架是本项目最大的挑战。Jetpack Compose 是谷歌推出的新式声明性界面工具包 ,包括一整套全新的渲染、布局、事件、刷新机制,需要一段时间才能入门,但有效提高了本小组的开发效率。
本文档用于描述《计算机科学与技术专业实践与训练》课程所编写程序的设计方案,文档阅读对象为本课程授课教师及本课堂同学。
Jetpack Compose 是用于构建原生界面的最新的 Android 工具包,结合了反应式编程模型和 Kotlin 编程语言的简洁性和易用性。
游戏的执行程序可以概括为不断等待用户的输入信息,进行状态查询,渲染界面的过程。而这种游戏模型的逻辑非常符合前端开发的思想:数据驱动界面。
当下的小游戏多以前端为主,客户端开发成本较大,而使用 Compose 可以降低开发成本。Compose 实现数据驱动是依赖类似 Flutter 的 Provide 一样的更新机制,但并没有采用采用了传统 UI 的多层继承结构,而是多个 View 组合成一个 View ,更新数据只要在使用的实体上面加注解 @Model ,在更新实体的同时,会通知 UI 进行修改。
MVI 即 Model-View-Intent ,提倡一种单向数据流的设计思想,非常适合在 Compose 项目中实现逻辑部分,可以彻底贯彻“数据驱动 UI”的核心思想。
如下表所示,所有源代码部分都在 com.android_tetris 包内:
本程序完全使用 Kotlin 语言开发,并选用 Jetpack Compose 框架作为前端框架。
我们完全自主学习了 Jetpack Compose (以下简称 Compose )这个还未推出正式版的声明式 UI 框架。由于谷歌暂未推出 Compose 的正式版本,现在网上相关资料和教程都还非常稀少,API文档还是全英文的,我们常常在意想不到的问题上被卡住还没有什么办法。
虽然入门过程极其艰难,但是在学习使用 Compose 的全新框架开发的过程中,我们的英语水平、信息检索能力、解决问题的能力,都得到了极大的锻炼,相信这段艰苦的自主学习经历对我们之后的编程之路一定有所帮助。
由于这是一个工程量比较大的项目,我们采用 Gitee 进行版本管理。但在使用过程中,我们上传经常遇到“文件超过 100M ”的错误提示。
为解决这一问题,我们学习了 .gitignore 文件的使用方法:在该文件按优先级从高到低,写明让 Git 仓库上传时忽略掉的文件目录/后缀名, Git 就会主动忽略这些文件。
我们可以用这一办法处理项目编译产生的文件和庞大的开发环境文件。
开发初期,由于 Kotlin 和 Compose 还在频繁更新 API ,版本迭代很快,因此我们遇到了很多次 build 不成功的情况,报错位置都出现在 build.gradle 文件。
查阅资料发现, Project 层级和 Module 层级各有相对应的两个 build.gradle 文件,在 Android studio 中分别显示为 build.gradle (Project: 项目名) 和 build.gradle (Module: 项目名.app) 。
在 build.gradle (Project: 项目名) 中, buildscript { } 代码块是该项目的 gradle 配置,只存放用到的代码托管库和项目构建级别的依赖:
buildscript {
ext {
compose_version = '1.0.0-beta08'
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.0-alpha02'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.10"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
// (此处的注释提示:不要把应用程序的依赖库在此引用)
}
}
在 build.gradle (Module: 项目名.app) 中, build.gradle 主要用于配置模块级别的配置信息和依赖:
plugins {
id 'com.android.application'
id 'kotlin-android'
}
android {
compileSdk 30
buildToolsVersion "30.0.3"
defaultConfig {
applicationId "com.android_tetris"
minSdk 21
targetSdk 30
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
}
// 输出 APK 用到的应用签名信息
signingConfigs {
releaseConfig {
storeFile file("..\\key.jks")
storePassword "123456"
keyAlias "key0"
keyPassword "123456"
v1SigningEnabled true
v2SigningEnabled true
}
}
buildTypes {
debug {
signingConfig signingConfigs.releaseConfig
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
release {
signingConfig signingConfigs.releaseConfig
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
useIR = true
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
kotlinCompilerVersion '1.5.10'
}
}
dependencies {
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.3.0'
...
}
为遵守 MVI 设计逻辑,对于游戏界面,所有会由游戏内行为发出再导致前端显示刷新的变化,都集中存放在 TetrisState.kt 文件中。界面的所有变化都依赖后端 State 的变化而刷新。
俄罗斯方块共有 7 种形状,每种形状有随着旋转还会有几种不同形态,本项目在 MockData.kt 文件中选用 44 的二维 IntArray 数组储存方块形状,在 TetrisState.kt 中选用 1024 的二维 IntArray 数组表示存放方块的屏幕。
以下是 TetrisState.kt 文件中一部分重要函数和类的代码:
// class Tetris:对于单个俄罗斯方块的 State 状态类
data class Tetris constructor(
val shapes: List<List<Location>>, // 此方块所有可能的旋转结果
val type: Int, // 用于标记当前处于哪种旋转状态
val offset: Location, // 方块相对屏幕左上角的偏移量
) { ... }
// class TetrisState:游戏的 State 状态类
data class TetrisState(
val brickArray: Array<IntArray>, // 屏幕坐标系
val tetris: Tetris, // 下落的方块
val gameStatus: GameStatus = GameStatus.Welcome, // 游戏状态
val soundEnable: Boolean = true, // 是否开启音效
val nextTetris: Tetris = Tetris(), // 下一个方块
var currentScore: Int = infoStorage.currentScore // 当前分数
) { ... }
// class Action:接受用户 View 层次传入信号的 Action 类
sealed class Action {
object Welcome : Action()
object Start : Action()
object Pause : Action()
object Reset : Action()
object Sound : Action()
object Settings : Action()
object Background : Action()
object Resume : Action()
data class Transformation(val transformationType: TransformationType) : Action()
}
// fun combinedPlayListener() 函数:Action 行为的监听器,监听器接收到信号后,分别再发送给 private fun TetrisState.onStart() 等 TetrisState 的私有函数,进行处理
fun combinedPlayListener(
onStart: () -> Unit = {},
onPause: () -> Unit = {},
onReset: () -> Unit = {},
onSound: () -> Unit = {},
onSettings: () -> Unit = {},
onTransformation: (TransformationType) -> Unit = {}
) = PlayListener(
onStart = onStart,
onPause = onPause,
onReset = onReset,
onSound = onSound,
onSettings = onSettings,
onTransformation = onTransformation
)
5.5.View 层和 Model 层的交互机制
以下是 TetrisViewModel 类声明部分代码 :
class TetrisViewModel : ViewModel() {
//功能:接收 Action 信号,由用户的动作改变 ViewModel
fun dispatch(action: Action) {...}
//功能:改变下落速度
fun changeDownSpeed(newSpeed :Long){...}
//功能:初始进入软件时的欢迎效果
private fun onWelcome() {...}
//功能:开始游戏
private fun onStartGame(){...}
//功能:暂停游戏
private fun onPauseGame(){...}
//功能:结束游戏
private fun onGameOver(){...}
//功能:开始清空界面
private fun startClearScreenJob(invokeOnCompletion: () -> Unit){...}
//功能:取消清空界面
private fun cancelClearScreenJob(){...}
//功能:方块下降
private fun startDownJob() {...}
//功能:暂停方块下降
private fun cancelDownJob() {...}
//功能:将当前界面状态传递给用户
private fun dispatchState(tetrisState: TetrisState) {..}
//功能:游戏背景音乐播放
private fun playSound(action: Action) {...}
//功能:播放不同类型的背景音乐
private fun playSound(soundType: SoundType){...}
}
由于文档篇幅有限,详细的媒体内容展示可参考源代码、 PPT、和应用展示视频。
主要用到了 Compose 的自定义图像核心可组合项 Canvas ,预览效果如下图:
主要逻辑功能包括:绘制游戏屏幕背景、绘制以难度指定速度不断下落的方块、为方块提供按键移动功能、判断是否进行消行、如果方块超出当前屏幕则结束游戏。
代码如下:
// 源文件:TetrisScreen.kt
package com.android_tetris.ui
import...
@Composable
fun TetrisScreen(tetrisState: TetrisState) {...
Canvas(modifier=Modifier.(...)){...
kotlin.run { //绘制方块矩阵
screenMatrix.forEachIndexed { y, ints ->
ints.forEachIndexed { x, isFill ->
translate(...) {drawBrick(...)}
}
}
}
kotlin.run { ...drawPath(...)}//绘制下落方块
kotlin.run { drawRightPanel(...)}//绘制右侧得分栏
kotlin.run { drawHint(...)} //绘制提示文字
}
}
//绘制单个方块
fun DrawScope.drawBrick(brickSize: Float,//每一个方块的size
color: Color,//砖块颜色
background: Color //背景颜色
) {...
translate(...) {drawRect(...)}//绘制外部矩形边框
translate(...) {drawRect(...)}//绘制内部矩形边框
}
private fun DrawScope.drawRightPanel(...) {...}//绘制右侧得分栏
private fun DrawScope.drawHint(...) {...}//绘制提示文字
ConstraintLayout 是一种根据可组合项的相对位置关系显示的布局类型,代码如下:
//TetrisButton.kt
fun TetrisButton(
playListener: PlayListener = combinedPlayListener()
) {
Column(...){
Row(...){
ControlButton(...){ playListener.onStart()}//开始游戏
ControlButton(...){ playListener.onPause()}//暂停游戏
ControlButton(...){ playListener.onReset()}//重新开始
ControlButton(...){ playListener.onSound()}//音乐开关
}
ConstraintLayout(...){
PlayButton(...){playListener.onTransformation(Rotate)}//旋转方块
PlayButton(...){playListener.onTransformation(Fall)}//方块加速下落
PlayButton(...){ playListener.onTransformation(Left)}//方块左移
PlayButton(...){playListener.onTransformation(Right)}//方块右移
PlayButton(...){playListener.onTransformation(FastDown)}//方块直接降落到最底部
}
}
}
代码如下:
// TetrisBody.kt
package com.android_tetris.ui
fun TetrisBody(
tetrisScreen: @Composable (() -> Unit),
tetrisButton: @Composable (() -> Unit),
){...
val size by animateDpAsState(...)//改变大小状态
val color by infiniteTransition.animateColor(..,)//改变颜色状态
Column(...){
TopBar(...){
Row(...){
Icon(...); Text(...)//转到setting界面
Icon(...); Text(...)//转到More界面
Icon(...); Text(...)//增大屏幕
Icon(...); Text(...)//减小屏幕
}
}
Box(...){
Column(...){ tetrisScreen()}//游戏屏幕区
}
}
tetrisButton()//游戏按钮区
}
代码如下:
//TetrisSettingScreen.kt
fun SettingsScreen(){
Box(modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())//垂直滚动
){
Box(...){
Box{
Column(...){
Box(...){
LoginPageTopBlurImage(...)//顶部模糊背景
LoginPageTopRotaAndScaleImage(...)//顶部旋转头像
}
Column(...){
Row{
Text(...)//game setting 图标
if(...){... PlanetMoon()}//黑夜模式展示月亮动画
else AnimateSun(Modifier.size(50.dp))//白天模式展示太阳
}
ConstraintLayout(...){...
Row(...){...//难度选择
Column(...){
RadioSelectionButton(label = "Easy",...)//简单
RadioSelectionButton(label = "Normal",...)//普通
RadioSelectionButton(label = "Hard",...)//困难
}
}
Row(...){...//主题选择
Column(...){
RectangularButton(...)
{viewModel.Theme = ComposeTetrisTheme.Theme.Light} //白天模式
RectangularButton(...)
{viewModel.Theme = ComposeTetrisTheme.Theme.Dark} //黑夜模式
}
}
Row(...){...//音乐切换
RectangularButton(...){when(infoStorage.bgm){...}}
}
}
Text( text = "Player Profile",...)//个人主页标题
ConstraintLayout(...){
Row(...){...//用户名
TextField(...)
}
Row(...){...//最高分
Text(text = "$highest",...)
}
Row(...){...//帮助文档
DropDownAnimate(...){Text(...)}
}
}
}
}
}
}
}
}
代码如下:
//AboutUsScreen.kt
package com.android_tetris
...
fun AboutUsScreen(){
Box(modifier = Modifier.verticalScroll(rememberScrollState())//垂直滚动界面
.background(color = Color.Black)
){
Column(...){
topBarView_More() //顶部导航栏
Text( buildAnnotatedString {...})//彩虹式标题
BlinkTag {...}//提示文字,点击图片
Row(...){InkColorCanvas()} // 水墨渲染图片
Row(...){Parallax()}//3D安卓小人,展示开发信息
Row(...){
Text(text = "Our Faith",...)//标题
Spacer(...)//间距
Image(...)//月亮图片
}
Row(...){...
BoxWithConstraints(...){
Rocket(...)//发射动画
LaunchButton(...)//发射按钮
}
}
Text(text = "Message Board",...)//标题
Image(...)//胶片上边框
Box( modifier = Modifier.horizontalScroll(rememberScrollState())...
){//横向滚动
Row{
Box(...){ ImageCard(...)}...//留言卡片一
Box(...){ ImageCard(...)}...//留言卡片二
Box(...){ ImageCard(...)}...//留言卡片三
}
}
Text(text = "Contact us",...)//标题
Text(...)//提示信息
Box( modifier = Modifier.horizontalScroll(rememberScrollState())...
){//横向滚动
Row{
Box(...){Card(...){...}}//项目地址信息
Box(...){Card(...){...}}//开发者邮箱
Box(...){Card(...){...}}//开发者码云账户
}
}
}
}
}
最开始,我们想借助 Jetpack Navigation 框架的接口实现界面跳转,但进行了很多天的尝试也未能成功。
随着我们更加深入理解 Compose 的特性,我们认识到 Compose 作为“声明式界面”本身就能够自动更新其中的数据。因此,可以定义一个顶级声明变量,使该变量指代当前应该显示的屏幕,从而采用选择性显示或隐藏某界面的方式,间接实现界面切换功能。
// MainActivity.kt 中选择性显示界面的代码
ComposeTetrisTheme(viewModel.Theme) {
when (infoStorage.currentScreen) {
0 -> {
TetrisGameScreen()
}
1 -> {
SettingsScreen()
}
2 -> {
AboutUsScreen()
}
}
}
//Topbar.kt
package com.android_tetris.ui.theme
//设置界面导航栏
@Composable
fun topBarView_Set(){...}
//关于我们界面导航栏
@Preview
@Composable
fun topBarView_More(){...}
定义主题颜色:分白天和黑夜两个主题,对游戏界面背景颜色、设置界面背景颜色、字体和按钮等颜色进行设置。根据用户所选择的主题,确定颜色组合,赋值给 MaterialTheme 的 colors 成员。
// 黑色主题
private val DarkColorPalette = darkColors( ... )
// 白天主题
private val LightColorPalette = lightColors( ... )
构建 ComposeTetrisTheme 对象,用枚举类存放白天黑夜两个主题供用户选择。
object ComposeTetrisTheme {
enum class Theme {
Light, Dark
}
}
编写主题设置函数:定义 fun ComposeTetrisTheme () 函数,该函数第一个参数接收一个主题对象参数,用于参数判断当前的主题类型;第二个参数接收一个 content 参数确定主题函数作用范围。将变量 colors 传给 MaterialTheme。
fun ComposeTetrisTheme(
theme: ComposeTetrisTheme.Theme = ComposeTetrisTheme.Theme.Light,
content: @Composable () -> Unit
) {
val colors = if (theme == ComposeTetrisTheme.Theme.Dark)
DarkColorPalette
else
LightColorPalette
MaterialTheme(
colors = colors,
typography = Typography,
shapes = Shapes,
content = content
)
}
设置按钮事件实现主题切换:绘制两个按钮分别代表白天模式和黑夜模式,效果是点击按钮 day mode,将 viewModel.Theme 置为 Light,点击按钮 Night mode,将 viewModel.Theme 置为 Dark。主题 Theme 的控制变量放在 ThemeViewModel 类中。 ThemeViewModel 继承自 ViewModel , ViewModel 旨在以生命周期意识的方式,存储和管理用户界面相关的数据,目的是存放应用程序页面所需的数据。这样页面只需要处理用户交互,以及负责展示数据的工作。
ThemeViewModel 只包含一个成员 var Theme ,用 by mutableStateOf(ComposeTetrisTheme.Theme.Light) 初始化。其中 mutableStateOf 函数的作用是显式标明这个 Theme 是有状态的,如果 Theme 的状态发生了改变,所有引用这个状态的控件都需要重新绘制。
var viewModel : ThemeViewModel = viewModel()
Column(modifier = Modifier.fillMaxWidth()) {
RectangularButton(
label = "Daytime mode",
) {
viewModel.Theme = ComposeTetrisTheme.Theme.Light
}
RectangularButton(
label = "Night mode",
) {
viewModel.Theme = ComposeTetrisTheme.Theme.Dark
}
}
class ThemeViewModel : ViewModel() {
// 选择夜间模式还是普通配色
var Theme by mutableStateOf(ComposeTetrisTheme.Theme.Light)
}
Jetpack Compose 为各种动画效果提供实验性 API,可能会在之后的版本中逐步完善:
// Animation.kt
package com.android_tetris.ui.theme
// 绘制动态月亮
@Composable
fun PlanetMoon() {...}// 插入月亮图片
@Composable
private fun buildEarthFloatAnimation(): State<Float>{...}//实现月亮垂直方向浮动效果
@Composable
private fun buildEarthRotationAnimation(): State<Float> {...}//实现月亮沿着竖直轴旋转效果
// 水墨渲染动画
@Composable
fun InkColorCanvas() {...}
// 旋转太阳的动画
@Composable
fun AnimateSun(modifier: Modifier = Modifier){...}
@Composable
fun Sun(modifier: Modifier = Modifier) {...}
// 3D页面下翻动画
@Composable
fun DropDownAnimate(
text: String,
modifier: Modifier = Modifier,
initiallyOpened: Boolean = false,
content: @Composable () -> Unit
) {...}
//3D 安卓小人动画
fun Parallax() {...}
fun getRotationAngles(
start: Pair<Float, Float>,
end: Pair<Float, Float>,
size: Size
): Pair<Float, Float> {...}
fun getDistances(p1: Pair<Float, Float>, p2: Pair<Float, Float>): Pair<Float, Float> {...}
fun getTranslation(angle: Float, maxDistance: Float): Float {...}
// 火箭发射动画
@Composable
fun Rocket(
isRocketEnabled: Boolean,
maxWidth: Dp,
maxHeight: Dp
) {...}
//火箭发射按钮
@ExperimentalAnimationApi
@Composable
fun LaunchButton(
animationState: Boolean,
onToggleAnimationState: () -> Unit,
){...}
//ImageCard
@Composable
fun ImageCard(
painter: Painter,
contentDescription: String,
title: String,
modifier:Modifier=Modifier
){...}
// Login.kt
package com.android_tetris.ui.theme
import...
//登陆背景模糊头缩放部图片
@Composable
fun LoginPageTopBlurImage(
animatedBitmap: Animatable<Float, AnimationVector1D>,
animatedOffset: Animatable<Float, AnimationVector1D>,
animatedScales: Animatable<Float, AnimationVector1D>
) {...}
//登陆页面头部旋转缩放的图片
@Composable
fun LoginPageTopRotaAndScaleImage(
animatedColor: Animatable<androidx.compose.ui.graphics.Color, AnimationVector4D>,
animatedScales: Animatable<Float, AnimationVector1D>,
animatedOffset: Animatable<Float, AnimationVector1D>
) {...}
//圆形图片
@Stable
class CicleImageShape(val circle: Float = 0f) : Shape {
override fun createOutline(...): Outline {...}
}
//形状裁剪
@Stable
class QureytoImageShapes(var hudu: Float = 100f, var controller:Float=0f) : Shape {
override fun createOutline(...): Outline {...}
}
// 模糊效果
object BitmapBlur {
fun doBlur(sentBitmap: Bitmap, radiu: Int = 1, canReuseInBitmap: Boolean): Bitmap {}
}
//RainbowSpark.kt
package com.android_tetris.ui.theme
import...
// 彩虹渐变字体
@ExperimentalAnimationApi
@Composable
fun MultiColorSmoothText(
modifier: Modifier = Modifier,
text: String,
style: TextStyle = LocalTextStyle.current,
rainbow: List<Color> = PastelRainbow,
startIndex: Int = 0,
duration: Int
) {...}
//闪烁字体
@ExperimentalAnimationApi
@Composable
fun BlinkTag(
modifier: Modifier = Modifier,
duration: Int = 500000,
content: @Composable (modifier: Modifier) -> Unit
) {...}
Gitee:https://gitee.com/yxwang2023/android_tetris
GitHub:https://github.com/zyw-stu/Andriod_Tetris