用 Jetpack Compose 实现仿网易云音乐播放页 + 歌词滚动

247 阅读8分钟

最近在做一个 Compose 小项目时,手痒临时加了一个“仿网易云播放页”的功能,主要包含两个核心效果:

  • 黑胶唱片旋转 + 唱针动画
  • 点击切换歌词页 + 歌词自动滚动高亮

这篇文章就记录一下完整实现思路,以及实现过程中遇到的一些细节坑。

Demo效果图

image.png image.png

image.png image.png


技术栈选型

老实说这类界面用传统的 View 布局也能实现,但这次我想用 Compose 的声明式 UI 来试试。
搭配:

  • Jetpack Compose 渲染 UI
  • ExoPlayer 播放音频
  • Raw 资源文件存放音乐和 .lrc 歌词
  • LaunchedEffect + remember 管理动画和播放状态

页面结构大致规划

跟网易云差不多,分成几块:

  1. 模糊背景:用封面图铺底,Modifier.blur() + 半透明遮罩

  2. 顶部按钮:返回、分享

  3. 中间区域

    • 默认显示唱片 + 唱针
    • 点击切换到歌词列表
  4. 底部操作区:歌曲信息、点赞评论、进度条、播放控制

这样用一个 Box 根布局,把背景、前景分层加进去。


播放器 & 歌曲数据

播放用的是 ExoPlayer,我们把每首歌的封面、音频 raw 资源、歌词 raw 资源提前写进一个 Song 数据类,然后用 rememberExoPlayerMutable 保持每次切歌都能创建新的播放器并释放旧的:

Kotlin
val exoPlayer = rememberExoPlayerMutable(context, curSong.rawRes)
val isPlaying = remember { mutableStateOf(false) }

注意:MediaItem.fromUri("rawresource://...") 这种 URI 格式可以直接播放 raw 里的文件。


黑胶唱片旋转 & 唱针摆动

这部分是最有趣的。

旋转黑胶

Compose 没有“无限旋转”的现成组件,但可以自己控制一个 rotationZ 值,每帧递增:

Kotlin
var diskRotation by remember { mutableFloatStateOf(0f) }
LaunchedEffect(isPlaying.value) {
    while (isPlaying.value) {
        diskRotation += 0.042f * 16
        if (diskRotation > 360f) diskRotation -= 360f
        delay(16)
    }
}

然后用 .graphicsLayer { rotationZ = diskRotation } 应用到盘的外层 Box。

唱针动画

用 animateFloatAsState 实现播放/暂停时平滑旋转:

Kotlin
val needleAngle by animateFloatAsState(
    targetValue = if (isPlaying.value) 0f else -25f,
    animationSpec = tween(500)
)

暂停时往外摆动,播放时归零压到唱片。


歌词解析 & 自动滚动

网易云的歌词是 .lrc 格式,里面的时间戳形如 [mm:ss.xx]

解析

我写了一个简单的 loadLyricsFromRaw() 方法:

Kotlin
fun loadLyricsFromRaw(context: Context, @RawRes resId: Int): List<LyricLine> {
    val raw = context.resources.openRawResource(resId).bufferedReader().readText()
    return raw.lines().mapNotNull { parseLine(it) }.sortedBy { it.timestampMs }
}

每行用 substring 截掉时间和歌词文本,再转成毫秒。

自动滚动

歌词列表用 LazyColumn + rememberLazyListState()
每次 currentLyricIndex 变化时,把目标行滚动到列表中间:

Kotlin
LaunchedEffect(currentIndex) {
    val targetIndex = (currentIndex - offsetItems).coerceAtLeast(0)
    listState.animateScrollToItem(targetIndex)
}

高亮当前行就是 Compose 最擅长的声明式刷新:

Kotlin
color = if (index == currentIndex) Color(0xFFD83B67) else Color.White.copy(alpha = 0.7f)
fontSize = if (index == currentIndex) 20.sp else 16.sp

点击切换唱片 / 歌词界面

