Android——一个简单的音乐APP

简介: Github上有位牛人将网易云音乐的接口进行部署和总结,然后我将它的仓库部署到我的云服务器上,因为他的是需要翻墙的,此项目所有接口信息均与网易云音乐关联。由于此项目用于熟悉kotlin语言,所以绝大部分代码均使用kotlin编写;目前EasyMusic为第一版,功能上还有欠缺,但主流程已经基本完成;

效果视频

效果视频地址

简介

Github上有位牛人将网易云音乐的接口进行部署和总结,然后我将它的仓库部署到我的云服务器上,因为他的是需要翻墙的,此项目所有接口信息均与网易云音乐关联。
由于此项目用于熟悉kotlin语言,所以绝大部分代码均使用kotlin编写;
目前EasyMusic为第一版,功能上还有欠缺,但主流程已经基本完成;

技术栈

  • 网络:Okhttp+GSON
  • 存储:Room数据库+SharedPreferences
  • UI:Material Design,ViewBinding
  • 其余:EventBus,Glide...

实现功能

  • 最近播放(歌曲,歌单,专辑,mv)
  • 播放列表
  • 歌曲播放(折叠播放、播放详情页)
  • 歌词
  • 搜索(历史搜索记录、搜索匹配列表、歌曲,歌单,专辑,mv)
  • 榜单(官方榜,精选榜)
  • 推荐(歌曲,歌单,mv)
  • 登录(账号密码、手机验证码、二维码)

具体实现

首页

效果

4043c1939f684cc8b38127e8a5548571.jpeg
9c320b72097a42d2b6700988c0275a51.jpeg

代码

DrawerLayout+SlidingUpPanelLayout+BottomNavigationView+NavigationView

UI布局大致如上,一个侧边栏,一个底部导航栏,唯一值得注意的是SlidingUpPanelLayout,这个一个可滑动的菜单栏,但使用的时候注意其滑动冲突,向上滑动呼出菜单栏,然后显示歌词、歌曲信息等,由于歌词也需要滑动,但由于SlidingUpPanelLayout 是父布局,滑动焦点在它身上,所有歌词和进度条无法滑动,一滑动或点击就导致SlidingUpPanelLayout为折叠状态,所有将它的滑动事件禁用,并将滑动焦点交给FragmentLayout,也就是所显示的界面。这样可以避免滑动冲突,但是也少了滑动呼出SlidingUpPanelLayout,就通过点击折叠栏的图像去展开SlidingUpPanelLayout
禁用滑动

 binding.slidUpLayout.isTouchEnabled = false

SlidingUpPanelLayout的展开和折叠

R.id.songsCover-> binding.slidUpLayout.panelState = SlidingUpPanelLayout.PanelState.EXPANDED
R.id.detailExit-> binding.slidUpLayout.panelState = SlidingUpPanelLayout.PanelState.COLLAPSED

播放

效果

fd90c133da0548759f0e18fb155a0d7c.jpeg
aefb881eaf734d4aa1e04b2b8d89e45f.jpeg

代码

播放的开始、暂停、上一首、下一首、进度回调等都封装在一个单例Binder中,,此Binder的服务在启动时就默认开启,通过在歌单中点击播放全部按钮,将所在的歌单的所有歌曲添加的room数据库(播放列表)中,然后上一首、下一首则通过下标去进行歌曲切换,背景cover和缩略cover采用了高斯模糊处理和圆角处理。歌词部分采用的是一个第三方开源库

object MusicBinder : Binder() {
    private val TAG: String = "MusicBinderLog"

    /**
     * 当前播放歌曲在播放列表中的位置*/
    var curSongsCur: Int = 0
    private var curPosition: Long = 0
    private var duration: Long = 0
    var isPlay: Boolean = false
    lateinit var bean: SongBean
    private lateinit var player: MediaPlayer
    private lateinit var listener: ProgressStatus

    private val runnable = object : Runnable {
        override fun run() {
            if (isPlay) {
                curPosition += 1000
                listener.getCurrentProgress(isPlay, player.currentPosition.toLong(), duration)
            }
        }
    }

    private val scheduled = Executors.newSingleThreadScheduledExecutor()


