Compose 正式发布,来打造一个 Flappy Bird! | 开发者说·DTalk
本文原作者:?小蝦米君,原文發(fā)布于:?TechMerger
https://mp.weixin.qq.com/s/Hpd2NF0hOw4xOo3wVb_VFg
之前看到 fun 神用 Compose 打造的俄羅斯方塊,深受啟發(fā),萌生了也打造一個游戲的想法。正值 Compose 1.0 的正式發(fā)布,及時跟進(jìn)一下 Compose 的學(xué)習(xí)!
Flappy Bird 是 13 年紅極一時的小游戲,其簡單有趣的玩法和變態(tài)的難度形成了強(qiáng)烈反差,引發(fā)全球玩家競相把玩,欲罷不能!遂選擇復(fù)刻這個小游戲,在實現(xiàn)的過程中向大家演示 Compose 工具包的 UI 組合、數(shù)據(jù)驅(qū)動等重要思想。
拆解游戲
不記得這個游戲或完全沒玩過的朋友,可以前往體驗一下 Flappy Bird 的玩法:?https://flappybird.io/
為拆解游戲,筆者也錄了一段游戲過程。
反復(fù)觀看這段 GIF,可以發(fā)現(xiàn)游戲的一些規(guī)律:
遠(yuǎn)處的建筑和近處的土壤是靜止不動的
小鳥一直在上下移動,伴隨著翅膀和身體的飛翔姿態(tài)
管道和路面則不斷地向左移動,營造出小鳥向前飛翔的視覺效果
通過截圖、切圖、填充像素和簡單的 PS,可以拿到各元素的圖片。
復(fù)刻畫面
各方卡司已就位,接下來開始布置整個畫面。暫不實現(xiàn)元素的移動效果,先把靜態(tài)的整體效果搭建好。
布置遠(yuǎn)近景
靜止不動的建筑遠(yuǎn)景最為簡單,封裝到可組合函數(shù) FarBackground 里,內(nèi)部放置一張圖片即可。
@Composable fun FarBackground(modifier: Modifier) {Column {Image(painter = painterResource(id = R.drawable.background),contentScale = ContentScale.FillBounds,contentDescription = null,modifier = modifier.fillMaxSize())} }遠(yuǎn)景的下面由分割線、路面和土壤組成,封裝到 NearForeground 函數(shù)里。通過 Modifier 的 fraction 參數(shù)控制路面和土壤的比例,保證在不同尺寸屏幕上能按比例呈現(xiàn)游戲界面。
@Composable fun NearForeground(...) {Column( modifier ) {// 分割線Divider(color = GroundDividerPurple,thickness = 5.dp)// 路面Box(modifier = Modifier.fillMaxWidth()) {Image(painter = painterResource(id = R.drawable.foreground_road),...modifier = modifier.fillMaxWidth().fillMaxHeight(0.23f))}}// 土壤Image(painter = painterResource(id = R.drawable.foreground_earth),...modifier = modifier.fillMaxWidth().fillMaxHeight(0.77f))} }將整個游戲畫面抽象成 GameScreen 函數(shù),通過 Column 豎著排列遠(yuǎn)景和前景。考慮到移動的小鳥和管道需要呈現(xiàn)在遠(yuǎn)景之上,所以在遠(yuǎn)景的外面包上一層 Box 組件。
@Composable fun GameScreen( ... ) {Column( ... ) {Box(modifier = Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) {FarBackground(Modifier.fillMaxSize())}Box(modifier = Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) {NearForeground(modifier = Modifier.fillMaxSize())}} }擺放管道
仔細(xì)觀察管道,會發(fā)現(xiàn)一些管道具備朝上朝下、高度隨機(jī)的特點。為此將管道的視圖分拆成蓋子和柱子兩部分:
蓋子和柱子的放置順序決定管道的朝向
柱子的高度則控制著管道整體的高度這樣的話,只使用蓋子和柱子兩張圖片,就可以靈活實現(xiàn)各種形態(tài)的管道。
先來組合蓋子 PipeCover 和柱子 PipePillar 的可組合函數(shù)。
@Composable fun PipeCover() {Image(painter = painterResource(id = R.drawable.pipe_cover),contentScale = ContentScale.FillBounds,contentDescription = null,modifier = Modifier.size(PipeCoverWidth, PipeCoverHeight)) }@Composable fun PipePillar(modifier: Modifier = Modifier, height: Dp = 90.dp) {Image(painter = painterResource(id = R.drawable.pipe_pillar),contentScale = ContentScale.FillBounds,contentDescription = null,modifier = modifier.size(50.dp, height)) }管道的可組合函數(shù) Pipe 可以根據(jù)照朝向和高度的參數(shù),組合成對應(yīng)的管道。
@Composable fun Pipe( height: Dp = HighPipe,up: Boolean = true ) {Box( ... ) {Column {if (up) {PipePillar(Modifier.align(CenterHorizontally), height - 30.dp)PipeCover()} else {PipeCover()PipePillar(Modifier.align(CenterHorizontally), height - 30.dp)}}} }另外,管道都是成對出現(xiàn)、且無論高度如何中間的間距是固定的。所以我們再實現(xiàn)一個管道組的可組合函數(shù) PipeCouple。
@Composable fun PipeCouple( ... ) {Box(...) {GetUpPipe(height = upHeight,modifier = Modifier.align(Alignment.TopEnd))GetDownPipe(height = downHeight,modifier = Modifier.align(Alignment.BottomEnd))} }將 PipeCouple 添加到 FarBackground 的下面,管道就放置完畢了。
@Composable fun GameScreen( ... ) {Column(...) {Box(...) {FarBackground(Modifier.fillMaxSize())// 管道對添加遠(yuǎn)景上去PipeCouple(modifier = Modifier.fillMaxSize())}...} }放置小鳥
小鳥通過 Image 組件即可實現(xiàn),默認(rèn)情況下放置到布局的 Center 方位。
@Composable fun Bird( ... ) {Box( ... ) {Image(painter = painterResource(id = R.drawable.bird_match),contentScale = ContentScale.FillBounds,contentDescription = null,modifier = Modifier.size(BirdSizeWidth, BirdSizeHeight).align(Alignment.Center))} }視覺上小鳥呈現(xiàn)在管道的前面,所以 Bird 可組合函數(shù)要添加到管道組函數(shù)的后面。
@Composable fun GameScreen( ... ) {Column(...) {Box(...) {...PipeCouple( ... )// 將小鳥添加到遠(yuǎn)景上去Bird(modifier = Modifier.fillMaxSize(),state = viewState)}} }至此,各元素都放置完了。接下來著手讓小鳥,管道和路面這些動態(tài)元素動起來。
狀態(tài)管理和架構(gòu)
Compose 中 Modifier#offset() 函數(shù)可以更改視圖在橫縱方向上的偏移值,通過不斷地調(diào)整這個偏移值,即可營造出動態(tài)的視覺效果。無論是小鳥還是管道和路面,它們的移動狀態(tài)都可以依賴這個思路。
那如何管理這些持續(xù)變化的偏移值數(shù)據(jù)?如何將數(shù)據(jù)反映到畫面上?
Compose 通過 State 驅(qū)動可組合函數(shù)進(jìn)行重組,進(jìn)而達(dá)到畫面的重繪。所以我們將這些數(shù)據(jù)封到 ViewState 中,交由 ViewModel 框架計算和更新,Compose 訂閱 State 之后驅(qū)動所有元素活動起來。除了各元素的偏移值數(shù)據(jù),State 中還要存放游戲分值,游戲狀態(tài)等額外信息。
data class ViewState(val gameStatus: GameStatus = GameStatus.Waiting,// 小鳥狀態(tài)val birdState: BirdState = BirdState(),// 管道組狀態(tài)val pipeStateList: List<PipeState> = PipeStateList,var targetPipeIndex: Int = -1,// 路面狀態(tài)val roadStateList: List<RoadState> = RoadStateList,var targetRoadIndex: Int = -1,// 分值數(shù)據(jù)val score: Int = 0,val bestScore: Int = 0, )enum class GameStatus {Waiting,Running,Dying, Over }用戶點擊屏幕會觸發(fā)游戲開始、重新開始、小鳥上升等動作,這些視圖上的事件需要反向傳遞給 ViewModel 處理和做出響應(yīng)。事件由 Clickable 數(shù)據(jù)類封裝,再轉(zhuǎn)為對應(yīng)的 GameAction 發(fā)送到 ViewModel 中。
data class Clickable(val onStart: () -> Unit = {},val onTap: () -> Unit = {},val onRestart: () -> Unit = {},val onExit: () -> Unit = {} )sealed class GameAction {object Start : GameAction()object AutoTick : GameAction()object TouchLift : GameAction()object Restart : GameAction() }前面說過,可以不斷調(diào)整下 Offset 數(shù)據(jù)使得視圖動起來。具體實現(xiàn)可以通過 LaunchedEffect 啟動一個定時任務(wù),定期發(fā)送一個更新視圖的動作 AutoTick。注意: Compose 里獲取 ViewModel 實例發(fā)生 NoSuchMethodError 錯誤的話,記得按照官方構(gòu)建的版本重新 Sync 一下。
setContent {FlappyBirdTheme {Surface(color = MaterialTheme.colors.background) {val gameViewModel: GameViewModel = viewModel()LaunchedEffect(key1 = Unit) {while (isActive) {delay(AutoTickDuration)gameViewModel.dispatch(GameAction.AutoTick)}}Flappy(Clickable(onStart = {gameViewModel.dispatch(GameAction.Start)}...))}}ViewModel 收到 Action 后開啟協(xié)程,計算視圖的位置、更新對應(yīng) State,之后發(fā)射出去。
class GameViewModel : ViewModel() {fun dispatch(...) {response(action, viewState.value)}private fun response(action: GameAction, state: ViewState) {viewModelScope.launch {withContext(Dispatchers.Default) {emit(when (action) {GameAction.AutoTick -> run {// 路面,管道組以及小鳥移動的新State獲取...state.copy(gameStatus = GameStatus.Running,birdState = newBirdState,pipeStateList = newPipeStateList,roadStateList = newRoadStateList)}...})}}} }路面動起來
如果畫面上只放一張路面圖片,更改 X 軸 Offset 的話,剩余的部分會沒有路面,無法呈現(xiàn)出不斷移動的效果。
思前想后,發(fā)現(xiàn)放置兩張路面圖片可以解決: 一張放在屏幕外側(cè),一張放在屏幕內(nèi)側(cè)。游戲的過程中同時同方向移動兩張圖片,當(dāng)前一張圖片移出屏幕后重置其位置,進(jìn)而營造出道路不斷移動的效果。
@Composable fun NearForeground( ... ) {val viewModel: GameViewModel = viewModel()Column( ... ) {...// 路面Box(modifier = Modifier.fillMaxWidth()) {state.roadStateList.forEach { roadState ->Image(...modifier = modifier...// 不斷調(diào)整路面在x軸的偏移值.offset(x = roadState.offset))}}...if (state.playZoneSize.first > 0) {state.roadStateList.forEachIndexed { index, roadState ->// 任意路面的偏移值達(dá)到兩張圖片位置差的時候// 重置路面位置,重新回到屏幕外if (roadState.offset <= - TempRoadWidthOffset) {viewModel.dispatch(GameAction.RoadExit, roadIndex = index)}}}} }ViewModel 收到 RoadExit 的 Action 之后通知路面 State 進(jìn)行位置的重置。
管道動起來
設(shè)備屏幕寬度有限,同一時間最多呈現(xiàn)兩組管道就可以了。和路面運(yùn)動的思路類似,只需要放置兩組管道,就可以實現(xiàn)管道不停移動的視覺效果。
具體的話,兩組管道相隔一段距離放置,游戲中兩組管道一起同時向左移動。當(dāng)前一組管道運(yùn)動到屏幕外的時候,將其位置重置。
那如何計算管道移動到屏幕外的時機(jī)?
畫面重組的時候判斷管道偏移值是否達(dá)到屏幕寬度,YES 的話向 ViewModel 發(fā)送管道重置的 Action。
@Composable fun PipeCouple(modifier: Modifier = Modifier,state: ViewState = ViewState(),pipeIndex: Int = 0 ) {val viewModel: GameViewModel = viewModel()val pipeState = state.pipeStateList[pipeIndex]Box( ... ) {//從State中獲取管道的偏移值,在重組的時候讓管道移動 GetUpPipe(height = pipeState.upHeight,modifier = Modifier.align(Alignment.TopEnd).offset(x = pipeState.offset))GetDownPipe(...)if (state.playZoneSize.first > 0) {...// 移動到屏幕外的時候發(fā)送重置Actionif (pipeState.offset < - playZoneWidthInDP) {viewModel.dispatch(GameAction.PipeExit, pipeIndex = pipeIndex)}}} }ViewModel 收到 PipeExit 的 Action 后發(fā)起重置管道數(shù)據(jù),并將更新發(fā)射出去。
class GameViewModel : ViewModel() {private fun response(action: GameAction, state: ViewState) {viewModelScope.launch {withContext(Dispatchers.Default) {emit(when (action) {GameAction.PipeExit -> run {val newPipeStateList: List<PipeState> =if (state.targetPipeIndex == 0) {listOf(state.pipeStateList[0].reset(),state.pipeStateList[1])} else {listOf(state.pipeStateList[0],state.pipeStateList[1].reset())}state.copy(pipeStateList = newPipeStateList)}})}}} }但相比路面,管道還具備高度隨機(jī)、間距固定的特性。所以重置位置的同時記得將柱子的高度隨機(jī)賦值,并給另一根柱子賦值剩余的高度。
需要留意一點的是,如果希望管道組出現(xiàn)的節(jié)奏固定,那么管道組之間的橫向間距 (不是上下管道的間距) 始終需要保持一致。為此兩組管道初始的 Offset 數(shù)據(jù)要遵循一些規(guī)則,此處省略計算的過程,大概規(guī)則如下。
val FirstPipeWidthOffset = PipeCoverWidth * 2 // 第二組管道的offset等于 // 屏幕寬度 加上 三倍第一組管道offset 的一半 val SecondPipeWidthOffset = (TotalPipeWidth + FirstPipeWidthOffset * 3) / 2val PipeStateList = listOf(PipeState(),PipeState(offset = (SecondPipeWidthOffset)) )小鳥飛起來
不斷調(diào)整小鳥圖片在 Y 軸上的偏移值可以實現(xiàn)小鳥的上下移動。但相較于路面和管道,小鳥的需要些特有的處理:
監(jiān)聽用戶的點擊事件,向上調(diào)整偏移值實現(xiàn)上升效果
在上升和下降的過程中,調(diào)整小鳥的 Rotate 角度,以演示運(yùn)動的姿態(tài)
在觸碰到路面的時刻,發(fā)送 HitGround 的 Action 停止游戲
小鳥根據(jù) State 的 Offset 數(shù)據(jù)開始移動和調(diào)整姿態(tài),同時在觸地的時候告知 ViewModel。因為下降的偏移值誤差可能導(dǎo)致觸地的那刻小鳥位置發(fā)生偏差,所以在小鳥下落到路面的臨界點后需要手動調(diào)整下 Offset 值。
@Composable fun Bird(...) {...// 根據(jù)小鳥上升或下降的狀態(tài)調(diào)整小鳥的Roate角度val rotateDegree =if (state.isLifting) LiftingDegreeelse if (state.isFalling) FallingDegreeelse PendingDegreeBox(...) {var correctBirdHeight = state.birdState.birdHeightif (state.playZoneSize.second > 0) {...val fallingThreshold = BirdHitGroundThreshold// 小鳥偏移值達(dá)到背景邊界時發(fā)送落地Actionif (correctBirdHeight + fallingThreshold >= playZoneHeightInDP / 2) {viewModel.dispatch(GameAction.HitGround)// 修改下offset值避免下落到臨界位置的誤差correctBirdHeight = playZoneHeightInDP / 2 - fallingThreshold}}Image(...modifier = Modifier.size(BirdSizeWidth, BirdSizeHeight).align(Alignment.Center).offset(y = correctBirdHeight)// 將旋轉(zhuǎn)角度應(yīng)用到小鳥,展示飛翔姿態(tài).rotate(rotateDegree))} }碰撞和實時分值
動態(tài)的元素都實現(xiàn)好了,下一步開始安排碰撞算法,并將實時分值同步展示到游戲上方。
仔細(xì)思考,發(fā)現(xiàn)當(dāng)管道組移動到小鳥飛翔區(qū)域的時候,計算小鳥是否處在管道區(qū)域即可判斷是否產(chǎn)生了碰撞。而當(dāng)管道移動出小鳥飛翔范圍的時候,即可判定小鳥成功穿過了管道,開始計分。
如下圖所示當(dāng)管道移動到小鳥飛翔區(qū)域的時候,紅色部分為危險地帶,綠色部分才是安全區(qū)域。
@Composable fun GameScreen(...) {...Column(...) {Box(...) {...// 添加實時展示分值的Text組件ScoreBoard(modifier = Modifier.fillMaxSize(),state = viewState,clickable = clickable)// 遍歷兩個管道組,檢查小鳥的穿過狀態(tài)if (viewState.gameStatus == GameStatus.Running) {viewState.pipeStateList.forEachIndexed { pipeIndex, pipeState ->CheckPipeStatus(viewState.birdState.birdHeight,pipeState,playZoneWidthInDP,playZoneHeightInDP).also {when (it) {// 碰撞到管道的話通知ViewModel,安排墜落PipeStatus.BirdHit -> {viewModel.dispatch(GameAction.HitPipe)}// 成功通過的話通知ViewModel計分PipeStatus.BirdCrossed -> {viewModel.dispatch(GameAction.CrossedPipe, pipeIndex = pipeIndex)}}}}}}} }@Composable fun CheckPipeStatus(...): PipeStatus {// 管道尚未移動到小鳥運(yùn)動區(qū)域if (pipeState.offset - PipeCoverWidth > - zoneWidth / 2 + BirdSizeWidth / 2) {return PipeStatus.BirdComing} else if (pipeState.offset - PipeCoverWidth < - zoneWidth / 2 - BirdSizeWidth / 2) {// 小鳥成功穿過管道return PipeStatus.BirdCrossed} else {val birdTop = (zoneHeight - BirdSizeHeight) / 2 + birdHeightOffsetval birdBottom = (zoneHeight + BirdSizeHeight) / 2 + birdHeightOffset// 管道移動到小鳥運(yùn)動區(qū)域并和小鳥重合if (birdTop < pipeState.upHeight || birdBottom > zoneHeight - pipeState.downHeight) {return PipeStatus.BirdHit}return PipeStatus.BirdCrossing}}ViewModel 收到碰撞 HitPipe 和穿過管道 CrossedPipe 的 Action 后進(jìn)行墜落或計分的處理。
當(dāng)小鳥碰撞到了管道,立刻將下落的速度提高,并將 Rotate 角度加大,營造出快速墜落的效果。
@Composable fun Bird(...) {...val rotateDegree =if (state.isLifting) LiftingDegreeelse if (state.isFalling) FallingDegreeelse if (state.isQuickFalling) DyingDegreeelse if (state.isOver) DeadDegreeelse PendingDegree }結(jié)束分值和重新開始
結(jié)束和實時兩種分值功能有交叉,統(tǒng)一封裝到 ScoreBoard 可組合函數(shù)中,根據(jù)游戲狀態(tài)自由切換。
游戲結(jié)束時展示的信息較為豐富,包含本次分值、最高分值,以及重新開始和退出兩個按鈕。為了方便視圖的 Preview 和提高重組性能,我們將其拆分為單個分值、按鈕、分值儀表盤和結(jié)束分值四個部分。
Compose 的 Preview 功能很好用,但要留意一點:?其 Composable 函數(shù)里不要放入 ViewModel 邏輯,否則會渲染失敗。我們可以拆分 UI 和 ViewModel 邏輯,在保證 Preview 能順利進(jìn)行的同時能復(fù)用視圖部分的代碼。
@Composable fun ScoreBoard(...) {when (state.gameStatus) {// 開始的狀態(tài)下展示簡單的實時分值GameStatus.Running -> RealTimeBoard(modifier, state.score)// 結(jié)束的話展示豐富的儀表盤GameStatus.Over -> GameOverBoard(modifier, state.score, state.bestScore, clickable)} }// 包含豐富分值和按鈕的Box組件 @Composable fun GameOverBoard(...) {Box(...) {Column(...) {GameOverScoreBoard(Modifier.align(CenterHorizontally),score,maxScore)Spacer(...)GameOverButton(modifier = Modifier.wrapContentSize().align(CenterHorizontally), clickable)}} }豐富分值和按鈕的可組合函數(shù)的分別實現(xiàn)。
// 展示豐富分值,包括背景邊框、當(dāng)前分值和最高分值 @Composable fun GameOverScoreBoard(...) {Box(...) {// Score board backgroundImage(painter = painterResource(id = R.drawable.score_board_bg),...)Column(...) {LabelScoreField(modifier, R.drawable.score_bg, score)Spacer(modifier = Modifier.wrapContentWidth().height(3.dp))LabelScoreField(modifier, R.drawable.best_score_bg, maxScore)}} }// 重新開始和退出按鈕 @Composable fun GameOverButton(...) {Row(...) {// 重新開始按鈕Image(painter = painterResource(id = R.drawable.restart_button),...modifier = Modifier....clickable(true) {clickable.onRestart()})Spacer(...)// 退出按鈕Image(painter = painterResource(id = R.drawable.exit_button),...modifier = Modifier....clickable(true) {clickable.onExit()})} }再監(jiān)聽重新開始和退出按鈕的事件,發(fā)送 Restart 和 Exit 的 Action。Exit 的響應(yīng)比較簡單,直接關(guān)閉 Activity 即可。
setContent {FlappyBirdTheme {Surface(color = MaterialTheme.colors.background) {val gameViewModel: GameViewModel = viewModel()Flappy(Clickable(...onRestart = {gameViewModel.dispatch(GameAction.Restart)},onExit = {finish()}))}} }Restart 則要告知 ViewModel 去重置各種游戲數(shù)據(jù),包括小鳥位置、管道和道路的位置、以及分值,但最高分值數(shù)據(jù)應(yīng)當(dāng)保留下來。
class GameViewModel : ViewModel() {private fun response(action: GameAction, state: ViewState) {viewModelScope.launch {withContext(Dispatchers.Default) {emit(when (action) {GameAction.Restart -> run {state.reset(state.bestScore)}})}}} }data class ViewState(...// 重置State數(shù)據(jù),最高分值除外fun reset(bestScore: Int): ViewState =ViewState(bestScore = bestScore) }最終效果
給復(fù)刻好的游戲做個 Logo: 采用小鳥的 Icon 和特有的藍(lán)色背景作成的 Adaptive Icon。
從點擊 Logo 到游戲結(jié)束再到重新開始,錄制一段完整游戲。
代碼地址
代碼開源至 https://github.com/ellisonchan/ComposeBird,感謝不吝 Star。
復(fù)刻的效果還是比較完整的,但仍然有不少可以優(yōu)化和擴(kuò)展的地方:
比如增加簡易模式的選擇。可以從小鳥的升降幅度、管道的間隔、管道移動的速度、連續(xù)出現(xiàn)的組數(shù)等角度入手
增加翅膀扇動的姿態(tài)。實現(xiàn)的話也不難,比如將小鳥的翅膀部分扣出來,在飛翔的過程中不斷地來回 Rotate 一定角度
Canvas 自定義描畫。部分視圖元素采用的是圖片,其實也可以通過 Canvas 來實現(xiàn),順道強(qiáng)化一下 Compose 的描畫使用
感興趣的朋友可以 Fork 一下,試著改改!
結(jié)語
復(fù)刻 Flappy Bird 的中途,發(fā)現(xiàn)一位韓國朋友早在去年底就用 Compose 實現(xiàn)過了。忍不住下載試用了一下,發(fā)現(xiàn)只完成了基礎(chǔ)功能,而且實現(xiàn)的思路和我完全不同。
談不上孰優(yōu)孰劣,感興趣的朋友可以看看他的效果和代碼,地址: github.com/elye/demo_android_jetpack_compose_flappy_bird
整個游戲復(fù)刻下來,發(fā)現(xiàn) Compose 數(shù)據(jù)驅(qū)動視圖的思想特別適合游戲開發(fā)。再加上 Compose 對 ViewModel、Flow 和 Coroutines 等技術(shù)的優(yōu)秀支持,使這個過程變得更加簡單和高效。
現(xiàn)在 Jetpack Compose 1.0 已正式發(fā)布!這將是 Android 平臺重要的 UI 編程方式。仍未嘗鮮的朋友,可以安排上了,就從復(fù)刻這個小游戲開始!
長按右側(cè)二維碼
查看更多開發(fā)者精彩分享
"開發(fā)者說·DTalk" 面向中國開發(fā)者們征集 Google 移動應(yīng)用 (apps & games)?相關(guān)的產(chǎn)品/技術(shù)內(nèi)容。歡迎大家前來分享您對移動應(yīng)用的行業(yè)洞察或見解、移動開發(fā)過程中的心得或新發(fā)現(xiàn)、以及應(yīng)用出海的實戰(zhàn)經(jīng)驗總結(jié)和相關(guān)產(chǎn)品的使用反饋等。我們由衷地希望可以給這些出眾的中國開發(fā)者們提供更好展現(xiàn)自己、充分發(fā)揮自己特長的平臺。我們將通過大家的技術(shù)內(nèi)容著重選出優(yōu)秀案例進(jìn)行谷歌開發(fā)技術(shù)專家 (GDE)?的推薦。
?點擊屏末?|?閱讀原文?|?即刻報名參與?"開發(fā)者說·DTalk"?
總結(jié)
以上是生活随笔為你收集整理的Compose 正式发布,来打造一个 Flappy Bird! | 开发者说·DTalk的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: kitty终端使用笔记
- 下一篇: android pay 机型,苹果有Ap