这部分直接用一个 showLyrics 布尔状态控制:

Kotlin
Box(
    Modifier.fillMaxSize().clickable { showLyrics = !showLyrics }
) {
    if (!showLyrics) {
        // 黑胶界面
    } else {
        LyricList(lyrics, currentLyricIndex)
    }
}

点击区域覆盖整个中间部分,这样无论点唱片还是歌词都能切换。


播放进度 & 控制按钮

进度条用 Compose 的 Slidervalue绑定播放进度比例。

拖动时同步歌词位置:

Kotlin
onValueChange = { newValue ->
    val targetMs = (newValue * totalDuration).toLong()
    currentLyricIndex = findCurrentLyricIndex(lyrics, targetMs)
}

按钮都是 IconButton,上一曲 / 下一曲 / 播放暂停直接改 songIndex 或调用 exoPlayer.pause()/play()


完成后的效果

成品效果:

  • 播放时黑胶转动,唱针压下
  • 暂停时唱针抬起,唱片定格
  • 点击中间切换到歌词页,歌词滚动到当前行并高亮
  • 进度条拖动歌词跟着跳
  • 背景封面模糊覆盖全屏,网易云同款沉浸感

整体源码

package com.example.test001

import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.*
import androidx.compose.ui.zIndex
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer

import kotlinx.coroutines.delay

class MusicPlayerActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MusicPlayerScreen()
        }
    }
}

// 新增: 歌词行类
data class LyricLine(val timestampMs: Long, val text: String)

// 新增: 解析 LRC 文件
fun loadLyricsFromRaw(context: Context, @androidx.annotation.RawRes resId: Int): List<LyricLine> {
    val inputStream = context.resources.openRawResource(resId)
    val raw = inputStream.bufferedReader().use { it.readText() }
    val lines = raw.split("\n")
    val parsed = mutableListOf<LyricLine>()
    for (line in lines) {
        val start = line.indexOf("[")
        val end = line.indexOf("]")
        if (start != -1 && end != -1) {
            val timeStr = line.substring(start + 1, end) // mm:ss.xx
            val lyricText = line.substring(end + 1).trim()
            if (lyricText.isEmpty()) continue
            val timeParts = timeStr.split(":")
            val minute = timeParts[0].toInt()
            val secondsDouble = timeParts[1].toDouble()
            val second = secondsDouble.toInt()
            val millisecond = ((secondsDouble - second) * 1000).toInt()
            val timestampMs = minute * 60_000 + second * 1000 + millisecond
            parsed.add(LyricLine(timestampMs.toLong(), lyricText))
        }
    }
    parsed.sortBy { it.timestampMs }
    return parsed
}

// 新增: 查找当前歌词行
fun findCurrentLyricIndex(lyrics: List<LyricLine>, positionMs: Long): Int {
    for (i in lyrics.indices) {
        if (positionMs >= lyrics[i].timestampMs &&
            (i == lyrics.size - 1 || positionMs < lyrics[i + 1].timestampMs)
        ) {
            return i
        }
    }
    return 0
}