    init {
        player = MediaPlayer()

        val jsonBean =
            SPUtil.getInstance().GetData(BaseApplication.context, CacheKeyParam.recordBean, "")
        if (!TextUtils.isEmpty(jsonBean.toString())) {
            val jsonObject = JSONObject(jsonBean.toString())
            val bean: SongBean = HttpUtil.fromJson(jsonObject.toString(), SongBean::class.java)
            this.bean = bean
            //player.setDataSource(bean.songUrl)
        }

        player.setOnPreparedListener {
            Log.d(TAG, "准备完成")
            duration = player.duration.toLong()
            curPosition = 0
            play()
        }

        player.setOnCompletionListener {
            next(curSongsCur + 1)
        }

        player.setOnErrorListener { mp, what, extra ->
            stop()
            false
        }
    }


    /**
     * 准备播放,传入播放地址*/
    fun prepare(bean: SongBean) {
        Log.d(TAG, "准备播放")
        this.bean = bean
        try {
            player.stop()
            player.reset()
            player.setAudioStreamType(AudioManager.STREAM_MUSIC)
            player.setDataSource(bean.songUrl)
            player.prepareAsync()
        } catch (e: IllegalStateException) {
            e.printStackTrace()
        }
    }

    /**
     * 开始播放*/
    fun play() {
        Log.d(TAG, "开始播放")
        player.start()
        isPlay = true
        EventBus.getDefault().postSticky(this.bean)
        scheduled.scheduleAtFixedRate(runnable, 0, 1000, TimeUnit.MILLISECONDS)
        SPUtil.getInstance().PutData(BaseApplication.context, CacheKeyParam.recordId, curSongsCur)
        SPUtil.getInstance().PutData(
            BaseApplication.context,
            CacheKeyParam.recordBean,
            Gson().toJson(this.bean).toString()
        )
    }

    /**
     * 暂停播放,保留当前seek*/
    fun pause() {
        Log.d(TAG, "暂停播放")
        player.pause()
        isPlay = false
        EventBus.getDefault().postSticky(PlayProgressBean(false, curPosition, duration))
    }

    /**
     * 停止播放,清空播放状态*/
    fun stop() {
        player.stop()
        isPlay = false
        EventBus.getDefault().postSticky(PlayProgressBean(false, curPosition, duration))
    }

    /**
     * 读取播放列表下一首歌曲,如果已是最后一首,则播放第一首*/
    fun next(pos: Int) {
        val db = PlayListDataBase.getDBInstance().playListDao()
        val songs = db.getAll()
        if (songs.isEmpty()) {
            ToastUtil.setFailToast(HomePageActivity.MA, "当前播放列表暂无数据!")
            return
        }

        val position = if (songs.size - 1 >= pos)
            pos
        else
            0

        curSongsCur = position
        val bean = db.findByMainId(position)

        getSongUrl(bean.songId.toString(), object : IGenerallyInfo {
            override fun onRespond(json: String?) {
                isPlay = true
                bean.songUrl = json
                prepare(bean)
            }

            override fun onFailed(e: String?) {
                ToastUtil.setFailToast(HomePageActivity.MA, "歌曲播放错误!")
            }
        })
    }

    /**
     * 读取播放列表上一首歌曲,如果已是第一首,则播放最后一首*/
    fun previous(pos: Int) {
        val db = PlayListDataBase.getDBInstance().playListDao()
        val songs = db.getAll()
        if (songs.isEmpty()) {
            ToastUtil.setFailToast(HomePageActivity.MA, "当前播放列表暂无数据!")
            return
        }

        val position = if (pos >= 0)
            pos
        else
            songs.size - 1

        curSongsCur = position
        val bean = db.findByMainId(position)

        getSongUrl(bean.songId.toString(), object : IGenerallyInfo {
            override fun onRespond(json: String?) {
                isPlay = true
                bean.songUrl = json
                prepare(bean)
            }

            override fun onFailed(e: String?) {
                ToastUtil.setFailToast(HomePageActivity.MA, "歌曲播放错误!")
            }
        })
    }

    /**
     * 当前是否正在播放*/
    fun isPlaying(): Boolean {
        return isPlay
    }

    /**
     * 歌曲总时长*/
    fun duration(): Long {
        return duration
    }

    /**
     * 当前播放进度*/
    fun position(): Long {
        return curPosition
    }

    /**
     * 改变进度条*/
    fun seek(pos: Long) {
        player.seekTo(pos, SEEK_CLOSEST)
    }


