Bootstrap

【VUE】【IOS】【APP】IOS Music APP播放器开发

前言

周末闲来无事,学习了下移动端的一些知识。了解到移动端的一些实现方式,先从最简单的开始。本人没有IOS swift 和Android的开发经验。抱着学习态度从简单的入手,经过了解,本人之前自己用vue的写着玩了几个小项目。看到可以用VUE直接生成IOS IPA。所以先简单基于vue写前端页面(有一点点点点的VUE经验)然后基于Cordova来打包ios项目。最终通过Xcode来发布和生成IOS安装包到手机。

在学习的过程中也了解到,目前Vue对移动端有一个项目VUE Native Script. Native Script在设备的API之上,提供一系列API可以在VUE中调用。性能各方面看官网上说很接近于原生app,后面也会去尝试这种方式去开发小程序。有兴趣的同学可以自行学习 Introduction - NativeScript-Vue

技术栈

基于VUE, Nginx, Springboot,Cordova, XCode 开发Music APP。

  • 前端:VUE
  • 网关:Nginx
  • 音乐后台: Springboot

 Module Overview Diagram

Music VUE 前端

使用vue开发页面,安装相关组件

node

# installs nvm (Node Version Manager)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
# download and install Node.js (you may need to restart the terminal)
nvm install 20
# verifies the right Node.js version is in the environment
node -v # should print `v20.18.0`
# verifies the right npm version is in the environment
npm -v # should print `10.8.2`

Reference  Node.js — Download Node.js®

vue-cli生成项目

npm install -g vue-cli
vue init webpack my-project
cd my-project
npm install
npm run dev

这个过程中可能会有一些存在一些包无法下载或者问题,可以更改可以自行google,baidu.

这边给一些代码更改npm的register地址作为参考

* npm --registry https://registry.npm.taobao.org/  install @types/lodash
* npm config set registry <仓库地址>

官方仓库 - https://registry.npmjs.org/

官方镜像仓库 - https://skimdb.npmjs.com/registry/

淘宝镜像仓库(旧域名) - https://registry.npm.taobao.org/

淘宝镜像仓库(新域名) - https://registry.npmmirror.com/

腾讯仓库 - https://mirrors.cloud.tencent.com/npm/

cnpm仓库 - https://r.cnpmjs.org/

yarn仓库 - https://registry.yarnpkg.com/

* 通过cnpm来安装
  npm intsall -g cnpm -- registry=https://registry.npm.taobao.org/

cnpm -v

music vue 页面开发

  • music play 页面效果

绘制播放器,包括music封面,music名称,music作者。

  • Template 代码如下:
<template>
  <main class="audioPlayer">
    <a class="nav-icon" @click="togglePlaylist" :class="{ 'isActive': isPlaylistActive }" title="Music List">
      <span></span>
      <span></span>
      <span></span>
    </a>
    <div class="audioPlayerList" :class="{'isActive': isPlaylistActive}">
      <div class="item" v-for="(item,index) in musicPlaylist" v-bind:class="{ 'isActive':isCurrentSong(index) }"
           v-on:click="changeSong(index), isPlaylistActive=!isPlaylistActive">
        <p class="title">{{ item.title }}</p>
        <p class="artist">{{ item.artist }}</p>
      </div>

      <p class="debugToggle" @click="toggleDebug">debug: {{ debug }}</p>
    </div>

    <div class="audioPlayerUI" :class="{ 'isDisabled': isPlaylistActive }">
      <!-- Audio Player UI -->
      <div class="albumImage">
        <transition name="ballmove" enter-active-class="animated zoomIn" leave-active-class="animated fadeOutDown" mode="out-in">
          <img @load="onImageLoaded" :src="currentSongImage" :key="currentSong" id="playerAlbumArt"/>
        </transition>
        <div class="loader" v-if="!imgLoaded">Loading...</div>
      </div>

      <div class="albumDetails">
        <p class="title" :key="currentSong">{{ currentSongTitle }}</p>
        <p class="artist" :key="currentSong">{{ currentSongArtist }}</p>
      </div>

      <div class="playerButtons">
        <a class="button" :class="{ 'isDisabled': currentSong === 0 }" @click="prevSong" title="Previous Song">
          <i class="fas fa-step-backward"></i>
        </a>
        <a class="button play" @click="playAudio" title="Play/Pause Song">
          <transition name="slide-fade" mode="out-in">
            <i class="fas" :class="playPauseIcon" :key="1"></i>
          </transition>
        </a>
        <a class="button" :class="{ 'isDisabled': currentSong === musicPlaylist.length - 1 }" @click="nextSong" title="Next Song">
          <i class="fas fa-step-forward"></i>
        </a>
      </div>

      <div class="currentTimeContainer" style="text-align:center">
        <span class="currentTime">{{ Math.round(this.audio.currentTime) | fancyTimeFormat }}</span>
        <span class="totalTime">{{ trackDuration | fancyTimeFormat }}</span>
      </div>

      <div class="currentProgressBar">
        <div class="currentProgress" :style="{ width: currentProgressBar + '%' }"></div>
      </div>
    </div>
  </main>