// 新增: 可组合歌词列表
@Composable
fun LyricList(lyrics: List<LyricLine>, currentIndex: Int) {
    val listState = rememberLazyListState()

    val density = LocalDensity.current
    val lineHeightPx = with(density) { 40.dp.toPx() }

    // 当 currentIndex 改变时自动居中滚动
    LaunchedEffect(currentIndex) {
        if (lyrics.isNotEmpty()) {
            val viewportHeightPx =
                listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset
            val offsetItems = (viewportHeightPx / (lineHeightPx)).toInt() / 2 // 居中行数偏移
            val targetIndex = (currentIndex - offsetItems).coerceAtLeast(0)
            listState.animateScrollToItem(targetIndex)
        }
    }

    LazyColumn(
        state = listState,
        modifier = Modifier
            .fillMaxSize()
            .padding(vertical = 16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        items(lyrics.size) { index ->
            Text(
                text = lyrics[index].text,
                color = if (index == currentIndex) Color(0xFFD83B67) else Color.White.copy(alpha = 0.7f),
                fontSize = if (index == currentIndex) 20.sp else 16.sp,
                fontWeight = if (index == currentIndex) FontWeight.Bold else FontWeight.Normal,
                modifier = Modifier
                    .height(40.dp) // 固定高度,给计算居中用
                    .padding(vertical = 4.dp)
            )
        }
    }
}

//========= UI页面 ==============

@SuppressLint("ConfigurationScreenWidthHeight")
@Composable
fun MusicPlayerScreen() {

    val context = LocalContext.current

    // 歌曲列表(只需填写raw与对应信息,多首均可)
    data class Song(
        val name: String,
        val artist: String,
        val coverRes: Int,
        @androidx.annotation.RawRes val rawRes: Int,
        @androidx.annotation.RawRes val lyricRes: Int // 新增: 对应歌词文件 raw 资源
    )

    val songList = listOf(
        Song("最后一页", "江语晨", R.drawable.cover_demo, R.raw.music_demo1, R.raw.music1),
        Song("跳楼机", "LBI利比", R.drawable.cover_demo2, R.raw.music_demo2, R.raw.music2),
        Song("忘不掉的你", "h3R3", R.drawable.cover_demo3, R.raw.music_demo3, R.raw.music3),
        Song("像晴天像雨天", "汪苏泷", R.drawable.cover_demo, R.raw.music_demo4, R.raw.music4),
        // 如需添加更多,继续添加
    )
    var songIndex by remember { mutableIntStateOf(0) }
    val songCount = songList.size
    val curSong = songList[songIndex]
    // 1.播放器
    val exoPlayer = rememberExoPlayerMutable(context, curSong.rawRes)
    val isPlaying = remember { mutableStateOf(false) }
    val totalDuration = remember { mutableLongStateOf(0L) }
    val currentProgress = remember { mutableFloatStateOf(0f) }
    val currentTime = remember { mutableLongStateOf(0L) }

    // 黑胶
    val discRes = R.drawable.ic_disc
    // 唱针
    val needleRes = R.drawable.ic_needle3
    // 黑胶背景
    val discBackground = R.drawable.ic_disc_blackground

    // ====== 新增: 歌词状态 ======
    var lyrics by remember { mutableStateOf<List<LyricLine>>(emptyList()) }
    var currentLyricIndex by remember { mutableStateOf(0) }
    var showLyrics by remember { mutableStateOf(false) }

    // 加载歌词(切换歌曲时执行)
    LaunchedEffect(songIndex) {
        lyrics = loadLyricsFromRaw(context, curSong.lyricRes)
    }

    //列表循环
    DisposableEffect(exoPlayer, songIndex) {
        val listener = object : Player.Listener {
            override fun onPlaybackStateChanged(state: Int) {
                if (state == Player.STATE_ENDED) {
                    // 列表循环
                    songIndex = if (songIndex < songCount - 1) songIndex + 1 else 0
                }
            }
        }
        exoPlayer.addListener(listener)
        onDispose { exoPlayer.removeListener(listener) }
    }

    // 播放监听:进度等
    LaunchedEffect(exoPlayer, isPlaying.value, songIndex) {
        while (true) {
            totalDuration.longValue = exoPlayer.duration.coerceAtLeast(1L)
            currentTime.longValue = exoPlayer.currentPosition
            currentProgress.floatValue = if (totalDuration.longValue > 0)
                exoPlayer.currentPosition.toFloat() / totalDuration.longValue else 0f
            // 新增: 歌词更新
            currentLyricIndex = findCurrentLyricIndex(lyrics, exoPlayer.currentPosition)
            delay(100)
        }
    }

    // 自动播放、记得关闭单曲循环
    LaunchedEffect(exoPlayer, songIndex) {
        exoPlayer.repeatMode = Player.REPEAT_MODE_OFF
        exoPlayer.playWhenReady = true
        isPlaying.value = true
    }


    var diskRotation by remember { mutableFloatStateOf(0f) }
    val rotationSpeed = 0.042f // 每帧递增度数,可调(速度)

    // 动画:黑胶旋转,唱针旋转
    LaunchedEffect(isPlaying.value) {
        while (isPlaying.value) {
            diskRotation += rotationSpeed * 16    // 16是大致每帧毫秒
            if (diskRotation > 360f) diskRotation -= 360f
            delay(16)
        }
        // 这里不处理归零,保持在当前角度,暂停状态
    }

    val needleAngle by animateFloatAsState(
        targetValue = if (isPlaying.value) 0f else -25f, // 靠近时0,离开-25
        animationSpec = tween(500)
    )

    val configuration = LocalConfiguration.current
    val density = LocalDensity.current
    val screenWidth = configuration.screenWidthDp.dp
    val screenHeight = configuration.screenHeightDp.dp
    val reservedBottomSpace = 300.dp // 为底部控制区/信息区预留区域空间

    // 1. 唱盘外框最大直径 = 比例(如68%)* 屏幕宽,不超过 屏幕高-底部栏
    val maxDiscSize = screenWidth * 0.88f
    val maxDiscHeight = screenHeight - reservedBottomSpace
    val discSize = if (maxDiscSize < maxDiscHeight) maxDiscSize else maxDiscHeight

    // 2. 细分
    val backgroundSize = discSize * 0.98f           // 背景直径=外框约98%
    val blackDiscSize = discSize * 0.93f            // 黑胶盘直径=外框约93%
    val coverSize = discSize * 0.6f                 // 封面直径=黑胶盘约60%
    val needleWidth = discSize * 0.32f              // 唱针宽
    val needleHeight = needleWidth * 1.8f           // 唱针高
    val needleOffsetX = discSize * 0.14f            // 唱针X方向偏移
    val needleOffsetY = -needleHeight * 0.55f       // 唱针Y方向偏移

    // ========= UI ============

    Box(
        Modifier
            .fillMaxSize()
    ) {
        // 背景封面高斯 + 遮罩
        Image(
            painter = painterResource(id = curSong.coverRes),
            contentDescription = null,
            modifier = Modifier
                .fillMaxSize()
                .blur(45.dp),
            contentScale = ContentScale.Crop
        )
        Box(
            Modifier
                .fillMaxSize()
                .background(Color(0x66000000))
        )
        // 顶部Bar
        Row(
            Modifier
                .fillMaxWidth()
                .padding(top = 40.dp, start = 16.dp, end = 16.dp),
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            IconButton(onClick = {
                /*返回*/
                (context as? Activity)?.finish()
                (context as? Activity)?.overridePendingTransition(0, R.anim.slide_out_bottom)

            }) {
                Icon(Icons.Default.ArrowBackIosNew, null, tint = Color.White)
            }
            IconButton(onClick = {
                /*分享*/
                val intent = Intent(Intent.ACTION_SEND)
                intent.type = "text/plain"
                intent.putExtra(Intent.EXTRA_SUBJECT, "音乐分享")
                intent.putExtra(
                    Intent.EXTRA_TEXT,
                    "我正在听好听的歌曲 “${curSong.name} - ${curSong.artist}”,推荐给你!"
                )
                context.startActivity(Intent.createChooser(intent, "分享音乐到..."))

            }) {
                Icon(Icons.Default.Share, null, tint = Color.White)
            }
        }

        // ====== 中间: 唱盘or歌词 ======
        Box(
            Modifier
                .fillMaxSize()
                .clickable(indication = null,
                    interactionSource = remember { MutableInteractionSource() }
                ) {
                    showLyrics = !showLyrics
                }
        ) {
            if (!showLyrics) {
                // =========== 优化黑胶盘与唱针合成区(自适应屏幕) ===========
                Box(
                    Modifier
                        .align(Alignment.Center)
                        .size(discSize)
                        .offset(y = -discSize * 0.15f)
                ) {
                    // 黑胶盘合成体
                    Box(
                        Modifier
                            .align(Alignment.Center)
                            .size(backgroundSize)
                            .graphicsLayer { rotationZ = diskRotation }
                    ) {
                        // 黑胶盘背景
                        Image(
                            painter = painterResource(id = discBackground),
                            contentDescription = null,
                            modifier = Modifier.matchParentSize()
                        )
                        // 黑胶盘
                        Box(
                            Modifier
                                .align(Alignment.Center)
                                .size(blackDiscSize)

                        ) {
                            Image(
                                painter = painterResource(id = discRes),
                                contentDescription = null,
                                modifier = Modifier.matchParentSize()
                            )
                        }
                        // 封面
                        Box(
                            modifier = Modifier
                                .size(coverSize)
                                .align(Alignment.Center)
                                .clip(CircleShape)
                                .background(Color.White, CircleShape)
                        ) {
                            Image(
                                painter = painterResource(id = curSong.coverRes),
                                contentDescription = null,
                                modifier = Modifier
                                    .fillMaxSize()
                                    .clip(CircleShape)
                            )
                        }
                    }
                    // 唱针
                    Image(
                        painter = painterResource(id = needleRes),
                        contentDescription = null,
                        modifier = Modifier
                            .size(width = needleWidth, height = needleHeight)
                            .align(Alignment.TopCenter)
                            .offset(x = needleOffsetX, y = needleOffsetY)
                            .graphicsLayer {
                                rotationZ = needleAngle
                                transformOrigin = TransformOrigin(0.12f, 0.13f)
                            }
                            .zIndex(2f)
                    )
                }
            } else {
                // 歌词视图
                Box(
                    Modifier
                        .padding(top = 65.dp)
                        .fillMaxWidth()
                        .fillMaxHeight(0.65f)
                ) {
                    LyricList(lyrics, currentLyricIndex)
                }
            }

        }


        var isLiked by remember { mutableStateOf(false) }
        var likeCount by remember { mutableIntStateOf(520) }
        var commentCount by remember { mutableIntStateOf(999) }


        // 下部信息
        Column(
            Modifier
                .fillMaxWidth()
                .align(Alignment.BottomCenter),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Spacer(Modifier.height(32.dp))
            Row(
                Modifier
                    .fillMaxWidth(0.88f)
                    .padding(horizontal = 8.dp),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                // 左边歌曲名+艺术家
                Column(
                    Modifier.weight(1f),
                    verticalArrangement = Arrangement.Center
                ) {
                    Text(
                        curSong.name,
                        color = Color.White,
                        fontSize = 22.sp,
                        fontWeight = FontWeight.Bold,
                        maxLines = 1
                    )
                    Text(
                        curSong.artist,
                        color = Color.White.copy(alpha = 0.78f),
                        fontSize = 15.sp,
                        maxLines = 1
                    )
                }
                // 右侧点赞/评论按钮+数量
                Row(
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    IconButton(
                        onClick = {
                            isLiked = !isLiked
                            likeCount += if (isLiked) 1 else -1
                        }
                    ) {
                        Icon(
                            imageVector = if (isLiked) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
                            contentDescription = "Like",
                            tint = if (isLiked) Color(0xFFD83B67) else Color.White
                        )
                    }
                    Text(
                        likeCount.toString(),
                        color = Color.White,
                        fontSize = 15.sp,
                        modifier = Modifier.padding(end = 8.dp)
                    )
                    IconButton(onClick = { /* 评论点击 */ }) {
                        Icon(
                            Icons.Default.Comment,
                            contentDescription = "Comment",
                            tint = Color.White
                        )
                    }
                    Text(
                        commentCount.toString(),
                        color = Color.White,
                        fontSize = 15.sp
                    )
                }
            }
            Spacer(Modifier.height(18.dp))
            // 播放进度
            Row(
                Modifier.fillMaxWidth(0.85f),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    formatTime(currentTime.longValue),
                    color = Color.White.copy(alpha = 0.8f), fontSize = 10.sp
                )
                Slider(
                    value = currentProgress.floatValue,
                    onValueChange = { newValue ->
                        currentProgress.floatValue = newValue
                        val targetMs =
                            (newValue * totalDuration.longValue).toLong().coerceAtLeast(0)
                        currentLyricIndex = findCurrentLyricIndex(lyrics, targetMs) // 新增: 拖动歌词跟跳
                        currentTime.longValue = targetMs
                    },
                    onValueChangeFinished = {
                        val targetMs =
                            (currentProgress.floatValue * totalDuration.longValue).toLong()
                                .coerceAtLeast(0)
                        exoPlayer.seekTo(targetMs)
                    },
                    modifier = Modifier.weight(1f),
                    colors = SliderDefaults.colors(
                        thumbColor = Color.White,
                        activeTrackColor = Color.White,
                        inactiveTrackColor = Color.White.copy(alpha = 0.2f)
                    )
                )
                Text(
                    formatTime(totalDuration.longValue),
                    color = Color.White.copy(alpha = 0.8f), fontSize = 10.sp
                )
            }
            Spacer(Modifier.height(8.dp))
            // 播放控制
            Row(
                Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.Center,
                verticalAlignment = Alignment.CenterVertically
            ) {
                IconButton(onClick = {
                    // 上一曲
                    songIndex = if (songIndex > 0) songIndex - 1 else songCount - 1
                }) {
                    Icon(
                        Icons.Default.SkipPrevious,
                        null,
                        tint = Color.White,
                        modifier = Modifier.size(36.dp)
                    )
                }
                Spacer(Modifier.width(8.dp))
                IconButton(onClick = {
                    // 播放/暂停
                    if (exoPlayer.isPlaying) exoPlayer.pause()
                    else exoPlayer.play()
                    isPlaying.value = exoPlayer.isPlaying
                }) {
                    Icon(
                        if (isPlaying.value) Icons.Default.PauseCircle else Icons.Default.PlayCircle,
                        null, tint = Color.White, modifier = Modifier.size(66.dp)
                    )
                }
                Spacer(Modifier.width(8.dp))
                IconButton(onClick = {
                    // 下一曲
                    songIndex = if (songIndex < songCount - 1) songIndex + 1 else 0
                }) {
                    Icon(
                        Icons.Default.SkipNext,
                        null,
                        tint = Color.White,
                        modifier = Modifier.size(36.dp)
                    )
                }
            }
            Spacer(Modifier.height(42.dp))
        }
    }
}

