前言
周末闲来无事,学习了下移动端的一些知识。了解到移动端的一些实现方式,先从最简单的开始。本人没有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,欢迎点赞关注支持!