</template>

  • script
<script>
export default {
  name: 'MusicPlayer',
  data() {
    return {
      audio: "",
      imgLoaded: false,
      currentlyPlaying: false,
      currentTime: 0,
      trackDuration: 0,
      currentProgressBar: 0,
      isPlaylistActive: false,
      currentSong: 0,
      debug: false,
      musicPlaylist: []
    };
  },
  created() {
    this.musicData();
  },
  computed: {
    currentSongImage() {
      return this.musicPlaylist[this.currentSong].image;
    },
    currentSongTitle() {
      return this.musicPlaylist[this.currentSong].title;
    },
    currentSongArtist() {
      return this.musicPlaylist[this.currentSong].artist;
    },
    playPauseIcon() {
      return this.currentlyPlaying ? 'fa-pause-circle' : 'fa-play-circle'; // Update icon names
    }
  },
  mounted() {
    this.changeSong();
    this.audio.loop = false;
  },
  filters: {
    fancyTimeFormat(s) {
      const minutes = Math.floor(s / 60);
      const seconds = s % 60;
      return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
    }
  },
  methods: {
    musicData: function () {
      this.$axios({
        method: "post",
        data:{
          pageSize: 10,
          pageNum: 1
        },
        url: "<your url>",
        baseURL: "<https://your host>"
      })
      .then(response => {
        this.musicPlaylist = response.data.list;
        this.changeSong();
      })
      .catch(error => this.$message.error( error + 'Request failed'));
    },
    togglePlaylist() {
      this.isPlaylistActive = !this.isPlaylistActive;
    },
    nextSong() {
      if (this.currentSong < this.musicPlaylist.length - 1) {
        this.changeSong(this.currentSong + 1);
      }
    },
    prevSong() {
      if (this.currentSong > 0) {
        this.changeSong(this.currentSong - 1);
      }
    },
    changeSong(index) {
      const wasPlaying = this.currentlyPlaying;
      this.imgLoaded = false;

      if (index !== undefined) {
        this.stopAudio();
        this.currentSong = index;
      }

      this.audio = new Audio(this.musicPlaylist[this.currentSong].url);
      this.audio.addEventListener("loadedmetadata", () => {
        this.trackDuration = Math.round(this.audio.duration);
      });
      this.audio.addEventListener("ended", this.handleEnded);
      if (wasPlaying) {
        this.playAudio();
      }
    },
    isCurrentSong(index) {
      return this.currentSong === index;
    },
    playAudio() {
      if (!this.currentlyPlaying) {
        this.currentTime = 0; // Reset current time
        this.audio.play();
        this.currentlyPlaying = true;
        this.updateProgressBar();
      } else {
        this.stopAudio();
      }
    },
    stopAudio() {
      this.audio.pause();
      this.currentlyPlaying = false;
      clearTimeout(this.updateProgressBar); // Clear the interval
    },
    handleEnded() {
      this.nextSong();
      this.playAudio();
    },
    onImageLoaded() {
      this.imgLoaded = true;
    },
    updateProgressBar() {
      this.currentProgressBar = (this.audio.currentTime / this.trackDuration) * 100;
      if (this.currentlyPlaying) {
        requestAnimationFrame(this.updateProgressBar);
      }
    },
    toggleDebug() {
      this.debug = !this.debug;
      document.body.classList.toggle('debug');
    }
  },
  beforeDestroy() {
    this.audio.removeEventListener("ended", this.handleEnded);
    this.audio.removeEventListener("loadedmetadata", this.handleEnded);
    this.stopAudio();
  }
};
</script>