    private fun getSongUrl(id: String, callback: IGenerallyInfo) {
        var url = HttpUtil.getSongURL(id)
        HttpUtil.getGenerallyInfo(url, object : IGenerallyInfo {
            override fun onRespond(json: String?) {
                val array = JSONObject(json.toString()).getJSONArray("data")
                val songURL = JSONObject(array[0].toString()).getString("url")
                callback.onRespond(songURL)
            }

            override fun onFailed(e: String?) {
                callback.onFailed(e.toString())
            }
        })
    }

    interface ProgressStatus {
        fun getCurrentProgress(isStart: Boolean, current: Long, duration: Long)
    }

    fun setProgressListener(listener: ProgressStatus) {
        this.listener = listener
    }
}

歌单&专辑

效果

d0d017e971c84255a263c0ddb35858b3.jpeg

代码

由于歌单和专辑的接口存在差异,就分为了两个界面,但内容大同小异。操作也很简单,请求接口,将所有内容更新到UI就行,在对每一个的item进行点击事件处理,可以进行歌曲播放

点击播放全部按钮,将当前歌单内所有歌曲添加到播放列表

   /**
     * 点击播放按钮,将当前歌单内所有歌曲添加到播放列表*/
    private fun addToSongList() {
        /**
         * 获取数据库实例*/
        val db = PlayListDataBase.getDBInstance().playListDao()
        val allSongs = db.getAll()
        if (allSongs != null || allSongs.size > 0) {
            db.deleteAll()
        }

        db.insertAll(songBeanList)

        ToastUtil.setSuccessToast(this@SongListActivity, "已添加${songBeanList.size}首歌至播放列表")
    }

高斯模糊和圆角处理,值得注意的是需要判断当前activity是否正在运行,因为当点击进入某个歌单,开始网络请求,但你忽然点击退出,此时activity已经销毁,但glide拿到返回的结果还会继续加载,但因为没有上下文,会出现异常,故而加一个判断。默认采用歌单第一首歌曲的cover作为歌单的cover

if (!this.isFinishing) {
            Glide.with(binding.songListCover)
                .asDrawable()
                .load(songBeanList[0].albumCover)
                .transform(GlideRoundTransform(BaseApplication.context, 10))
                .placeholder(R.drawable.icon_default_songs)
                .dontAnimate()
                .into(binding.songListCover)

            Glide.with(binding.albumCover)
                .asDrawable()
                .load(songBeanList[0].albumCover)
                .apply(bitmapTransform(BlurTransformation(50)))
                .placeholder(R.drawable.icon_default_songs)
                .dontAnimate()
                .into(binding.albumCover)
        }

最近播放

效果

61ef55338e96459bbba89be6ce03a407.jpeg
542eb92f5c0b48bba5809ddc63836589.jpeg
91490d4034f943b68cd3e8139ca8d5de.jpeg

代码

UI

TabLayout+ViewPager+Fragment

UI布局如上所述,较为简单,通过请求一个相同接口,根据页面内容传入不同的type获取不同的内容。每一个fragment
页面加一个loding,并创造一个父fragment,在里面设计懒加载,并让所有fragment继承此父类;意味,只有当用户滑到此页面时,才开始执行父fragment暴露出来抽象函数中的内容,可放置一个网络和io操作,因为并不是所有的界面用户都会去看,所以避免内存消耗,采用此方法进行解决。
以歌曲为例,当此界面可见时,加载initLazyFunc的内容,然后loading展现,并请求网络

  override fun initLazyFunc() {
        binding.loading.show()
        getRecentInfo()
    }
private fun getRecentInfo(){
        val url = HttpUtil.getRecentlyURL(MusicParam.recentlySong)

        HttpUtil.getGenerallyInfo(url,object : IGenerallyInfo{
            override fun onRespond(json: String?) {
                Log.d(TAG, json.toString())
                val results = JSONObject(json.toString()).getJSONObject("data").getJSONArray("list")
                val songs = HttpUtil.fromListJson(results.toString(), SongRecentBean::class.java)
                val message = Message()
                message.what = UPDATE
                message.obj = songs
                handler.sendMessage(message)
            }

            override fun onFailed(e: String?) {
                Log.d(TAG,e.toString())
            }
        })
    }

搜索

效果

83a331036b86489fa5089d546918f406.jpeg
db8a1247c5994edf9570be93e8428157.jpeg

代码

搜索匹配列表UI

SearchView+RecyclerView