// ============= 工具代码 ================

// 快速格式化时间
private fun formatTime(ms: Long): String {
    val totalSec = (ms / 1000).toInt()
    val min = totalSec / 60
    val sec = totalSec % 60
    return "%02d:%02d".format(min, sec)
}


// ExoPlayer记住每一首资源
@Composable
fun rememberExoPlayerMutable(context: Context, @androidx.annotation.RawRes rawRes: Int): ExoPlayer {
    val exoPlayer = remember(rawRes) {
        ExoPlayer.Builder(context).build().apply {
            val mediaItem = MediaItem.fromUri("rawresource://${context.packageName}/$rawRes")
            setMediaItem(mediaItem)
            prepare()
        }
    }
    DisposableEffect(exoPlayer) {
        //可自动释放的ExoPlayer
        onDispose {
            exoPlayer.release()
        }
    }
    return exoPlayer
}

一些感受

用 Compose 做这种复杂 UI 页面,状态管理和动画控制比 View 时代直观很多,比如歌词高亮和滚动基本是状态驱动自动更新。不用手动刷新 UI,代码量也比我预想的少。

唯一要注意的是动画和 LaunchedEffect 要合理关停,否则旋转和播放器监听可能会占用资源。


如果你也想练练 Compose 动画 + 多媒体播放,仿网易云这种黑胶 + 歌词滚动的界面会很有成就感,而且手机一跑起来就很“网易云味”。