Springboot 后台

API 数据结构

  • 接口Url定义
api/v1/xyxMusic/page
  •  Method POST
  • Request Body
{
  "pageSize": 10,
  "pageNum": 1
}

Response Body

{
  "code":200,
  "msg":"success",
  "data":{
    "total":5,
    "list":[
      {
        "id":1,
        "title":"Remember Our Summer",
        "artist":"Frog Monster",
        "url":"https://xyxmusic.com/RememberOurSummer.mp3",
        "image":"https://xyxmusic.com/RememberOurSummer.jpg",
        "uploadDate":"2024-10-17 08:00:00"
      },
      {
        "id":5,
        "title":"Kids and Climate Change",
        "artist":"6 Minute English",
        "url":"https://xyxmusic.com/240815_6_minute_english_kids_and_climate_change_download.mp3",
        "image":"https://xyxmusic.com/kids_and_climate_change.jpg",
        "uploadDate":"2024-10-17 08:00:00"
      },
      {
        "id":2,
        "title":"How Bubble tea got its Bubbles",
        "artist":"6 Minute English",
        "url":"https://xyxmusic.com/240523_6_minute_english_how_bubble_tea_got_its_bubbles_download.mp3",
        "image":"https://xyxmusic.com/bubbles.jpg",
        "uploadDate":"2024-10-17 08:00:00"
      },
      {
        "id":4,
        "title":"Did Taylor Swift fans Cause an Earthquake",
        "artist":"6 Minute English",
        "url":"https://xyxmusic.com/241010_6_minute_english_did_taylor_swift_fans_cause_an_earthquake_download.mp3",
        "image":"https://xyxmusic.com/earthquake.jpg",
        "uploadDate":"2024-10-17 08:00:00"
      },
      {
        "id":3,
        "title":"Can you keep a Secret",
        "artist":"6 Minute English",
        "url":"https://xyxmusic.com/240516_6_minute_english_can_you_keep_a_secret_download.mp3",
        "image":"https://xyxmusic.com/serects.jpg",
        "uploadDate":"2024-10-17 08:00:00"
      }
    ],
    "pageNum":1,
    "pageSize":10,
    "size":5,
    "startRow":1,
    "endRow":5,
    "pages":1,
    "prePage":0,
    "nextPage":0,
    "isFirstPage":true,
    "isLastPage":true,
    "hasPreviousPage":false,
    "hasNextPage":false,
    "navigatePages":8,
    "navigatepageNums":[
      1
    ],
    "navigateFirstPage":1,
    "navigateLastPage":1
  }
}

 API JAVA代码

MusicController

@PostMapping(value = "/page")
    public RestResponse<Page<XyxMusic>> page(
            @Schema(example = "{\"pageSize\":10,\"pageNum\":1}")
            @RequestBody XyxMusicModel xyxMusicModel) {

        XyxMusicExample xyxMusicExample = new XyxMusicExample();
        xyxMusicExample.setOrderByClause("title desc");
        PageParam pageParam = new PageParam();
        pageParam.setPageNum(xyxMusicModel.getPageNum());
        pageParam.setPageSize(xyxMusicModel.getPageSize());
        return RestResponse.ok(iXyxMusicService.page(xyxMusicExample, pageParam));
    }
  •  Cross-Domain 配置

这边要说一下后台需要配置跨域, 因为app在Cordova中使用web浏览器访问,在手机端的用的localhost,对于外部请求会跨域。当然也有一些方式可以绕过。这边不做讨论。

package top.xyx0123.wx.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * This configuration for APP
 */
@Configuration
@Slf4j
public class WebConfig {

    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                log.info("coming cross domain");
                registry.addMapping("/**") // Allow all paths
                        .allowedOrigins("*") // Allow all origins, replace with specific domains if needed
                        .allowedMethods("*")
                        .allowedHeaders("*");
            }
        };
    }
}