下面分别为历史搜索记录、歌单、歌曲、MV推荐信息。
通过监听SearchView的输入字符,然后并将每一个不为空的字符进行网络请求,在将相关数据进行列表展现。
搜索历史记录使用Room数据库,为了不占太大空间,最多可显示9个记录,并且每一次插入都进行一次更新。若存在相同项,则删除原项,并插入新的关键字,然后倒置数列,最后取后面9个。

  /**
     * 历史搜索记录最大显示个数为9个,故采用九宫格形式
     * 从最后添加的开始追加
     * 若存在相同项,则删除原项,将新项添加到第一个显示*/
    private fun updateHistory(key: String) {
        val dao = PlayListDataBase.getDBInstance().historyDao()
        var all = dao.getAll()

        if (!TextUtils.isEmpty(key)){
            val bean: HistorySearchBean = dao.findByKey(key)
            if (bean != null){
                dao.delete(bean)
                Log.d(TAG,"搜索记录=${bean.key}")
            }
            dao.insert(HistorySearchBean(key))
            all = dao.getAll()
        }

        all.reverse()
        historyList.clear()
        if (all.size < 9) {
            historyList.addAll(all)
        } else {
            var count = 0
            for (item in all) {
                count++
                if (count > 9) break
                historyList.add(item)
            }
        }
        historyAdapter.notifyDataSetChanged()
    }

搜索结果页

效果

9c70e6f3dcb74b5cad22a886f749a6db.jpeg
c50b894a5fc04681a4ea195137ddf7da.jpeg
0c9496fc870f4348ac400eb14db0485f.jpeg

代码

搜索结果页和最近播放一样,分为四个部分:(歌曲、专辑、歌单、MV),实现原理也和最近播放一样,不同于的地方在于,搜索结果页顶部多了一个搜索栏,每一次的搜索点击确定之后,通过EventBus分发给四个Fragment,然后在重新获取数据,进行更新替换。

   @Subscribe(threadMode = ThreadMode.MAIN,sticky = true)
     fun onEvent(bean: SearchKeyBean){
        binding.loading.show()
        getSongsResult(bean.key)
    }

    /**
     * 获取歌曲搜索结果*/
    private fun getSongsResult(keyWords: String){
        val url = HttpUtil.getSearchResultURL(keyWords,1,30)

        HttpUtil.getGenerallyInfo(url, object : IGenerallyInfo {
            override fun onRespond(json: String?) {
                Log.d(TAG, json.toString())
                val results = JSONObject(json.toString()).getJSONObject("result").getJSONArray("songs")
                val songs = HttpUtil.fromListJson(results.toString(), ResultBean::class.java)
                val message = Message()
                message.what = UPDATE
                message.obj = songs
                handler.sendMessage(message)
            }

            override fun onFailed(e: String?) {
                Log.d(TAG, e.toString())
            }
        })
    }

MV

效果

843f08701f33492f920ea18c3e3e38c2.jpeg
c35e253ca3ef4613bf82f2604f1e7060.jpeg

代码

视频播放器采用第三方开源播放器GSYVideoPlayer,通过重写播放器,改变播放器滑动进度条样式、声音滑动进度条样式、亮度进度条样式,加载loading样式等。横竖屏切换会导致activity重建,所以需要在清单文件中对该Activity进行configChanges配置,然后每一次旋转屏幕就在onConfigurationChanged回调中处理竖屏和横屏。

private fun initPlayer() {
        orientationUtils = OrientationUtils(this, binding.iVideoPlayer)
        val builder = GSYVideoOptionBuilder()
        builder.setUrl(null)
            .setVideoTitle(null) //movie title
            .setIsTouchWiget(false) //小屏时不触摸滑动
            .setRotateViewAuto(false) //是否开启自动旋转
            .setLockLand(false) //一全屏就锁屏横屏,默认false竖屏,可配合setRotateViewAuto使用
            .setAutoFullWithSize(true) //是否根据视频尺寸,自动选择竖屏全屏或者横屏全屏
            .setShowFullAnimation(true) //全屏动画
            .setNeedLockFull(false) //是否需要全屏锁定屏幕功能
            .setCacheWithPlay(true) //是否边缓存,m3u8等无效
            .setReleaseWhenLossAudio(false) //音频焦点冲突时是否释放
            .setSeekRatio(8f)
            .setSeekOnStart(0)
            .setIsTouchWigetFull(true) //是否可以全屏滑动界面改变进度,声音等
            .setVideoAllCallBack(object : GSYSampleCallBack() {
                override fun onPrepared(url: String, vararg objects: Any) {
                    super.onPrepared(url, *objects)
                    orientationUtils.isEnable = true
                }

                override fun onQuitFullscreen(url: String, vararg objects: Any) {
                    super.onQuitFullscreen(url, *objects)
                    orientationUtils.backToProtVideo()
                }
            })

        binding.iVideoPlayer.backButton.setOnClickListener(View.OnClickListener {
            if (orientationUtils != null && GSYVideoManager.isFullState(this)) {
                orientationUtils.backToProtVideo()
                GSYVideoManager.backFromWindowFull(this)
            } else {
                finish()
            }
        })
    }

