历时2个多月,基于开源网易云API的一款使用Flutter
开发的桌面音乐程序终于完成了它的第一个版本v1.0.0🎉。再次感谢大佬为我们整理并提供众多接口。
效果图来一波。
本文接力上一篇介绍文,主要为大家讲解下开发DreamMusic中遇到的各种问题,以及关键实现。
用户登录
用户登录可以说是项目开发的第一步,你只有拿到cookie了,很多接口才能正常返回数据。我想,每一个基于网易云API开发项目的都有过下面的经历,调用登录接口 /login/cellphone
(PS:密码登录和验证码登录)返回{code: -460, message: ‘网络太拥挤,请稍候再试!’}
。GIthub上有人回答传入realIP
可解。其实真实原因就是你没传cookie,或者传的cookie不对。只要在请求头上添加cookie即可。
DreamMusic的登录态有三种,分别是:未登录,游客登录,用户登录。
大致流程是APP启动,读取本地保存的cookie,如果没有获取到,那么是第一次启动或是已经退出登录了,调用游客登录接口。如果存在cookie,但是没有MUSIC_U
这个cookie,那么就认为当前是游客登录状态。如果是存在cookie,并且cookie中有MUSIC_U
这个cookie的话,说明当前是用户登录状态,拉取用户信息接口能获取到具体的用户信息,如昵称,签名,手机号等。
[图片上传失败...(image-2f26d3-1667455027467)]
所以要实现上面的逻辑,关键在于cookie如何获取,从哪里获取。两个地方,一个是在每个接口响应头的set-cookie
字段中获取,还有一个地方是在登录接口的响应体中获取,比如手机号登录返回的json中有个cookie字段记录了cookie的值。
[图片上传失败...(image-97b6af-1667455027467)]
这里推荐一个Flutter中好用的cookie解析和管理三方库,cookie_jar
和dio_cookie_manager
,前者主要是cookie的解析和io操作,后面的是dio
的拦截器,可以在HTTP请求中注入和读取cookie,非常方便。
不过这里有个坑,就是如果你使用的是登录后响应体中的cookie,也就是返回json中的cookie的值,那么会存在解析异常的情况,也就是cookiejar会解析失败,并丢失部分cookie,具体原因可以自行查看两者的区别。因此我在项目中写了个辅助工具类CookieParse
,先修正格式问题,再交给cookieJar
处理。
做到上面这些,不出意外,登录应该是没问题了的。
音乐播放功能实现
音乐播放功能可以说是这个项目的核心了,当然由于Flutter是个UI框架,因此这部分功能还是需要依赖原生实现。市面上做的好的有关音乐播放的库其实也就两个,just_audio
和audioplayers
。
[图片上传失败...(image-ec09e6-1667455027467)]
而要同时支持macos
,windows
,linux
的就只有audioplayers
了。所以没得选,就用audioplayers
了。
当然在桌面端引入audioplayers
,不出意外的报错了。
Warning: CocoaPods not installed. Skipping pod install.
CocoaPods is used to retrieve the iOS and macOS platform side's plugin code that responds to your plugin usage on the Dart side.
Without CocoaPods, plugins will not work on iOS or macOS.
For more info, see https://flutter.dev/platform-plugins
To install see https://guides.cocoapods.org/using/getting-started.html#installation for instructions.
Exception: CocoaPods not installed or not in valid state.
这个问题看上去像是因为Cocoapods没有安装,导致无法给macos(iOS)平台安装正确的依赖环境,因此无法启动。事实上,cocoapods是安装了的,只要去掉audioplayers
这个三方库,立马能正常跑起来。那么问题就出在这个三方库上,它的引入似乎导致了Flutter识别异常。
现在audioplayer的git仓库的issue上搜索了一番,没找到相关问题。看来这个问题比较特殊,只有自己遇到过。
我的解决办法,mac系统升级,xcode升级。应该有其他解决方法,我这边是正好遇上要升级系统,索性先升级系统了。
运行起来后,另一个问题出现了。
[ERROR:flutter/lib/ui/ui_dart_state.cc(198)] Unhandled Exception: AVPlayerItem.Status.failed
#0 MethodChannelAudioplayersPlatform._doHandlePlatformCall
package:audioplayers_platform_interface/method_channel_audioplayers_platform.dart:174
#1 MethodChannelAudioplayersPlatform.platformCallHandler
package:audioplayers_platform_interface/method_channel_audioplayers_platform.dart:147
#2 MethodChannel._handleAsMethodCall
package:flutter/…/services/platform_channel.dart:404
#3 MethodChannel.setMethodCallHandler.<anonymous closure>
package:flutter/…/services/platform_channel.dart:397
#4 _DefaultBinaryMessenger.setMessageHandler.<anonymous closure>
package:flutter/…/services/binding.dart:380
#5 _DefaultBinaryMessenger.setMessageHandler.<anonymous closure>
package:flutter/…/services/binding.dart:377
#6 _invoke2.<anonymous closure> (dart:ui/hooks.dart:190:15)
#7 _rootRun (dart:async/zone.dart:1426:13)
#8 _CustomZone.run (dart:async/zone.dart:1328:19)
#9 _CustomZone.runGuarded (dart:async/zone.dart:1236:7)
#10 _invoke2 (dart:ui/hooks.dart:189:10)
#11 _ChannelCallbackRecord.invoke (dart:ui/channel_buffers.dart:42:5)
#12 _Channel.push (dart:ui/channel_buffers.dart:132:31)
#13 ChannelBuffers.push (dart:ui/channel_buffers.dart:329:17)
#14 PlatformDispatcher._dispatchPlatformMessage (dart:ui/platform_dispatcher.dart:589:22)
#15 _dispatchPlatformMessage (dart:ui/hooks.dart:89:31)
ERROR出现的直接原因就是AVPlayerItem
初始化失败,多为路径/url填错,如果是播放网络音乐,也可能是没有网络权限导致。
MacOS开发和iOS开发一样,如果要用一些系统级的功能,需要申请权限,在info.plist
中添加配置信息。
如果要支持HTTPS,在macos
目录下的info.plist
中添加如下键值对。
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
如果是iOS
到这一步就结束了,MacOS
开发则要多做一步,向macos
目录的DebugProfile.entitlements
添加如下键值对。
<key>com.apple.security.network.client</key>
<true/>
这时,音乐能正常播放了。
音乐播放的所有功能都封装在SongPlayer
类下,目前实现的功能有:播放列表,播放,暂停,上一首,下一首,音量调节,播放跳转到指定时间点。
路由控制器
用过网易云音乐桌面端的都知道在应用的左上角有个两个按钮,用于控制页面前进和后退,以及Tab的切换,非常方便。因此这个实用的功能必须加上。
要实现这个功能,我们必须记录用户的每一次页面切换和tab切换。我将这个行为抽象为RouteAction
,页面跳转是PageRouteAction
,Tab切换是TabRouteAction
。这些action都统一由RouteControlManager
管理,这样就能在前进和后退时利用这些action来控制页面的切换了。
Tab切换也就是侧边栏的时机好处理,毕竟这个侧边栏是自定义的,按钮的点击完全可控。我们直接来看页面的切换也就是导航的push和pop操作。这个怎么监听?答案是使用自定义NavigatorObserver
。NavigatorObserver中暴露了导航的每一次push和pop操作,以及相应的路由route对象,从中我们可以获取道对应的navigator
state和routesetting
,因此对于页面的切换也被我们掌控在了手中。
class CustomNavigatorObserver extends NavigatorObserver {
// push: route是将要push的路由
@override
void didPush(Route route, Route? previousRoute) {
super.didPush(route, previousRoute);
RouteControlManager().pushAction(PageRouteAction(navigator: route.navigator, settings: route.settings));
}
// pop: route是将要被pop的路由
@override
void didPop(Route route, Route? previousRoute) {
super.didPop(route, previousRoute);
RouteControlManager().popAction(PageRouteAction(navigator: route.navigator, settings: route.settings));
}
}
这里对这块没有深入的同学就要问了,为啥拿到avigatorState
和routesetting
就可以控制页面的切换了。那我就多嘴一句。
我们先来看下Flutter中页面跳转是如何控制的。有两个页面A,B。从页面A到页面B叫push,再从页面B回到页面A叫pop。
// push
Navigator.pushNamed(context, "B");
// pop,返回导航栈的上一层
Navigator.pop(context);
这里的关键就是获取到上下文context
,底层通过context
查找到树结构中最近的NavigatorState
,从而控制页面的导航。也就是context → NavigatorState
。为什么要这样,因为我们的每个页面在导航中都是以OverlayEntry
的形式存在并被管理的,拿到了NavigatorState
也就代表拿到了页面的控制权。
回到自定义的NavigatorObserver
中,这时,我们再进行Navigator.pushNamed(context, “setting”);
时,就可以在CustomNavigatorObserver
中拦截到跳转信息了。
flutter: [route]push --> ModelRoute(RouteSettings("setting", null),
animation: AnimationController#68be1(▶ 0.000; for ModelRoute)),
MaterialPageRoute<dynamic>(RouteSettings("/", null),
animation: AnimationController#c6365(⏭ 1.000; paused;
for MaterialPageRoute<dynamic>(/)))
而CustomNavigatorObserver
或者回调函数中的参数Route
都能直接获取到NavigatorState
,到这里,我们不通过context
而直接获取到了控制导航的NavigatorState
。
现在,我们就可以写两个方法来控制前进和返回。
返回,调用navigator
的pop()
。
void back() {
if (canBack()) {
final current = _actions[_currentIndex];
_currentIndex -= 1;
if (current.type == RouteActionType.page) {
PageRouteAction action = current as PageRouteAction;
action.navigator?.pop();
}
notifyListeners();
}
}
前进,调用navigator
的pushNamed
方法。
void forward() {
if (canForward()) {
_currentIndex += 1;
final current = _actions[_currentIndex];
if (current.type == RouteActionType.page)
PageRouteAction action = current as PageRouteAction;
if (action.settings.name != null) {
action.navigator?.pushNamed(action.settings.name!,
arguments: action.settings.arguments);
}
}
notifyListeners();
}
}
这样,我们就可以搭配返回和前进按钮,通过调用back()
和forward()
来全局控制页面的前进和后退了,完美。
歌词滚动和定位
知道每一行的高度,以及歌词列表的高度,可以通过context的findRenderObject来获取渲染组件的尺寸。
final ob = context.findRenderObject();
if (ob != null && ob is RenderBox) {
model?.size = ob.size;
}
结合滚动监听NotificationListener<ScrollNotification>
,获知当前的滚动偏移。计算出需要偏移的距离,调用ScrollController
的animateTo
即可实现滚动到指定行的歌词功能。
scrollController.animateTo(offsety,
duration: const Duration(milliseconds: 200), curve: Curves.ease);
下载音乐
DreamMusic还实现了音乐下载功能。不过遇到了文件权限问题,我在下载歌曲尝试写入本地Download目录时,报以下错误:
出现异常。
FileSystemException (FileSystemException: Cannot create file, path = '/Users/zl/Library/Containers/com.jinfeng.dreammusic.dreamMusic/Data/Downloads' (OS Error: Operation not permitted, errno = 1))
这是因为我们没有读写文件的权限,需要去修改下用户的权限。
通过Apple的开发文档找到有关权限问题的说明。其中有个授权私钥的key为com.apple.security.files.downloads.read-write
,表示对用户的下载文件夹的读/写访问权限。那么,使用Xcode打开Flutter项目中的mac应用,修改工程目录下的DebugProfile.entitlements
文件,向entitlements
文件中添加com.apple.security.files.downloads.read-write
,并将值设置为YES,保存后重启Flutter项目。发现已经可以向下载目录中读写文件了。
当然,这是正常操作。还有个骚操作就是关闭系统的沙盒机制。将entitlements
文件的App Sandbox
设置为NO。这样我们就可以访问任意路径了。当然关闭应用的沙盒也就相当于关闭了应用的防护机制,因此这个选项慎用。
下载歌曲后,歌曲的名字等信息如何保存?
这里有两个方案,一个是同时保存一个json文件,用于记录歌曲信息,这个方案的缺点是数据分散,内容容易丢失。另一个方案是直接存入mp3文件内部,网易云音乐的音乐下载是这么实现了,使用id3库可以读取到mp3文件中额外存储的信。ID3解析相关介绍
目前由于技术原因,暂时采用了下载mp3+json的方式。后面有时间会做优化。这种方式特别依赖于两个文件的完整性,毕竟mp3+json相当于一首完整的歌。因此DreamMusic还对下载文件做了删除的监听,通过下载方式实现。
final stream =
directory.watch(events: FileSystemEvent.delete, recursive: true);
stream.listen((event) async {
//处理删除后的逻辑
}
Show In Finder实现
当时看到这个功能,第一反应就是网上找文件操作相关的库。比较有名的有file_selector
和file_picker
。这两个都提供了文件选择功能,但是没有单纯的打开文件目录的选项。然后我看了file_picker的实现,好家伙,竟然是直接通过命令行实现的,这当时就让我灵感一闪,有办法了。
open 命令
搞定一切。
class ShowInFinder {
/// 打开目录
static void open({String? initialDirectory}) {
if (initialDirectory?.isNotEmpty == true) {
final type = FileSystemEntity.typeSync(initialDirectory!);
if (type == FileSystemEntityType.file || type == FileSystemEntityType.link) {
debugPrint("[finder]$initialDirectory, 不是目录");
return;
}
if (type == FileSystemEntityType.notFound) {
// 指定目录不存在,创建
Directory(initialDirectory).create();
}
}
String argument = initialDirectory ?? '.';
Process.run("open", [argument]);
}
}
修改桌面应用的图标和名称
应用名称和图标都需要原生工程中去配置,这里也就是macos、windows、linux目录。
macOS配置看这里。
windows配置看这里。
Linux配置看这里。
这里我只配了macos和windows的,毕竟谁会去用linux运行这个项目呢!(哈哈,其实就是懒)
最后
关于DreamMusic的实现细节就先讲这么多吧,如果有问题欢迎在评论区指出,感谢观看。最后再贴一波链接:DreamMusic