我这边请求的数据库的数据,你可以直接在controller写死music list的返回。结果参考API数据结构的Response Body

Ngnix配置

因为使用nginx做反代,这边也需要做一些跨域的配置如下

location / {
       # Handle preflight OPTIONS requests
        if ($request_method = 'OPTIONS') {
           add_header 'Access-Control-Allow-Origin' *;  # Reflect the requesting origin
             add_header 'Access-Control-Allow-Headers' '*';  # Add any additional headers you are sending
            return 204;  # No content response
        }

       proxy_set_header Host $host;
       proxy_set_header X-Real-IP $remote_addr;
       proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
       proxy_set_header X-Forwarded-Proto $scheme;
       proxy_pass http://host:ip/path
}

安装Xcode

接下来你需要XCode。XCode将安装在macOS 10.15.7 Catalina或更高版本上。安装需要50G左右的磁盘空间。打开AppStore,搜索XCode并安装它。

一旦安装完成(这可能需要一段时间-冲泡咖啡并享受一点休息),打开XCode,如果它提示你安装命令行工具,请确保选择Yes。

打开XCode›Preferences›Locations并确保设置了命令行工具

Cordova

  • 使用 Cordova

Apache Cordova is another option for building mobile apps from web applications. 

  • 安装Cordova 
npm install -g cordova
  • 添加Cordova到Vue 

在的vue 根目录运行如下命令, 提示命名cordova目录为src-cordova

vue add cordova
  • 初始化 Cordova with iOS platform
cd src-cordova
cordova platform add ios

生成目录如下:

 

Vue打包 && XCode部署到手机

  • vue打包项目
npm run build
  • 拷贝dist中的内容到src-cordova/www/, 确保你的当前路径是在vue根目录。运行如下拷贝命令。
cp -r dist/* src-cordova/www/
  • cordova build ios 运行命令,目录切换到src-cordova目录
cd src-cordova
cordova build ios

结果出现success

  • 打开XCode 运行如下命令
open platforms/ios/YourApp.xcworkspace

打开如下图

config.xml 配置一些app信息,注意下cross domain配置。可以参考如下

<?xml version='1.0' encoding='utf-8'?>
<widget id="top.xyx0123.xyxmusic" version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
  <name>XyxMusicApp</name>
  <description>Xyx Music App</description>
  <author email="[email protected]" href="https://xyx0123.top">
    Zhicheng Xu
  </author>

  <!-- Dev Server Hook (for Vue hot-reloading during development) -->
  <hook type="after_prepare" src="../node_modules/vue-cli-plugin-cordova/serve-config-hook.js" />

  <!-- The main entry point of the app -->
  <content src="index.html" />

  <!-- Allow intents for external browsing -->
  <allow-intent href="http://*/*" />
  <allow-intent href="https://*/*" />

  <!-- Preferences for using HTTPS scheme -->
  <preference name="scheme" value="https" />

  <!-- Allow navigation to external URLs (especially for API requests) -->
  <allow-navigation href="*" />

  <!-- Access control for external resources -->
  <access origin="*" /> <!-- Or specify specific origins if needed -->

  <!-- Plugins -->
  <plugin name="cordova-plugin-whitelist" spec="1" />
</widget>
  • 用数据线连上你的手机,模拟器选择你自己的手机,点击 左上角 运行即可。XCode会在你手机安装并运行。
    注意安装到手机你需要apple developer account。这个很简单邮箱手机都可以注册。参考此处:Become a member - Apple Developer Program
  • 手机APP效果

结语

有一点需要强调下,这种直接用vue打包成APP安装包的方式。在性能上肯定比不上原生语言开发。但是由于这种方式省去了原生语言的学习成本,让有js基础同学也可以参与到app的开发中来。这也是为何前些年混合app雨后春笋的流行起来。好了,整个过程中也遇到很多问题,强烈建议官方英文文档。基本可以解决遇到的问题,若有疑问也欢迎留言。

本人打算下一步学习下VUE Native Script的相关知识,这种集成了直接调用原生设备的api。性能上肯定要比现在的这种方式强。后面也会带来demo,欢迎点赞关注支持!


 

;