榜单

效果

ab04a904ff9b426892a086d4b352ff92.jpeg

代码

榜单分为官方榜和精选榜两个榜单,顶部一个banner,实现较为简单,略...


    /**
     * 获取排行榜信息*/
    private fun getRankInfo(){
        val url = HttpUtil.getTopRankURL()

        HttpUtil.getGenerallyInfo(url,object : IGenerallyInfo{
            override fun onRespond(json: String?) {
                Log.d(TAG,json.toString())
                val array = JSONObject(json.toString()).getJSONArray("list")
                val result = HttpUtil.fromListJson(array.toString(),SheetPlayBean::class.java)
                val message = Message()
                message.what = UPDATE_RANK
                message.obj = result
                handler.sendMessage(message)

            }

            override fun onFailed(e: String?) {
                Log.d(TAG,e.toString())
            }
        })
    }

    private fun updateRank(rankList: List<SheetPlayBean>){
        for (rank in rankList){
            if (rank.tracks.size() == 0){
                //精选榜
                selectBeanList.add(rank)
            }else{
                //官网榜
                officialBeanList.add(rank)
            }
        }

        officialAdapter.notifyDataSetChanged()
        selectAdapter.notifyDataSetChanged()
        binding.loading.hide()
    }

登录

效果

475945c7ded7421fb7602889ca8e39b3.jpeg
289064420ced49639e0b751c8e93d534.jpeg
562d4131e103473788dccfb183216c1b.jpeg

代码

如上图所示,登录分为三种方式:账号密码、手机验证码、二维码;登陆成功会获得一个cookie,然后将此cookie保存到本地,下一次启动app时,在Application进行判断,如果cookie存在则跳过登录,反之进行登录,如上述表示而言,此APP与网易云音乐账号相关联,所有必须使用网易云音乐账号。

 val mCookie: String = SPUtil.getInstance().GetData(context, CacheKeyParam.cookieKey,"") as String
        val mId: String = SPUtil.getInstance().GetData(context, CacheKeyParam.UserId,"") as String

        val intent: Intent = if (TextUtils.isEmpty(mCookie)){
            userId = ""

            Intent(this,LoginActivity::class.java)
        }else{
            cookie = mCookie

            userId = if (TextUtils.isEmpty(mId))
                ""
            else
                mId

            Intent(this,HomePageActivity::class.java)
        }
        intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK;
        startActivity(intent)

尾言

此项目仅用于学习所用,仍存在许多不足,如在使用过程中出现Bug,可提交反馈,但请尽量描述清楚复现过程

  1. News53231323@163.com
  2. 2337466033

Git链接

如果喜欢可以点个star
Gitte项目链接

