每个音乐播放器的实现都大致相同,个人认为难点在于歌曲播放与Slider的同步,歌词的解析与播放的同步。这些过程虽然繁琐,但是理解起来并不难。先来看看简单实现结果吧。
QQ音乐播放器简单实现
虽然功能简单,但是还是耗费了我很长时间来整理其中的逻辑关系,接下来我们就来分析一下音乐播放器的简单实现。
这个播放器比较简单,只有一个界面,创建CLPlayingViewController,使用stortyboard布局,先来看一下布局
播放器页面布局
界面共分为四部分,其中需要注意的是中间歌手图片约束的添加,为了保证其在不同的屏幕上都为圆形,这里先将1、3、4部分布局约束添加好,然后设置歌手图片距离上面第1部分和下面第3部分歌词分别有一个距离并且居中显示,然后设置图片长宽比为1:1即可,其他部分的约束比较简单,这里不再赘述,添加约束还是需要多练才能掌握。歌手图片的约束如下。
歌手图片Image的约束
背景图片的毛玻璃效果 添加毛玻璃效果的方法有很多种,如果本来就有一张模糊处理过的美景图片那就最好不过了,如果没有的话只能自己添加,可以使用第三方框架 (DRNRealTimeBlur)或者自己通过coreImage实现高斯模糊。以上两种方法功能强大但是比较麻烦,我们这里只是简单的实现图片模糊,可以使用给UIImageView添加UIToolbar来实现
// 1.初始化toolBar
UIToolbar *toolBar = [[UIToolbar alloc] init];
// 2. 设置frame
toolBar.frame = [UIScreen mainScreen].bounds;
// 3. 设置模糊的样式
toolBar.barStyle = UIBarStyleBlack;
// 4. 添加到imageView
[self.albumView addSubview:toolBar];
而iOS8之后storyboard中出现了专门给图片添加模糊效果的控件。
Blur
我们只需将blur添加到imageView上面然后设置blur的样式即可,
blur的样式
需要注意的是:blur需要添加到背景imageView上面和其他View之间,防止模糊效果影响到歌手图片,播放按钮等其他控件。
歌手图片进行圆角处理 这里直接修改imageView的layer进行圆角处理
- (void)viewWillLayoutSubviews
{
[super viewWillLayoutSubviews];
// .添加圆角
self.iconView.layer.cornerRadius = self.iconView.bounds.size.width * 0.5;
self.iconView.layer.masksToBounds = YES;
// 设置边框颜色宽度
self.iconView.layer.borderColor = CLColor(36, 36, 36, 1.0).CGColor;
self.iconView.layer.borderWidth = 5;
}
这里需要注意的是虽然我们在storyboard中为歌手图片添加约束,但是当运行到模拟器上时,屏幕大小和storyboard中屏幕大小可能会不同,如果在viewDidLoad中设置圆角,此时拿到的歌手图片的大小还是storyboard中的大小,所以显示在模拟器上就会使圆形计算错误,因此我们在viewWillLayoutSubviews方法中添加圆角设置。
歌手图片的转动动画效果 图片转动用到核心动画利用CABasicAnimation修改图片z轴进行旋转,设置一定时间旋转一圈,重复无数次。
CABasicAnimation *rotateAnimate = [CABasicAnimation animationWithKeyPath:@"transform.rotation.z"];
rotateAnimate.fromValue = @(0);
rotateAnimate.toValue = @(M_PI * 2);
rotateAnimate.repeatCount = NSIntegerMax;
rotateAnimate.duration = 36;
[self.iconView.layer addAnimation:rotateAnimate forKey:nil];
最后,修改Slider的原点的样式,我们发现在storyboard是没有办法修改Slider原点的图片的,只能修改颜色,所以需要通过代码修改Slider原点的图片
[self.progressSlider setThumbImage:[UIImage imageNamed:@"player_slider_playback_thumb"] forState:UIControlStateNormal];
这里为了方便使用本地音乐进行播放,首先根据plist文件创建CLMusicModel模型,然后创建CLMusicTool工具类,用来获取所有音乐以及当前正在播放的音乐设置默认播放的音乐等等。最后创建CLAVdioTool工具类用来播放音乐,以及切换上一首,下一首音乐。
接下来来详细分析这三个类的作用。 CLMusicModel模型类仅仅是歌曲模型,内含歌曲名,文件名,歌词文件名,歌手信息等。 CLMusicTool工具类提供方法用来初始化音乐列表将plist文件转化为Model,并存储到数组中,获取所有音乐数组,以及设置默认播放的音乐
static NSArray *_musics;
static CLMusicModel *_playingMusic;
// 类加载的时候初始化音乐列表和播放音乐
+(void)initialize
{
if (_musics == nil) {
// 使用MJExtension将plist文件转化为模型
_musics = [CLMusicModel objectArrayWithFilename:@"Musics.plist"];
}
if (_playingMusic == nil) {
// 设置一开始就播放的音乐
_playingMusic = _musics[0];
}
}
// 获取所有音乐
+(NSArray *)Musics
{
return _musics;
}
// 当前正在播放的音乐
+(CLMusicModel *)playingMusic
{
return _playingMusic;
}
// 设置默认播放的音乐
+(void)setUpPlayingMusic:(CLMusicModel *)playingMusic
{
_playingMusic = playingMusic;
}
CLAVdioTool工具类用来播放音乐,以及切换上一首,下一首音乐。这里提供三个方法,根据参数文件名找到文件路径并根据文件路径创建播放器player,创建全局字典用来存储播放器,每首歌对应一个播放器,播放音乐的时候先去字典中找到对应的播放器进行播放,如果没有就创建对应的播放器。
static NSMutableDictionary *_players;
+(void)initialize
{
_players = [NSMutableDictionary dictionary];
}
+(AVAudioPlayer *)playingMusicWithMusicFileName:(NSString *)filename
{
AVAudioPlayer *player = nil;
player = _players[filename];
if (player == nil) {
// 文件路径转化为url
NSURL *url = [[NSBundle mainBundle]URLForResource:filename withExtension:nil];
if (url == nil) {
return nil;
}
// 创建player
player = [[AVAudioPlayer alloc]initWithContentsOfURL:url error:nil];
// 准备播放
[player prepareToPlay];
// 将播放器存储到字典中
[_players setObject:player forKey:filename];
}
// 开始播放
[player play];
return player;
}
+(void)pauseMusicWithMusicFileName:(NSString *)filename
{
AVAudioPlayer *player = _players[filename];
if (player) {
[player pause];
}
}
+(void)stopMusicWithMusicFileName:(NSString *)filename
{
AVAudioPlayer *player = _players[filename];
if (player) {
[player stop];
[_players removeObjectForKey:filename];
player = nil;
}
}
此时在CLPlayingViewController中进行音乐播放就非常简单了,使用CLMusicTool获得当前正在播放的CLMusicModel音乐模型,对页面信息进行设置,使用CLAVdioTool根据CLMusicModel的属性音乐名,播放音乐。主要代码如下。
// 获取当前正在播放的音乐
CLMusicModel *playingMusic = [CLMusicTool playingMusic];
// 根据文件名播放音乐并且获取播放的音乐
AVAudioPlayer *currentPlayer = [CLAVdioTool playingMusicWithMusicFileName:playingMusic.filename];
播放时间和歌曲总时间的string处理,通过播放器可以拿到已经播放时间currentTime和歌曲总时间duration,播放器返回给我们的是秒,需要将秒转化为分钟,这里给NSString添加分类,添加stringWithTime方法将返回的NSTimeInterval转化为NSString。
+ (NSString *)stringWithTime:(NSTimeInterval)time
{
NSInteger min = time / 60;
NSInteger sec = (int)round(time) % 60;
return [NSString stringWithFormat:@"%02ld:%02ld",min,sec];
}
Slider随播放时间而移动 通过添加定时器的方法,使Slider原点随着播放的时间而移动,将定时器添加到主RunLoop中并修改Mode为NSRunLoopCommonModes防止在滑动时定时器失效。
#pragma mark - 对进度条时间的处理
- (void)addProgressTimer
{
// 定时器每1s执行一次,第一次来到这里需要先等1s,所以先刷新一下界面
[self updateProgressInfo];
self.progressTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(updateProgressInfo) userInfo:nil repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:self.progressTimer forMode:NSRunLoopCommonModes];
}
- (void)removeProgressTimer
{
[self.progressTimer invalidate];
self.progressTimer = nil;
}
#pragma mark - 更新进度条
- (void)updateProgressInfo
{
// 1.更新播放的时间,使用NSString分类方法处理时间。
self.currentTimeLabel.text = [NSString stringWithTime:self.currentPlayer.currentTime];
// 2.更新滑动条
self.progressSlider.value = self.currentPlayer.currentTime / self.currentPlayer.duration;
}
注意:当我们在播放音乐方法里面添加定时器的时候需要先移除定时器,然后在添加,避免当点击下一首的时候,定时器没有移除,时间发生错误。
Slider滑动更新界面和音乐播放时间 给Slider添加点击事件,监听Slider的滑动。在storyboard中给Slider添加点击事件,分别监听Slider的点击,滑动和松开。
#pragma mark - slider 事件处理
- (IBAction)start {
// 移除定时器
[self removeProgressTimer];
}
- (IBAction)end {
// 1.更新播放的时间
self.currentPlayer.currentTime = self.progressSlider.value * self.currentPlayer.duration;
// 2.添加定时器
[self addProgressTimer];
}
- (IBAction)progressValueChange {
self.currentTimeLabel.text = [NSString stringWithTime:self.progressSlider.value * self.currentPlayer.duration];
}
Slider的点击直接跳转当前时间播放 通过storyboard给Slider添加手势监听Slider的点击,当点击Slider直接跳转到点击位置开始播放。 获取点击的位置,然后计算点击位置占真个Slider的比例,根据比例计算出当前播放时间,最后更新label时间和滑块的位置。
- (IBAction)sliderClick:(UITapGestureRecognizer *)sender {
// 1.获取点击到的点
CGPoint point = [sender locationInView:sender.view];
// 2.获取点击的比例
CGFloat ratio = point.x / self.progressSlider.bounds.size.width;
// 3.更新播放的时间
self.currentPlayer.currentTime = self.currentPlayer.duration * ratio;
// 4.更新时间和滑块的位置
[self updateProgressInfo];
}
监听播放按钮点击 播放按钮有播放和暂停两个状态,程序一开始运行就自动播放,所以首先需要在音乐一开始播放的时候修改播放按钮的selected。
self.playWithPauseBtn.selected = currentPlayer.isPlaying;
当点击播放按钮的时候首先需要修改按钮的状态,然后判断音乐播放的状态,如果正在播放则暂停音乐,移除定时器,并且停止歌手图片的动画,如果是暂停的则开始播放,添加定时器,并且回复动画。 暂停动画和恢复动画通过给CALayer添加分类方法实现。分类可以直接拖到别的项目中使用
- (void)pauseAnimate
{
CFTimeInterval pausedTime = [self convertTime:CACurrentMediaTime() fromLayer:nil];
self.speed = 0.0;
self.timeOffset = pausedTime;
}
- (void)resumeAnimate
{
CFTimeInterval pausedTime = [self timeOffset];
self.speed = 1.0;
self.timeOffset = 0.0;
self.beginTime = 0.0;
CFTimeInterval timeSincePause = [self convertTime:CACurrentMediaTime() fromLayer:nil] - pausedTime;
self.beginTime = timeSincePause;
}
播放按钮点击事件实现
- (IBAction)playWithPause:(UIButton *)sender {
sender.selected = !sender.selected;
if (self.currentPlayer.playing) {
// 1.暂停播放器
[self.currentPlayer pause];
// 2.移除定时器
[self removeProgressTimer];
// 3.暂停旋转动画
[self.iconView.layer pauseAnimate];
} else {
// 1.开始播放
[self.currentPlayer play];
// 2.添加定时器
[self addProgressTimer];
// 3.恢复动画
[self.iconView.layer resumeAnimate];
}
}
上一首下一首按钮点击实现 我们可以在CLMusicTool工具类中添加获取上一首歌曲和下一首歌曲的方法,首先拿到当前播放音乐的下标,然后在获取上一首或者下一首歌曲时需要对下标进行判断,拿上一首为例,如果当前歌曲的下标为0,则返回最后一首歌,形成循环播放,如果不为0则获取上一首即可,否则会造成数组越界。获取下一首判断方法相同。
// 返回上一首音乐
+ (CLMusicModel *)previousMusic
{
NSInteger index = [_musics indexOfObject:_playingMusic];
if (index == 0) {
index = _musics.count -1;
}else{
index = index -1;
}
CLMusicModel *previousMusic = _musics[index];
return previousMusic;
}
// 返回下一首音乐
+ (CLMusicModel *)nextMusic
{
NSInteger index = [_musics indexOfObject:_playingMusic];
if (index == _musics.count - 1) {
index = 0;
}else{
index = index +1;
}
CLMusicModel *previousMusic = _musics[index];
return previousMusic;
}
此时点击上一首或者下一首按钮时,首先停止当前播放的音乐,然后将上一首或者下一首歌曲设置为默认播放歌曲,最后开始播放,因为停止播放当前音乐,开始播放下一首音乐的代码相同,将其抽成一个方法
- (IBAction)nextMusic {
CLMusicModel *nsxtMusic = [CLMusicTool nextMusic];
[self playMusicWithMusic:nsxtMusic];
}
- (IBAction)previousMusic {
CLMusicModel *previousMusic = [CLMusicTool previousMusic];
[self playMusicWithMusic:previousMusic];
}
- (void)playMusicWithMusic:(CLMusicModel *)muisc
{
// 获取当前播放的音乐并停止
CLMusicModel *playingMusic = [CLMusicTool playingMusic];
[CLAVdioTool stopMusicWithMusicFileName:playingMusic.filename];
// 设置下一首或者上一首为默认播放音乐
[CLMusicTool setUpPlayingMusic:muisc];
// 更新界面
[self startPlayingMusic];
}
创建存放歌词的tableView 当滑动歌手图片时,会来到歌词界面,这里往歌手图片和歌词label上面覆盖scrollView,设置scrollView的contentSize为两个屏幕的宽度,显示歌词的tableView放在屏幕外面,布局如图。
歌词tableView布局
使用storyboard添加scrollView并自定义scrollView为CLLrcView,使用代码添加tableView,在scrollView的initWithFrame方法中创建并初始化tableView, 在layoutSubviews中对tableView进行一些设置。例如设置tableView的背景图片为透明,需要cell之间的线,设置tabaleView的contentInset一开始滑动到屏幕中央。
scrollView滑动歌手图片逐渐消失处理 当向右滑动出现歌词时,歌手图片和歌词label是逐渐消失的,我们通过scrollView的代理监听scrollView的滑动,根据scrollView.contentOffset.x的长度占据整个屏幕的比例设置歌手图片和歌词label的透明度,当完全滑动一个屏幕宽度时,歌手图片和歌词label的透明度为0。
#pragma mark - scrollView代理方法
-(void)scrollViewDidScroll:(UIScrollView *)scrollView
{
CGPoint offcetPoint = scrollView.contentOffset;
CGFloat alpha = 1 - offcetPoint.x / self.view.frame.size.width;
self.iconView.alpha = alpha;
self.lrcLabel.alpha = alpha;
}
自定义tableView的cell和cell中的label 自定义tableView的cell为CLLrcTableViewCell,对cell进行初始化,对cell的style和背景进行设置,对cell内label的frame和字体等进行设置。 自定义label为CLLrcLabel,便于我们之后对label中的歌词进行一些处理。
歌词的显示处理 歌词显示处理逻辑比较繁琐,这里尽量使代码解耦,便于更清晰的理解其中的逻辑。 首先歌词的显示在自定义的CLLrcView中的tableView中,所以给CLLrcView添加lrcName歌词文件名字属性,用来接收歌词文件的名字,然后重写setLrcName:方法根据歌词名获得歌词并对歌词进行一些处理。先来看一下歌词文件中的歌词样式
歌词内容格式
这里需要将每一句歌词转化为歌词模型,模型中包含时间和歌词,并且时间需要转化为秒。
例如 [01:32.64]宁愿相信我们前世有约
需要转化为time = 96 text = @"宁愿相信我们前世有约"存入到模型中。
首先需要将歌词一行一行分开转化为数组,这里创建CLLrcTool工具类用来将每一行歌词分开,并将每一行存入到数组中,此时数组中存储的歌词样式为 [01:32.64]宁愿相信我们前世有约
然后创建CLLrcLine歌词模型,模型中提供方法将[01:32.64]宁愿相信我们前世有约
样式的歌词分割出时间和歌词内容并转化为模型。
先来看CLLrcTool的将歌词转化为歌词数组方法
+(NSArray *)lrcToolWithLrcName:(NSString *)lrcName
{
// 1. 获取路径
NSString *lrcFilePath = [[NSBundle mainBundle]pathForResource:lrcName ofType:nil];
// 2. 获取歌词
NSString *lrcString = [NSString stringWithContentsOfFile:lrcFilePath encoding:NSUTF8StringEncoding error:nil];
// 将歌词转化为数组,会以每个\n为分隔符 将每一行转化为数组中的每个元素
NSArray *lrcArr = [lrcString componentsSeparatedByString:@"\n"];
NSMutableArray *tempArr = [NSMutableArray array];
// 遍历数组,将不需要的去除,并调用模型的方法,将字符串转化为模型
for (NSString *lrcLineString in lrcArr) {
// 过滤掉不要的字符串,如果是以这些开头 或者不是以[开头的直接退出循环
if ([lrcLineString hasPrefix:@"[ti:"] ||
[lrcLineString hasPrefix:@"[ar:"] ||
[lrcLineString hasPrefix:@"[al:"] ||
![lrcLineString hasPrefix:@"["]) {
continue;
}
// 将字符串转化为模型
CLLrcLine *lrcLine = [CLLrcLine LrcLineString:lrcLineString];
[tempArr addObject:lrcLine];
}
return tempArr;
}
模型中将字符串转化为模型的方法
// 返回模型
+ (instancetype)LrcLineString:(NSString *)lrcLineString
{
return [[self alloc] initWithLrcLineString:lrcLineString];
}
// 将字符串分割为时间和歌词内容
- (instancetype)initWithLrcLineString:(NSString *)lrcLineString
{
if (self = [super init]) {
// [01:32.64]宁愿相信我们前世有约
NSArray *lrcArray = [lrcLineString componentsSeparatedByString:@"]"];
// 设置歌词内容
self.text = lrcArray[1];
// 对时间进行处理然后设置时间
self.time = [self timeWithString:[lrcArray[0] substringFromIndex:1]];
}
return self;
}
// 处理时间,将时间转化为秒
- (NSTimeInterval)timeWithString:(NSString *)timeString
{
// 01:32.64
NSInteger min = [[timeString componentsSeparatedByString:@":"][0] integerValue];
NSInteger sec = [[timeString substringWithRange:NSMakeRange(3, 2)] integerValue];
NSInteger hs = [[timeString componentsSeparatedByString:@"."][1] integerValue];
return min * 60 + sec + hs * 0.01;
}
此时已经得到歌词模型的数组,刷新tableView即可。 但是此时的歌词是固定的,并不会根据播放时间即时的显示当先播放的时间。
歌词的即时显示 如果想即时的按照播放时间显示歌词,则需要拿到歌曲的总时间并且使用定时器不断的获取当前播放的时间,因为歌词的时间需要比较精确,这里使用CADisplayLink定时器
#pragma mark - 歌词定时器
- (void)addLrcTimer
{
self.lrcTiemr = [CADisplayLink displayLinkWithTarget:self selector:@selector(updateLrcInfo)];
[self.lrcTiemr addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}
- (void)removeLrcTimer
{
[self.lrcTiemr invalidate];
self.lrcTiemr = nil;
}
#pragma mark 更新歌词
- (void)updateLrcInfo
{
self.lrcScrollView.currentTime = self.currentPlayer.currentTime;
}
为CLLrcView添加currentTime已播放时间和duration歌曲总时间属性,重写setCurrentTime:对currentTime进行一些判断。
tableView:tableView cellForRowAtIndexPath:
方法中判断如果是第i行则将lable的字体放大,如果不是则改为原来的值。歌词的即时渲染 为达到歌词随播放时间即时渲染变换颜色,通过重写CLLrcLabel的drawRect:方法渲染歌词的颜色,并为CLLrcLabel添加progress属性用来记录歌词的播放进度,通过播放进度的变化随时调用CLLrcLabel的setNeedsDisplay刷新CLLrcLabel的渲染长度。 播放进度 = (当前播放的时间 - 正在唱的歌词的开始时间)/ 当前唱的歌词需要的总时间。
主页面歌词的即时显示 将主页面歌词的label同样设置为CLLrcLabel型,为CLLrcView添加lrcLabel属性,lrcLabel是CLLrcLabel类型的,在获得当前播放放的歌词之后,通过lrcLabel属相将歌词内容回传给主页面歌词label即可。
CLLrcView中setCurrentTime:方法
#pragma mark - 当前播放时间set方法
- (void)setCurrentTime:(NSTimeInterval)currentTime
{
// 记录当前时间
_currentTime = currentTime;
// 获取歌词行数
NSInteger count = self.lrcList.count;
for (int i = 0; i < count; i ++) {
// 获取i位置的歌词
CLLrcLine *currentLrcLine = self.lrcList[i];
// 获取下一句歌词
NSInteger nextIndex = i + 1;
// 先创建空的歌词模型
CLLrcLine *nextLrcLine = nil;
// 判断歌词是否存在
if (nextIndex < self.lrcList.count) {
// 说明存在
nextLrcLine = self.lrcList[nextIndex];
}
// 用播放器的当前的时间和i位置歌词、i+1位置歌词的时间进行比较,如果大于等于i位置的时间并且小于等于i+1歌词的时间,说明应该显示i位置的歌词。
// 并且如果正在显示的就是这行歌词则不用重复判断
if (self.currentIndex != i && currentTime >= currentLrcLine.time && currentTime < nextLrcLine.time) {
// 设置主页上的歌词
self.lrcLabel.text = currentLrcLine.text;
// 将当前播放的歌词移动到中间
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
[self.tableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionTop animated:YES];
// 记录上一句位置,当移动到下一句时,上一句和当前这一句都需要进行更新行
NSIndexPath *previousPath = [NSIndexPath indexPathForRow:self.currentIndex inSection:0];
// 记录当前播放的下标。下次来到这里,currentIndex指的就是上一句
self.currentIndex = i;
[self.tableView reloadRowsAtIndexPaths:@[indexPath,previousPath] withRowAnimation:UITableViewRowAnimationNone];
}
if (self.currentIndex == i) {
// 获取播放速度 已经播放的时间 / 播放整句需要的时间
CGFloat progress = (currentTime - currentLrcLine.time) / (nextLrcLine.time - currentLrcLine.time);
// 获取当前行数的cell
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
CLLrcTableViewCell *lrccell = [self.tableView cellForRowAtIndexPath:indexPath];
// 设置歌词界面歌词的进度
lrccell.lrcLabel.progress = progress;
// 设置主页面歌词的进度
self.lrcLabel.progress = progress;
}
}
}
CLLrcLabel的setProgress:方法
- (void)setProgress:(CGFloat)progress
{
_progress = progress;
[self setNeedsDisplay];
}
- (void)drawRect:(CGRect)rect {
[super drawRect:rect];
[[UIColor greenColor] set];
CGRect fillRect = CGRectMake(0, 0, self.bounds.size.width * self.progress, self.bounds.size.height);
UIRectFillUsingBlendMode(fillRect, kCGBlendModeSourceIn);
}
虽然项目中播放的是本地音乐,但是使用AVFoundation播放在线音乐也非常简单。
// 1.创建音乐资源
NSURL *url = [NSURL URLWithString:@"url"];
AVPlayerItem *playerItem = [AVPlayerItem playerItemWithURL:url];
// 2.创建播放器
// AVPlayer *player = [AVPlayer playerWithURL:url];
AVPlayer *player = [AVPlayer playerWithPlayerItem:playerItem];
[player play];
注意:AVAudioPlayer只能播放本地音乐,AVPlayerItem既能播放本地音乐也能播放在线音乐
至此,QQ音乐播放器已经基本实现,其中还有许多细节没有处理到位,例如歌曲播放完毕之后的处理,进入后台在返回的旋转动画的处理等,另外对于歌词即时显示感觉讲的还不是很清晰,如果有不清楚的地方还请提出来,我们一起探讨分析一下。
源码: github下载地址
文中如果有不对的地方欢迎指出。我是xx_cc,一只长大很久但还没有二够的家伙。