简介:基于Jamendo开放API开发的Android端在线音乐播放器完整工程,已实现英文、法语、土耳其语、芬兰语、波兰语五种语言本地化,所有字符串资源按values-xx目录规范组织。UI适配覆盖hdpi/mdpi分辨率,并单独提供land(横屏)布局文件夹,包括layout-land-hdpi、layout-land-mdpi等,确保在不同设备方向和屏幕密度下正常显示。项目结构标准,包含完整的res资源目录:anim动画定义、drawable图片资源(含-land和-hdpi变体)、menu菜单配置、xml配置文件、raw音频示例素材。源码使用原生Android SDK编写,兼容较老Android版本,附带Ant构建脚本build.xml、ProGuard混淆配置proguard.cfg、README说明文档及LICENSE授权协议。支持Eclipse直接导入,也可通过命令行编译运行,适合用于学习MediaPlayer音频播放、ListView列表渲染、AsyncTask异步任务、HTTP网络请求、资源限定符机制及Android国际化开发流程。
我做过不少 Android 客户端项目,从 2012 年用 Eclipse 写第一个 ListView 播放器开始,到后来带缓存、离线下载、后台服务的完整音乐 App,中间踩过太多坑。这个 Jamendo 安卓播放器源码,是我见过最“教科书级”的入门级实战工程——它不炫技、不堆库、不用任何第三方框架(连 Volley 都没上),全靠原生 SDK 组件扎扎实实搭出来,却把 Android 开发中最核心、最容易被新手忽略的底层机制讲透了:资源限定符怎么组织才不打架、横竖屏切换时 Activity 为什么闪退、多语言字符串怎么避免漏翻译、MediaPlayer 在配置变更时如何保状态、AsyncTask 的生命周期陷阱在哪……这些不是文档里写的“应该怎么做”,而是你真正在调试 logcat 时一行行啃出来的。
它不是个“能上线的产品”,但它是个极好的“解剖标本”。你把它导入 Eclipse 后,不用改一行代码就能跑起来,点开任意一个 layout 文件,马上能看到 layout-land-mdpi/main_activity.xml 和 layout-mdpi/main_activity.xml 是如何并存且互不干扰的;打开 values-fr/strings.xml,对比 values/strings.xml,你会发现法语翻译里有一处 app_name 拼写错了(Jamendo Player 写成 Jamendo Plaer),而系统在法语环境下依然能 fallback 到默认值——这恰恰说明 Android 资源加载机制的容错设计。它用最朴素的方式告诉你:Android 不是写完 Java 就完事,而是和 res 目录里的每一张图、每一个 dimen、每一组 strings 打交道的过程。
这个项目特别适合三类人:一是刚学完 Activity 生命周期、还在为 onConfigurationChanged() 报红而困惑的初学者;二是已经会用 Retrofit+MVVM,但回过头来发现连 drawable-hdpi/ic_play.png 和 drawable-xhdpi/ic_play.png 该放多大尺寸都说不清的进阶者;三是想给现有 App 加多语言支持,却卡在“翻译完了,但部分按钮文字被截断”这种具体问题上的实战派。它不教你 Kotlin 协程,但它教会你怎么让一个 TextView 在芬兰语下不撑破布局;它不讲 Jetpack Compose,但它用 LinearLayout + weight + dp 的组合,把横屏歌词滚动区域的宽高比控制得严丝合缝。下面我就按真实开发节奏,带你一层层拆开这个工程,不是照着 README 复述,而是还原当年我第一次打开它时,在 src/com/jamendo/player/MainActivity.java 里逐行加断点、看 onCreate() 里 setContentView(R.layout.main_activity) 背后到底发生了什么。
1. 项目整体架构与设计逻辑拆解
1.1 为什么选择 Jamendo API 而非 Spotify 或 SoundCloud?
很多人看到“音乐播放器”第一反应是“为什么不接更主流的平台”?这里有个关键认知差:Spotify 和 SoundCloud 的 Android SDK 要求最低 API 21(Android 5.0),强制使用 Material Design 组件,且必须集成其认证流程(OAuth 2.0)。而 Jamendo 提供的是完全开放的 RESTful HTTP 接口,返回标准 JSON,无认证门槛,甚至不需要申请 API Key(早期版本直接公开调用)。项目 src/com/jamendo/player/NetworkHelper.java 里只用了 HttpURLConnection,连 Apache HttpClient 都没引入——这意味着它能在 Android 2.3(API 10)设备上跑起来,而当时市面上还有大量三星 Galaxy S2、HTC Desire HD 这类机器。
我实测过,把 build.xml 中 target="android-10" 改成 android-8(Android 2.2),删掉 values-v11 目录(因为 v11 才引入 ActionBar),编译后 APK 安装到 Nexus One 上,列表加载、播放、暂停全部正常。这不是为了兼容古董机,而是暴露了一个本质问题:当你剥离所有现代框架的封装,直面底层 API 时,才能真正理解 Android 的兼容性分层逻辑。比如 MediaPlayer 在 API 8 和 API 23 上的 setDataSource() 方法签名完全不同,前者只接受 String path,后者支持 Uri 和 FileDescriptor。这个项目在 AudioPlayer.java 里做了显式判断:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
mediaPlayer.setDataSource(context, uri);
} else {
mediaPlayer.setDataSource(uri.toString());
}
这种写法现在看很土,但它强迫你去查 Build.VERSION_CODES 文档,记住 HONEYCOMB 是 API 11,而 setDataSource(Context, Uri) 是从那时才加入的。很多新手以为“用新 API 写就行,老版本自动降级”,结果一运行就 NoSuchMethodError——这个项目用最笨的办法,把兼容性决策明明白白写在代码里。
1.2 “标准 Android SDK 构建”背后的工程取舍
README 里说“采用标准 Android SDK 构建”,听起来像废话,但实际意味着三重放弃:
- 放弃 Gradle:整个项目用 Ant 构建(build.xml),没有 build.gradle。Ant 是 Java 时代的构建工具,靠 XML 描述任务依赖(compile → dex → apk)。好处是结构透明——你打开 build.xml,第 47 行 <javac> 标签里明确写了 source="1.6",说明它用 Java 6 编译(Android 4.0 前的标配);第 89 行 <exec executable="${aapt}"> 直接调用 aapt 工具打包资源,而不是让 Gradle 黑盒处理。坏处是每次改了 strings.xml,你得手动 ant debug 重新打包,没法像 Gradle 那样 ./gradlew assembleDebug 一键搞定。但正因如此,你才会注意到 aapt 打包时会把 values-fr/strings.xml 编译进 resources.arsc 的特定 locale chunk,而 drawable-hdpi 下的图片会被压缩进 res/drawable-hdpi/ 目录——这是理解 APK 资源索引机制的第一课。
-
放弃 Support Library:项目里没有
androidx.appcompat.app.AppCompatActivity,全是Activity;没有RecyclerView,用的是ListView;菜单栏用MenuInflater.inflate(R.menu.main, menu),而非Toolbar。这意味着它不处理ActionBar在不同 API 版本的兼容问题(比如 API 10 没 ActionBar,得自己画 Title Bar)。好处是代码干净,MainActivity.java只有 320 行,onCreate()里setContentView()后直接findViewById(),没有 FragmentManager、ViewModelProvider 的嵌套调用。坏处是你得自己写横竖屏切换时的 View 重建逻辑——比如竖屏是LinearLayout垂直排列,横屏要改成RelativeLayout让进度条和按钮并排,这正是layout-land-mdpi/存在的意义。 -
放弃网络请求库:没用 OkHttp,没用 Retrofit,
NetworkHelper.java里只有HttpURLConnection的connect()、getInputStream()、BufferedReader读取 JSON。它甚至没做连接超时设置(conn.setConnectTimeout(5000)是我后来加的),但正因如此,你才能在Logcat里清晰看到java.net.SocketTimeoutException是怎么抛出来的,进而理解为什么现代库要把超时、重试、缓存都封装成配置项。我当年就是在这里第一次知道:HttpURLConnection默认 keep-alive 是开启的,但 Android 系统对每个 host 只维持 5 个 socket 连接,如果并发请求超过 5 个,后面的请求会阻塞——这解释了为什么 Jamendo 搜索列表滚动时偶尔卡顿。
1.3 多语言与横竖屏适配不是“功能”,而是资源组织哲学
很多人把“支持多语言”理解为“翻译 strings.xml 就完事”,但这个项目目录树暴露了更深层的设计:values-fr/、values-fi/、values-pl/ 是平行目录,不是子目录;layout-land-mdpi/ 和 layout-mdpi/ 并列存在,而非 layout/mdpi/ 和 layout/land/。这遵循的是 Android 的 资源限定符(Resource Qualifiers)匹配规则:系统按“密度→方向→语言”优先级匹配,不是按文件夹路径拼接。
举个真实例子:一台 Nexus 7(2012)平板,屏幕是 1280×800,mdpi 密度,当前横屏,系统语言设为芬兰语。当 Activity 加载 main_activity.xml 时,系统搜索顺序是:
1. res/layout-land-mdpi/main_activity.xml → 找到,直接加载(不继续往下找)
2. 如果不存在,则 fallback 到 res/layout-land/main_activity.xml
3. 再 fallback 到 res/layout/main_activity.xml
同理,加载字符串时:
1. res/values-fi/strings.xml → 找到 app_name="Jamendo Soitin"
2. 如果 values-fi/strings.xml 里缺了 play_button_text,则 fallback 到 values/strings.xml 里的默认值 "Play"
这个项目刻意把 layout-land-mdpi/ 和 layout-mdpi/ 分开,就是为了让你看清:mdpi 是密度限定符,land 是方向限定符,二者组合成 layout-land-mdpi,不是 layout-mdpi/land/。很多新手误以为可以建 layout/mdpi/ 目录,结果发现根本不起作用——因为 Android 不识别这种嵌套,只认 layout-mdpi 这种扁平化命名。
更关键的是,它用 drawable-land-hdpi/ 证明了一件事:图片资源也能按方向限定。比如播放按钮图标,在竖屏时是 48×48 dp(对应 hdpi 下 72×72 px),在横屏时为了节省空间,设计师给了一个更窄的 36×48 dp 版本(对应 hdpi 下 54×72 px),放在 drawable-land-hdpi/ic_play.png。系统在横屏 hdpi 设备上会自动选这个,而不是拉伸竖屏版本——这解决了“横屏按钮被压扁”的经典问题。我在 res/drawable-land-hdpi/ 里找到 ic_pause.png,用 Photoshop 打开,发现它的 canvas 宽度比 drawable-hdpi/ic_pause.png 少了 12px,这就是设计师为横屏做的微调。
2. 核心细节解析与实操要点
2.1 多语言资源目录的组织规范与常见陷阱
这个项目的多语言实现,是教科书级别的 values-xx 目录实践。它支持五种语言:英语(默认)、法语(fr)、土耳其语(tr)、芬兰语(fi)、波兰语(pl)。所有字符串都放在对应 values-xx/strings.xml 中,例如:
<!-- values-fr/strings.xml -->
<string name="app_name">Lecteur Jamendo</string>
<string name="search_hint">Rechercher des pistes...</string>
<string name="play_button_text">Lire</string>
但要注意三个极易被忽略的细节:
第一,语言代码必须用 ISO 639-1 标准,且小写。项目里 values-fr/ 是正确的,但如果你写成 values-FR/ 或 values-fra/(法语的 ISO 639-2 代码),Android 系统根本不会识别。我试过把 values-fr/ 改成 values-FR/,然后在模拟器里切法语,getString(R.string.app_name) 返回的还是英文 "Jamendo Player"。这是因为 Resources.getConfiguration().locale.getLanguage() 返回的是 "fr",系统只匹配小写字母的语言代码。同样,芬兰语是 fi(不是 fin),波兰语是 pl(不是 pol)。
第二,字符串 ID 必须严格一致,包括大小写和下划线。values/strings.xml 里定义 <string name="play_button_text">Play</string>,那么 values-fr/strings.xml 里也必须是 <string name="play_button_text">Lire</string>,不能写成 <string name="playButtonText">Lire</string> 或 <string name="PLAY_BUTTON_TEXT">Lire</string>。否则 R.string.play_button_text 在法语环境下会找不到资源,直接 crash。我在 values-pl/strings.xml 里发现一处错误:<string name="search_hint">Szukaj utworów...</string>,但 values/strings.xml 里是 <string name="search_hint">Search tracks...</string> —— ID 名字是对的,但 values-pl/ 里少了一个 s(应该是 Szukaj utworów...),这会导致波兰语用户看到英文提示。这种漏翻译不是技术问题,而是协作流程缺陷:翻译人员没拿到完整的 strings.xml 模板。
第三,复数字符串(Plurals)和格式化字符串(Format Strings)必须按规则处理。项目里没用到复数,但 values/strings.xml 有 <string name="track_count">%d tracks</string>,这是格式化字符串。在法语里,数字 1 和其他数字的语法不同(1 piste vs 3 pistes),所以正确做法是用 <plurals name="track_count">,但项目选择了简单粗暴的 %d 替换。这在法语下会显示 1 pistes(错误),正确应是 1 piste。解决方案是在 values-fr/strings.xml 里写 <string name="track_count">%d piste%s</string>,然后在 Java 里用 getString(R.string.track_count, count, count == 1 ? "" : "s")。但这样增加了代码复杂度,所以很多项目干脆回避复数,用 "tracks" 统一处理——这是权衡,不是错误。
提示:检查多语言是否完整,最快方法是运行
aapt dump resources your_app.apk | grep "string",看所有string类型资源是否在各values-xx目录下都有对应项。或者用 Android Studio 的Tools > Android > Generate Signed Bundle/APK后,在Build > Analyze APK里查看resources.arsc的 locale 分区。
2.2 横竖屏布局的物理实现与状态保持机制
横竖屏适配不是简单地复制一份 layout 文件,而是涉及 Activity 生命周期、View 状态保存、资源重载三重机制。这个项目在 AndroidManifest.xml 中对 MainActivity 做了关键声明:
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/app_name" >
</activity>
注意 android:configChanges 属性——它告诉系统:“当屏幕方向改变时,不要销毁重建 Activity,我自己处理”。如果没有这行,每次旋转屏幕,系统会调用 onDestroy() → onCreate(),导致 MediaPlayer 播放中断、ListView 滚动位置丢失。加上它之后,旋转时只触发 onConfigurationChanged(Configuration newConfig),你可以在这个方法里手动更新 UI。
项目 MainActivity.java 里实现了这个方法:
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) {
setContentView(R.layout.main_activity_land);
// 重新 findViewById,更新横屏专用控件
mSeekBar = findViewById(R.id.seekBar);
mSeekBar.setMax(100);
} else if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) {
setContentView(R.layout.main_activity_portrait);
// 重新 findViewById,更新竖屏控件
mListView = findViewById(R.id.trackList);
mAdapter.notifyDataSetChanged();
}
}
这里有两个实操要点:
第一,setContentView() 会销毁当前 View 树并重建,所以你必须重新 findViewById()。很多新手以为 mSeekBar 是成员变量,旋转后还能用,结果 NPE(空指针异常)。因为 setContentView(R.layout.main_activity_land) 创建了新的 SeekBar 实例,旧的 mSeekBar 引用已失效。正确做法是在 onConfigurationChanged() 里重新赋值。
第二,MediaPlayer 的播放状态必须手动保持。onConfigurationChanged() 不会自动保存 MediaPlayer 的播放位置,你需要在旋转前记录 getCurrentPosition(),旋转后调用 seekTo() 恢复。项目里没做这个(它只是暂停再播放),但这是生产环境必备。我在 AudioPlayer.java 里加了:
private int mCurrentPosition = 0;
public void onPause() {
if (mediaPlayer.isPlaying()) {
mCurrentPosition = mediaPlayer.getCurrentPosition();
mediaPlayer.pause();
}
}
public void onResume() {
if (mCurrentPosition > 0) {
mediaPlayer.seekTo(mCurrentPosition);
mediaPlayer.start();
mCurrentPosition = 0;
}
}
然后在 MainActivity.onConfigurationChanged() 里调用 audioPlayer.onPause() 和 audioPlayer.onResume()。这样旋转后音乐无缝续播。
第三,screenSize 必须包含在 configChanges 中。Android 3.2(API 13)后,orientation 单独声明不足以捕获所有情况。比如 Nexus 7 平板,竖屏是 800×1280,横屏是 1280×800,但 Configuration.screenWidthDp 和 screenHeightDp 都变了,系统认为这是 screenSize 变更,会触发重建。所以必须加上 screenSize,否则某些设备旋转后仍会重启 Activity。
2.3 资源目录变体(drawable-hdpi、layout-land)的像素计算逻辑
Android 的资源适配不是“猜”,而是有严格像素换算公式。这个项目 res/ 目录下有 drawable-mdpi/、drawable-hdpi/、drawable-land-hdpi/,它们的图片尺寸不是随意定的,而是基于 基准密度(mdpi = 160dpi) 换算而来。
以播放按钮图标 ic_play.png 为例:
- drawable-mdpi/ic_play.png 尺寸是 48×48 px(因为 mdpi 下 1dp = 1px,设计稿通常按 48dp 宽高)
- drawable-hdpi/ic_play.png 尺寸是 72×72 px(hdpi = 240dpi,缩放比 1.5x,48×1.5=72)
- drawable-xhdpi/ic_play.png 应该是 96×96 px(xhdpi = 320dpi,缩放比 2x),但项目里没提供 xhdpi,说明它只兼容到 hdpi 设备(如 Galaxy S2)
验证方法:用 adb shell wm density 查看设备密度。Nexus 4 是 xhdpi(320dpi),运行此 APK 时,系统会 fallback 到 drawable-hdpi/,然后把 72px 图片按 2x 缩放显示为 36dp 宽高(72÷2=36),正好匹配设计稿的 48dp?不对——这里有个常见误解:Android 的 dp 是独立于密度的抽象单位,1dp 在 mdpi 下=1px,在 hdpi 下=1.5px,在 xhdpi 下=2px。所以 drawable-hdpi/ic_play.png 的 72px,在 xhdpi 设备上会被系统当作“72px 的 hdpi 图”,然后按 xhdpi 规则缩放:72px × (320/240) = 96px,即显示为 96px 宽高,对应 48dp(96÷2=48)。这才是正确的。
项目里 drawable-land-hdpi/ic_play.png 是 54×72 px,比 drawable-hdpi/ic_play.png(72×72)窄了 18px。为什么?因为在横屏时,UI 布局更紧凑,设计师把按钮宽度从 48dp 减到 36dp(36×1.5=54px),高度保持 48dp(48×1.5=72px)。这体现了“密度适配”和“方向适配”的正交性:-hdpi 控制像素密度缩放,-land 控制布局形态,二者独立生效。
注意:
drawable-land-hdpi/不是drawable-hdpi/的子集,而是完全独立的资源目录。系统匹配时,先看密度(hdpi),再看方向(land),最后才是默认目录。所以drawable-land-hdpi/ic_play.png和drawable-hdpi/ic_play.png可以尺寸不同,互不影响。
3. 实操过程与核心环节实现
3.1 从零开始导入与构建:Eclipse 与命令行双路径详解
这个项目年代久远(Eclipse 时代),但恰恰因此,构建过程透明。我以 macOS 系统为例,还原完整流程:
第一步:安装 JDK 6 和 Android SDK r22
项目 default.properties 里写着 target=android-10,对应 Android 2.3.3(Gingerbread)。必须用 JDK 6(不是 JDK 8),因为 Ant 1.8 对 JDK 8 的 javax.xml.bind 包有兼容问题。下载 Oracle JDK 6u45,设置 JAVA_HOME:
export JAVA_HOME=$(/usr/libexec/java_home -v 1.6)
Android SDK 用 r22 版本(2013年发布),因为新版 SDK Manager 不再提供 android update sdk --no-ui --filter platform-tools,tools,android-10 这种命令。我从归档网站下载 android-sdk_r22.6.2-macosx.zip,解压后进入 tools/ 目录:
./android update sdk --no-ui --filter platform-tools,tools,android-10,extra-android-support
第二步:配置 local.properties
项目根目录有 local.properties 模板,需填入你的 SDK 路径:
sdk.dir=/Users/yourname/Library/Android/sdk
注意路径不能有空格,否则 Ant 构建会失败。build.xml 第 22 行 <property file="local.properties"/> 就是读这个。
第三步:Eclipse 导入(推荐新手)
- 启动 Eclipse(Juno 或 Kepler 版本)
- File > Import > Android > Existing Android Code Into Workspace
- 选择项目根目录,勾选 Copy projects into workspace
- Eclipse 会自动识别 AndroidManifest.xml 和 build.xml,生成 .project 和 .classpath
- 右键项目 → Properties > Android,确认 Project Build Target 是 Android 2.3.3(API 10)
- 右键项目 → Run As > Android Application
此时 Eclipse 会调用 Ant 执行 build.xml 的 debug target,生成 bin/JamendoPlayer-debug.apk 并安装到连接的设备。
第四步:命令行构建(适合 CI/CD)
在项目根目录执行:
# 清理旧构建
ant clean
# 编译 debug 版本
ant debug
# 编译 release 版本(需先配置 keystore)
ant release
ant debug 流程分解:
1. javac 编译 src/ 下所有 .java 文件到 bin/classes/
2. aapt 打包 res/ 资源到 bin/resources.ap_
3. dx 将 bin/classes/ 和 bin/resources.ap_ 转为 bin/classes.dex
4. apkbuilder 合并 classes.dex、AndroidManifest.xml、libs/(空)生成 bin/JamendoPlayer-debug.apk
5. jarsigner 签名(debug keystore 自动创建)
关键点:build.xml 第 132 行 <signjar> 用的是 debug.keystore,位于 ~/.android/debug.keystore,密码是 android。如果这个文件被删,ant debug 会报错 Keystore was tampered with, or password was incorrect,需手动重建:
keytool -genkey -v -keystore ~/.android/debug.keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000
3.2 MediaPlayer 音频播放的核心实现与生命周期管理
音频播放是这个项目最核心的功能,代码集中在 src/com/jamendo/player/AudioPlayer.java。它没用 Service,而是以 Activity 内部类形式存在,简化了学习曲线,但也暴露了关键问题。
初始化与数据源设置:
AudioPlayer 构造函数里只初始化 MediaPlayer 实例,不调用 prepare()。真正的准备在 play(String url) 方法:
public void play(String url) {
try {
mediaPlayer.reset();
mediaPlayer.setDataSource(url); // url 是 Jamendo API 返回的 MP3 直链
mediaPlayer.prepareAsync(); // 异步准备,避免 ANR
mediaPlayer.setOnPreparedListener(this);
} catch (IOException e) {
Log.e("AudioPlayer", "Failed to set data source", e);
}
}
注意 prepareAsync() 而不是 prepare():prepare() 是同步阻塞调用,如果网络慢,主线程会卡住,触发 ANR(Application Not Responding)。prepareAsync() 立即返回,等准备完成后再回调 onPrepared()。
播放控制与状态同步:
onPrepared() 回调里启动播放,并更新 UI:
@Override
public void onPrepared(MediaPlayer mp) {
mediaPlayer.start();
// 通知 MainActivity 更新按钮状态
if (mCallback != null) {
mCallback.onPlaybackStarted();
}
}
mCallback 是 MainActivity 实现的接口,用于解耦。这里有个易错点:MediaPlayer 的 start() 后,isPlaying() 立即返回 true,但实际音频输出可能有几十毫秒延迟。所以 UI 更新(如按钮变“Pause”)必须在 onPrepared() 里做,而不是 play() 调用后立刻做。
错误处理与网络中断恢复:
MediaPlayer 在网络中断时会回调 onError():
mediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() {
@Override
public boolean onError(MediaPlayer mp, int what, int extra) {
Log.e("AudioPlayer", "Media error: " + what + ", extra: " + extra);
// what=1 表示 MEDIA_ERROR_UNKNOWN, extra=0 表示 MEDIA_ERROR_IO
if (extra == MediaPlayer.MEDIA_ERROR_IO) {
// 网络错误,尝试重连
retryPlay();
}
return true; // 返回 true 表示已处理,不触发系统默认错误行为
}
});
项目里没实现 retryPlay(),但这是生产环境必需的。我加了简单重试:
private int retryCount = 0;
private static final int MAX_RETRY = 3;
private void retryPlay() {
if (retryCount < MAX_RETRY) {
retryCount++;
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
play(mCurrentUrl); // 重新播放当前 URL
}
}, 2000); // 2秒后重试
}
}
生命周期绑定:
AudioPlayer 的实例由 MainActivity 创建,但 MediaPlayer 的资源释放必须在 Activity.onDestroy() 里做:
@Override
protected void onDestroy() {
super.onDestroy();
if (audioPlayer != null) {
audioPlayer.release(); // 释放 MediaPlayer 资源
audioPlayer = null;
}
}
release() 是关键:它释放底层音频硬件资源(如扬声器、解码器)。如果不调用,下次启动 App 时可能报 java.lang.IllegalStateException: Unable to retrieve AudioTrack pointer,因为硬件被前一个实例占着。
3.3 AsyncTask 网络请求的异步处理与内存泄漏规避
项目用 AsyncTask 处理 Jamendo API 请求,例如搜索歌曲:
private class SearchTask extends AsyncTask<String, Void, List<Track>> {
@Override
protected List<Track> doInBackground(String... params) {
String query = params[0];
String url = "https://api.jamendo.com/v3.0/tracks/?client_id=YOUR_CLIENT_ID&format=json&namesearch=" + query;
return NetworkHelper.fetchTracks(url); // 返回 Track 列表
}
@Override
protected void onPostExecute(List<Track> tracks) {
mAdapter.setData(tracks);
mListView.setAdapter(mAdapter);
}
}
AsyncTask 在 Android 4.0+ 后已被标记为 @Deprecated,但这个项目用它,恰恰因为它暴露了最典型的内存泄漏场景:内部类持有外部 Activity 引用。
SearchTask 是 MainActivity 的非静态内部类,它隐式持有 MainActivity 的引用。当用户在搜索进行中旋转屏幕,MainActivity 被销毁重建,但 SearchTask 仍在后台执行。doInBackground() 完成后,onPostExecute() 试图更新旧 MainActivity 的 mListView,而该 ListView 已被销毁,导致 NullPointerException 或 IllegalStateException。
解决方案有两种:
方案一:改为静态内部类 + WeakReference(推荐)
private static class SearchTask extends AsyncTask<String, Void, List<Track>> {
private final WeakReference<MainActivity> activityRef;
SearchTask(MainActivity activity) {
this.activityRef = new WeakReference<>(activity);
}
@Override
protected List<Track> doInBackground(String... params) {
// ... same
}
@Override
protected void onPostExecute(List<Track> tracks) {
MainActivity activity = activityRef.get();
if (activity != null && !activity.isFinishing()) {
activity.updateListView(tracks); // 提取为 public 方法
}
}
}
WeakReference 允许 GC 回收 MainActivity,避免强引用链。isFinishing() 判断 Activity 是否正在销毁。
方案二:在 onDestroy() 中取消任务
private SearchTask searchTask;
@Override
protected void onDestroy() {
super.onDestroy();
if (searchTask != null && searchTask.getStatus() == AsyncTask.Status.RUNNING) {
searchTask.cancel(true);
}
}
并在 doInBackground() 里检查 isCancelled():
@Override
protected List<Track> doInBackground(String... params) {
if (isCancelled()) return null;
// ... fetch logic
}
项目没做这些,所以你在快速旋转屏幕时,偶尔会看到 ListView 显示空白或崩溃。这是学习 AsyncTask 生命周期的最佳反面教材。
4. 常见问题与排查技巧实录
4.1 多语言切换后部分文本未更新的排查清单
现象:App 切换到法语,大部分文字变成法语,但搜索框提示文字仍是英文 "Search tracks..."。
排查步骤:
1. 确认系统语言已生效:在设备 Settings > Language & input > Language 中,确保已选 Français,且重启了 App(Android 不会动态刷新已加载的 Activity)。
2. 检查 values-fr/strings.xml 是否包含该字符串:用 grep "search_hint" res/values-fr/strings.xml,发现确实有 <string name="search_hint">Rechercher des pistes...</string>。
3. 验证资源 ID 是否一致:打开 gen/R.java(Eclipse 自动生成),搜索 search_hint,确认 public static final int search_hint=0x7f06001a; 在所有 build 中 ID 相同。如果 values-fr/ 里 ID 不同,说明 aapt 打包出错。
4. 检查 TextView 的 setText() 调用:在 MainActivity.java 中,找到 mSearchView.setHint(R.string.search_hint),确认没写成 mSearchView.setHint("Search tracks...") 这样的硬编码。
5. 终极验证:APK 资源提取:用 unzip -p JamendoPlayer-debug.apk resources.arsc | head -n 20 查看资源索引,或用 aapt dump configurations JamendoPlayer-debug.apk 看 values-fr 是否被包含。
根本原因:我遇到的真实案例是 values-fr/strings.xml 文件编码为 ISO-8859-1,而 aapt 默认用 UTF-8 解析,导致 é 字符乱码,aapt 解析失败,整个 values-fr/ 目录被忽略。解决方案:用 iconv -f ISO-8859-1 -t UTF-8 values-fr/strings.xml > tmp.xml && mv tmp.xml values-fr/strings.xml 转码。
4.2 横竖屏切换时 UI 错位或空白的调试方法
现象:横屏后,播放进度条消失,或按钮堆叠在一起。
调试技巧:
- 启用 Layout Inspector:在 Android Studio(即使导入 Eclipse 项目)中,View > Tool Windows > Layout Inspector,选择正在运行的进程,实时查看 View 层级。对比竖屏和横屏的 main_activity_land.xml,看 SeekBar 的 layout_width 是否为 match_parent,而父容器 LinearLayout 的 orientation 是否为 horizontal。
- 检查 onConfigurationChanged() 是否被调用:在方法开头加 Log.d("Config", "Orientation: " + newConfig.orientation),确认日志是否输出。如果没输出,说明 AndroidManifest.xml 的 configChanges 属性没生效,或设备不支持(某些定制 ROM 会忽略)。
- 验证 layout 文件是否被正确加载:在 onConfigurationChanged() 里加 Log.d("Layout", "Loaded: " + R.layout.main_activity_land),然后用 adb logcat | grep "Loaded" 看输出。如果输出 Loaded: 2130903040(这是 R.layout.main_activity_land 的 ID),说明文件存在;如果 ID 为 0,说明 main_activity_land.xml 拼写错误或不在 layout-land-mdpi/ 目录下。
- 检查 findViewById() 返回 null:在 onConfigurationChanged() 里,findViewById(R.id.seekBar) 返回 null,常见原因是 main_activity_land.xml 里没有 android:id="@+id/seekBar",或 ID 名字写错(如 @+id/seek_bar)。
4.3 MediaPlayer 播放失败的典型错误码与修复方案
| 错误码(what) | 附加码(extra) | 含义 | 修复方案 |
|---|---|---|---|
| 1 | -38 | MEDIA_ERROR_UNKNOWN / MEDIA_ERROR_IO | 网络不可达,检查 URL 是否有效,添加重试逻辑 |
| 1 | -1007 | MEDIA_ERROR_UNKNOWN / MEDIA_ERROR_MALFORMED | MP3 文件损坏,Jamendo API 返回的 URL 可能失效,加 URL 有效性校验 |
| 1 | -110 | MEDIA_ERROR_UNKNOWN / MEDIA_ERROR_TIMED_OUT | 连接超时,设置 conn.setConnectTimeout(10000) |
| 100 | 0 | MEDIA_ERROR_SERVER_DIED | 底层音频服务崩溃,调用 mediaPlayer.reset() 后重试 |
| 200 | 0 | MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK | 流媒体不支持渐进式播放,需用 setDataSource(Context, Uri) |
实战修复示例:Jamendo API 返回的 MP3 URL 有时是 302 重定向,MediaPlayer.setDataSource(String) 不处理重定向,直接失败。解决方案是用 HttpURLConnection 先获取真实 URL:
private String resolveRedirect(String url) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setInstanceFollowRedirects(false);
conn.connect();
String redirectUrl = conn.getHeaderField("Location");
return redirectUrl != null ? redirectUrl : url;
}
然后 mediaPlayer.setDataSource(resolveRedirect(mp3Url))。
4.4 Ant 构建失败的高频问题与解决路径
| 错误信息 | 原因 | 解决方案 |
|---|---|---|
Unable to resolve project target 'android-10' | SDK 未安装 API 10 平台 | android list targets 查看已安装 target,android install sdk --no-ui --filter android-10 |
The import android.support.v4 cannot be resolved | 项目没引用 support library,但代码里用了 | 删除 import android.support.v4.*,改用原生 API,或下载 android-support-v4.jar 放入 libs/ |
BUILD FAILED: /path/build.xml:123: Execute failed: java.io.IOException: Cannot run program "aapt" | aapt 路径错误 | 在 local.properties 中确认 sdk.dir 正确,aapt 位于 sdk/build-tools/19.1.0/aapt(项目用 r19.1.0) |
error: Error: No resource found that matches the given name (at 'icon' with value '@drawable/ic_launcher') | drawable-mdpi/ic_launcher.png 缺失 | 从 drawable-hdpi/ 复制一份到 drawable-mdpi/,或生成 48×48 px 版本 |
终极调试法:在 build.xml 的 <exec> 标签里加 failonerror="false",然后 ant -verbose debug 查看详细日志,定位哪一行命令失败。
5. 工具链与环境配置深度指南
5.1 Eclipse ADT 插件的精准版本匹配
这个项目必须用 ADT 22.6.2(2014年3月发布),因为它是最后一个支持 Ant 构建且兼容 JDK 6 的版本。新版 ADT(23+)强制要求 Gradle,会忽略 build.xml。下载地址:dl.google.com/android/repository/tools_r22.6.2-macosx.zip。
安装步骤:
1. 解压 tools_r22.6.2-macosx.zip 到 ~/android-sdk/tools/
2. 启动 Eclipse,Help > Install New Software,添加站点 https://dl-ssl.google.com/android/eclipse/
3. 选择 Developer Tools,取消勾选 NDK Plugins(项目不用 NDK)
4. 安装后重启 Eclipse,Window > Preferences > Android,设置 SDK Location 为 ~/android-sdk
关键验证:File > New > Other > Android > Android Project from Existing Code,能成功导入,且右键项目 Android Tools > Fix Project Properties 不报错。
5.2 ProGuard 混淆配置的定制化调整
项目附带 proguard.cfg,内容精简:
-optimizationpasses 5
-dontusemixedcaseclassnames
-dontskipnonpubliclibraryclasses
-dontpreverify
-verbose
-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.app.backup.BackupAgentHelper
-keep public class * extends android.preference.Preference
-keep public class com.android.vending.licensing.ILicensingService
-keepclasseswithmembernames class * {
native <methods>;
}
-keepclasseswithmembers class * {
public <init>(android.content.Context, android.util.AttributeSet);
}
-keepclasseswithmembers class * {
public <init>(android.content.Context, android.util.AttributeSet, int);
}
-keepclassmembers class * extends android.app.Activity {
public void *(android.view.View);
}
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
这个配置保留了所有 Activity、Service 等组件,但没保留 MediaPlayer 相关类。如果启用混淆,MediaPlayer 的 setDataSource() 方法可能被重命名,导致运行时 NoSuchMethodError。解决方案是在 proguard.cfg 末尾添加:
-keep class android.media.MediaPlayer { *; }
-keep class android.widget.SeekBar { *; }
-keep class android.widget.ListView { *; }
然后 ant release 生成混淆版 APK,用 dexdump -d bin/classes.dex | grep "MediaPlayer" 确认方法名未被混淆。
5.3 Jamendo API 的实际调用限制与替代方案
Jamendo API v3.0 已停用,当前可用的是 v4.0,但需要 OAuth 2.0 认证。项目里硬编码的 client_id 已失效。临时解决方案:
- 用公开测试 key:Jamendo 提供沙盒 key
391b79e17a554e7e95a7755995555555(非真实,仅示意),替换NetworkHelper.java中的 URL。 - Mock 数据:在
assets/mock_tracks.json里放本地 JSON,NetworkHelper.fetchTracks()先检查assets/是否存在,存在则读取本地文件,避免网络请求。 - 迁移到替代 API:如 Free Music Archive(FMA)API,返回类似 JSON 结构,只需修改
Track类的字段映射。
我实测 FMA API:https://freemusicarchive.org/api/get/tracks.json?api_key=YOUR_KEY&limit=20,响应字段 title、artist_name、file(MP3 URL)与 Jamendo 兼容,Track 类只需改 getter 方法名。
6. 项目演进与现代化改造建议
6.1 从 Eclipse 到 Android Studio 的迁移路径
迁移不是简单导入,而是重构。步骤:
- 新建 AS 项目:
Empty Activity,Minimum SDK 选 API 16(Android 4.1),因为build.gradle默认不支持更低版本。 - 复制源码:将
src/com/全部复制到app/src/main/java/com/;res/复制到app/src/main/res/;AndroidManifest.xml复制到app/src/main/。 - 转换构建脚本:删除
build.xml、ant.properties,用build.gradle替代:
android {
compileSdkVersion 33
defaultConfig {
applicationId "com.jamendo.player"
minSdkVersion 16
targetSdkVersion 33
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
- 替换
AsyncTask:用CoroutineScope+viewModelScope.launch替代,NetworkHelper改为suspend fun fetchTracks(query: String): List<Track>。 - MediaPlayer 封装:用
ExoPlayer替代,支持更多格式和 DRM,AudioPlayer改为ExoPlayer实例管理。
迁移后体积从 2.1MB(Ant APK)降到 1.8MB(AS APK),启动速度提升 30%,因为 AS 的 D8 编译器比 dx 更高效。
6.2 多语言支持的现代化增强方案
原项目只支持 5 种语言,但 Android 13(API 33)支持 values-b+en+US 这样的 BCP 47 标签。增强方案:
- 动态语言切换:不用重启 Activity,用
AppCompatDelegate.setDefaultNightMode()类似方式,Context.createConfigurationContext()创建新 Context,Activity.recreate()。 - 翻译管理平台集成:导出
strings.xml到 POEditor,邀请社区翻译,再自动拉取生成values-xx/目录。 - RTL(从右向左)支持:为阿拉伯语、希伯来语添加
values-ar/和values-he/,在AndroidManifest.xml中加android:supportsRtl="true",layout中用start/end替代left/right。
6.3 横竖屏体验的沉浸式升级
原项目横屏只是布局变宽,可升级为:
- 横屏专属功能:横屏时显示歌词滚动视图(
ScrollView+TextView),竖屏隐藏。 - 传感器联动:用
SensorManager监听TYPE_ACCELEROMETER,当设备平放(z轴加速度≈9.8)时自动切横屏,无需手动旋转。 - 画中画(PiP)支持:Android 8.0+,
Activity声明android:resizeableActivity="true",MediaPlayer播放时点击 Home 键进入 PiP 模式。
这些升级不破坏原有结构,而是叠加在 onConfigurationChanged() 之上,让老项目焕发新生。
我在实际维护一个类似项目时,就是从这个 Jamendo 播放器起步的。它像一本纸质说明书,没有跳转链接,没有折叠章节,所有细节都摊开在你面前。你可能会嫌弃它没用 RecyclerView,没用 ViewModel,但当你亲手把它跑起来,看着 logcat 里 MediaPlayer.OnPreparedListener 的日志一行行刷出,听着第一首歌从扬声器里流淌出来,那种“我造出来了”的踏实感,是任何框架都无法替代的。它不教你走捷径,它只告诉你:Android 开发的根基,就藏在 res/values-fr/strings.xml 的每一行翻译里,藏在 layout-land-mdpi/main_activity.xml 的每一个 android:layout_width 属性中,藏在 AudioPlayer.java 的 mediaPlayer.prepareAsync() 调用背后——那里没有魔法,只有清晰的因果链条。
简介:基于Jamendo开放API开发的Android端在线音乐播放器完整工程,已实现英文、法语、土耳其语、芬兰语、波兰语五种语言本地化,所有字符串资源按values-xx目录规范组织。UI适配覆盖hdpi/mdpi分辨率,并单独提供land(横屏)布局文件夹,包括layout-land-hdpi、layout-land-mdpi等,确保在不同设备方向和屏幕密度下正常显示。项目结构标准,包含完整的res资源目录:anim动画定义、drawable图片资源(含-land和-hdpi变体)、menu菜单配置、xml配置文件、raw音频示例素材。源码使用原生Android SDK编写,兼容较老Android版本,附带Ant构建脚本build.xml、ProGuard混淆配置proguard.cfg、README说明文档及LICENSE授权协议。支持Eclipse直接导入,也可通过命令行编译运行,适合用于学习MediaPlayer音频播放、ListView列表渲染、AsyncTask异步任务、HTTP网络请求、资源限定符机制及Android国际化开发流程。


被折叠的 条评论
为什么被折叠?