相关文章
|
1月前
|
XML Java 数据库
安卓项目:app注册/登录界面设计
本文介绍了如何设计一个Android应用的注册/登录界面,包括布局文件的创建、登录和注册逻辑的实现,以及运行效果的展示。
147 0
安卓项目:app注册/登录界面设计
|
2月前
|
Java 数据库 Android开发
一个Android App最少有几个线程?实现多线程的方式有哪些?
本文介绍了Android多线程编程的重要性及其实现方法,涵盖了基本概念、常见线程类型(如主线程、工作线程)以及多种多线程实现方式(如`Thread`、`HandlerThread`、`Executors`、Kotlin协程等)。通过合理的多线程管理,可大幅提升应用性能和用户体验。
127 15
一个Android App最少有几个线程?实现多线程的方式有哪些?
|
2月前
|
存储 开发工具 Android开发
使用.NET MAUI开发第一个安卓APP
【9月更文挑战第24天】使用.NET MAUI开发首个安卓APP需完成以下步骤:首先,安装Visual Studio 2022并勾选“.NET Multi-platform App UI development”工作负载;接着,安装Android SDK。然后,创建新项目时选择“.NET Multi-platform App (MAUI)”模板,并仅针对Android平台进行配置。了解项目结构,包括`.csproj`配置文件、`Properties`配置文件夹、平台特定代码及共享代码等。
165 2
|
2月前
|
XML Android开发 数据格式
🌐Android国际化与本地化全攻略!让你的App走遍全球无障碍!🌍
在全球化背景下,实现Android应用的国际化与本地化至关重要。本文以一款旅游指南App为例,详细介绍如何通过资源文件拆分与命名、适配布局与方向、处理日期时间及货币格式、考虑文化习俗等步骤,完成多语言支持和本地化调整。通过邀请用户测试并收集反馈,确保应用能无缝融入不同市场,提升用户体验与满意度。
103 3
|
2月前
|
Java 数据库 Android开发
一个Android App最少有几个线程?实现多线程的方式有哪些?
本文介绍了Android应用开发中的多线程编程,涵盖基本概念、常见实现方式及最佳实践。主要内容包括主线程与工作线程的作用、多线程的多种实现方法(如 `Thread`、`HandlerThread`、`Executors` 和 Kotlin 协程),以及如何避免内存泄漏和合理使用线程池。通过有效的多线程管理,可以显著提升应用性能和用户体验。
72 10
|
1月前
|
安全 网络安全 Android开发
深度解析:利用Universal Links与Android App Links实现无缝网页至应用跳转的安全考量
【10月更文挑战第2天】在移动互联网时代,用户经常需要从网页无缝跳转到移动应用中。这种跳转不仅需要提供流畅的用户体验,还要确保安全性。本文将深入探讨如何利用Universal Links(仅限于iOS)和Android App Links技术实现这一目标,并分析其安全性。
230 0
|
2月前
|
XML 数据库 Android开发
10分钟手把手教你用Android手撸一个简易的个人记账App
该文章提供了使用Android Studio从零开始创建一个简单的个人记账应用的详细步骤,包括项目搭建、界面设计、数据库处理及各功能模块的实现方法。
|
3月前
|
API Android开发
Android P 性能优化:创建APP进程白名单,杀死白名单之外的进程
本文介绍了在Android P系统中通过创建应用进程白名单并杀死白名单之外的进程来优化性能的方法,包括设置权限、获取运行中的APP列表、配置白名单以及在应用启动时杀死非白名单进程的代码实现。
64 1
|
3月前
|
Android开发 iOS开发 C#
Xamarin:用C#打造跨平台移动应用的终极利器——从零开始构建你的第一个iOS与Android通用App,体验前所未有的高效与便捷开发之旅
【8月更文挑战第31天】Xamarin 是一个强大的框架,允许开发者使用单一的 C# 代码库构建高性能的原生移动应用,支持 iOS、Android 和 Windows 平台。作为微软的一部分,Xamarin 充分利用了 .NET 框架的强大功能,提供了丰富的 API 和工具集,简化了跨平台移动应用开发。本文通过一个简单的示例应用介绍了如何使用 Xamarin.Forms 快速创建跨平台应用,包括设置开发环境、定义用户界面和实现按钮点击事件处理逻辑。这个示例展示了 Xamarin.Forms 的基本功能,帮助开发者提高开发效率并实现一致的用户体验。
153 0
|
3月前
|
存储 XML Linux
深入理解操作系统:进程管理与调度策略探索安卓应用开发:从零开始构建你的第一个App
【8月更文挑战第28天】在数字世界里航行,操作系统是掌控一切的舵手。本文将带你领略操作系统的精妙设计,特别是进程管理和调度策略这两大核心领域。我们将从基础概念出发,逐步深入到复杂的实现机制,最后通过实际代码示例,揭示操作系统如何高效协调资源,确保多任务顺畅运行的秘密。准备好了吗?让我们启航,探索那些隐藏在日常电脑使用背后的奥秘。 【8月更文挑战第28天】在这个数字时代,拥有一款自己的移动应用程序不仅是技术的展示,也是实现创意和解决问题的一种方式。本文将引导初学者了解安卓开发的基础知识,通过一个简单的待办事项列表App项目,逐步介绍如何利用安卓开发工具和语言来创建、测试并发布一个基本的安卓应用

热门文章

最新文章

下一篇
无影云桌面