一、Uniapp框架概述
Uniapp是一个使用Vue.js语法开发所有前端应用的框架,可以编译到iOS、Android、Web以及各种小程序平台。
本教程将带领您从零开始开发一个功能完整的天气预报应用,涵盖以下核心功能:
- 当前位置自动获取与城市选择
- 实时天气信息展示
- 多日天气预报
- 天气指数和生活建议
- 主题切换和个性化设置
二、项目结构与配置
1. 创建Uniapp项目
# 使用HBuilderX创建项目或使用CLI
vue create -p dcloudio/uni-preset-vue weather-app
# 选择默认模板
# 进入项目目录
cd weather-app
2. 项目目录结构
weather-app/
├── pages/ // 页面文件
│ ├── index/ // 首页
│ ├── city-list/ // 城市列表页
│ └── settings/ // 设置页
├── components/ // 组件目录
│ ├── weather-card/ // 天气卡片组件
│ ├── daily-forecast/ // 每日预报组件
│ └── air-quality/ // 空气质量组件
├── static/ // 静态资源
├── store/ // Vuex状态管理
├── common/ // 公共工具类
├── App.vue // 应用配置
├── main.js // 入口文件
├── manifest.json // 应用配置
└── pages.json // 页面配置
3. 修改pages.json配置路由
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "天气预报",
"enablePullDownRefresh": true
}
},
{
"path": "pages/city-list/city-list",
"style": {
"navigationBarTitleText": "城市选择"
}
},
{
"path": "pages/settings/settings",
"style": {
"navigationBarTitleText": "设置"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "天气预报",
"navigationBarBackgroundColor": "#4a90e2",
"backgroundColor": "#f5f5f5"
}
}
三、核心功能实现
1. 首页实现 (pages/index/index.vue)
<template>
<view class="container" :class="{'dark-theme': isDarkMode}">
<view class="header">
<view class="location" @click="chooseCity">
<text class="icon">📍</text>
<text class="city-name">{{ currentCity }}</text>
<text class="icon">▼</text>
</view>
<text class="date">{{ currentDate }}</text>
</view>
<scroll-view scroll-y="true" class="content" @scrolltolower="loadMore">
<view class="current-weather">
<text class="temperature">{{ currentWeather.temp }}°</text>
<text class="description">{{ currentWeather.weather }}</text>
<view class="details">
<text>湿度: {{ currentWeather.humidity }}%</text>
<text>风速: {{ currentWeather.windSpeed }}km/h</text>
<text>气压: {{ currentWeather.pressure }}hPa</text>
</view>
</view>
<daily-forecast :forecastData="dailyForecast"></daily-forecast>
<view class="indexes">
<view class="index-card" v-for="(item, index) in lifeIndexes" :key="index">
<text class="index-name">{{ item.name }}</text>
<text class="index-value">{{ item.value }}</text>
<text class="index-desc">{{ item.desc }}</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import dailyForecast from '@/components/daily-forecast/daily-forecast.vue';
import { mapState, mapMutations } from 'vuex';
export default {
components: { dailyForecast },
data() {
return {
currentDate: '',
currentWeather: {
temp: '--',
weather: '--',
humidity: '--',
windSpeed: '--',
pressure: '--'
},
dailyForecast: [],
lifeIndexes: []
};
},
computed: {
...mapState(['currentCity', 'isDarkMode'])
},
onLoad() {
this.getCurrentDate();
this.getLocation();
},
onPullDownRefresh() {
this.loadWeatherData();
setTimeout(() => {
uni.stopPullDownRefresh();
}, 1000);
},
methods: {
...mapMutations(['setCurrentCity']),
getCurrentDate() {
const date = new Date();
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
this.currentDate = date.toLocaleDateString('zh-CN', options);
},
async getLocation() {
try {
const res = await uni.getLocation({ type: 'wgs84' });
this.getCityNameFromCoords(res.latitude, res.longitude);
} catch (error) {
console.error('获取位置失败', error);
this.loadWeatherData(); // 使用默认城市
}
},
async getCityNameFromCoords(lat, lon) {
// 这里使用高德地图逆地理编码API
try {
const response = await uni.request({
url: `https://restapi.amap.com/v3/geocode/regeo?key=YOUR_API_KEY&location=${lon},${lat}`
});
const city = response.data.regeocode.addressComponent.city;
this.setCurrentCity(city);
this.loadWeatherData();
} catch (error) {
console.error('获取城市名称失败', error);
}
},
async loadWeatherData() {
uni.showLoading({ title: '加载中...' });
try {
// 获取实时天气
const currentResponse = await uni.request({
url: `https://api.openweathermap.org/data/2.5/weather?q=${this.currentCity}&units=metric&appid=YOUR_API_KEY&lang=zh_cn`
});
this.processCurrentWeather(currentResponse.data);
// 获取天气预报
const forecastResponse = await uni.request({
url: `https://api.openweathermap.org/data/2.5/forecast?q=${this.currentCity}&units=metric&appid=YOUR_API_KEY&lang=zh_cn`
});
this.processForecast(forecastResponse.data);
// 获取生活指数
this.getLifeIndexes();
} catch (error) {
console.error('获取天气数据失败', error);
uni.showToast({ title: '获取数据失败', icon: 'none' });
} finally {
uni.hideLoading();
}
},
processCurrentWeather(data) {
this.currentWeather = {
temp: Math.round(data.main.temp),
weather: data.weather[0].description,
humidity: data.main.humidity,
windSpeed: data.wind.speed,
pressure: data.main.pressure
};
},
processForecast(data) {
// 处理5天天气预报数据
const dailyData = {};
data.list.forEach(item => {
const date = item.dt_txt.split(' ')[0];
if (!dailyData[date]) {
dailyData[date] = {
date: date,
temp_min: item.main.temp_min,
temp_max: item.main.temp_max,
weather: item.weather[0].main,
icon: item.weather[0].icon
};
} else {
dailyData[date].temp_min = Math.min(dailyData[date].temp_min, item.main.temp_min);
dailyData[date].temp_max = Math.max(dailyData[date].temp_max, item.main.temp_max);
}
});
this.dailyForecast = Object.values(dailyData).slice(0, 5);
},
getLifeIndexes() {
// 模拟生活指数数据
this.lifeIndexes = [
{ name: '舒适度', value: '较舒适', desc: '天气较好,感觉舒适' },
{ name: '紫外线', value: '中等', desc: '需要涂抹防晒霜' },
{ name: '穿衣', value: '适中', desc: '建议穿着轻便衣物' },
{ name: '运动', value: '适宜', desc: '适合户外运动' }
];
},
chooseCity() {
uni.navigateTo({
url: '/pages/city-list/city-list'
});
},
loadMore() {
// 加载更多数据
console.log('加载更多');
}
}
};
</script>
<style>
.container {
min-height: 100vh;
background: linear-gradient(to bottom, #4a90e2, #f5f5f5);
padding: 20rpx;
}
.dark-theme {
background: linear-gradient(to bottom, #2c3e50, #1a1a1a);
color: #ffffff;
}
.header {
text-align: center;
padding: 40rpx 0;
}
.location {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 20rpx;
}
.city-name {
font-size: 36rpx;
font-weight: bold;
margin: 0 20rpx;
}
.date {
font-size: 28rpx;
color: #666;
}
.dark-theme .date {
color: #ccc;
}
.current-weather {
text-align: center;
padding: 40rpx 0;
}
.temperature {
font-size: 100rpx;
font-weight: bold;
display: block;
}
.description {
font-size: 36rpx;
display: block;
margin: 20rpx 0;
}
.details {
display: flex;
justify-content: space-around;
margin-top: 40rpx;
}
.details text {
font-size: 24rpx;
}
.content {
height: calc(100vh - 200rpx);
}
.indexes {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
margin-top: 40rpx;
}
.index-card {
width: 48%;
background-color: rgba(255, 255, 255, 0.8);
border-radius: 16rpx;
padding: 20rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.dark-theme .index-card {
background-color: rgba(50, 50, 50, 0.8);
}
.index-name {
font-size: 28rpx;
font-weight: bold;
display: block;
}
.index-value {
font-size: 32rpx;
display: block;
margin: 10rpx 0;
}
.index-desc {
font-size: 24rpx;
color: #666;
display: block;
}
.dark-theme .index-desc {
color: #ccc;
}
</style>
2. Vuex状态管理 (store/index.js)
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
const store = new Vuex.Store({
state: {
currentCity: '北京',
isDarkMode: false,
favoriteCities: []
},
mutations: {
setCurrentCity(state, city) {
state.currentCity = city;
// 保存到本地存储
uni.setStorageSync('currentCity', city);
},
toggleDarkMode(state) {
state.isDarkMode = !state.isDarkMode;
uni.setStorageSync('isDarkMode', state.isDarkMode);
},
addFavoriteCity(state, city) {
if (!state.favoriteCities.includes(city)) {
state.favoriteCities.push(city);
uni.setStorageSync('favoriteCities', JSON.stringify(state.favoriteCities));
}
},
removeFavoriteCity(state, city) {
const index = state.favoriteCities.indexOf(city);
if (index > -1) {
state.favoriteCities.splice(index, 1);
uni.setStorageSync('favoriteCities', JSON.stringify(state.favoriteCities));
}
}
},
actions: {
loadStorageData({ commit }) {
// 从本地存储加载数据
const city = uni.getStorageSync('currentCity');
if (city) commit('setCurrentCity', city);
const isDarkMode = uni.getStorageSync('isDarkMode');
if (isDarkMode) commit('toggleDarkMode');
const favorites = uni.getStorageSync('favoriteCities');
if (favorites) {
state.favoriteCities = JSON.parse(favorites);
}
}
}
});
export default store;
3. 城市选择页面 (pages/city-list/city-list.vue)
<template>
<view class="city-list">
<view class="search-bar">
<input
class="search-input"
placeholder="搜索城市"
v-model="searchQuery"
@input="searchCities"
/>
</view>
<scroll-view scroll-y="true" class="list-content">
<view class="section" v-if="favoriteCities.length">
<text class="section-title">收藏城市</text>
<view class="city-item" v-for="city in favoriteCities" :key="city" @click="selectCity(city)">
<text>{{ city }}</text>
<text class="icon" @click.stop="removeFavorite(city)">❤️</text>
</view>
</view>
<view class="section">
<text class="section-title">热门城市</text>
<view class="city-item" v-for="city in hotCities" :key="city" @click="selectCity(city)">
<text>{{ city }}</text>
<text class="icon" @click.stop="addFavorite(city)">🤍</text>
</view>
</view>
<view class="section" v-if="searchResults.length">
<text class="section-title">搜索结果</text>
<view class="city-item" v-for="city in searchResults" :key="city" @click="selectCity(city)">
<text>{{ city }}</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
export default {
data() {
return {
searchQuery: '',
searchResults: [],
hotCities: ['北京', '上海', '广州', '深圳', '杭州', '成都', '武汉', '西安']
};
},
computed: {
...mapState(['favoriteCities'])
},
methods: {
...mapMutations(['setCurrentCity', 'addFavoriteCity', 'removeFavoriteCity']),
searchCities() {
if (this.searchQuery.length > 1) {
// 模拟搜索,实际应调用API
this.searchResults = this.hotCities.filter(city =>
city.includes(this.searchQuery)
);
} else {
this.searchResults = [];
}
},
selectCity(city) {
this.setCurrentCity(city);
uni.navigateBack();
},
addFavorite(city) {
this.addFavoriteCity(city);
uni.showToast({ title: '已添加收藏', icon: 'success' });
},
removeFavorite(city) {
this.removeFavoriteCity(city);
uni.showToast({ title: '已取消收藏', icon: 'success' });
}
}
};
</script>
<style>
.city-list {
padding: 20rpx;
}
.search-bar {
margin-bottom: 30rpx;
}
.search-input {
height: 80rpx;
background-color: #f5f5f5;
border-radius: 40rpx;
padding: 0 30rpx;
font-size: 28rpx;
}
.list-content {
height: calc(100vh - 120rpx);
}
.section {
margin-bottom: 40rpx;
}
.section-title {
font-size: 30rpx;
font-weight: bold;
display: block;
margin-bottom: 20rpx;
color: #666;
}
.city-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid #eee;
}
.city-item:active {
background-color: #f9f9f9;
}
.icon {
font-size: 36rpx;
}
</style>
四、多端适配与发布
1. 条件编译处理平台差异
// 在需要区分平台的地方使用条件编译
async shareWeather() {
// #ifdef MP-WEIXIN
await uni.share({
title: `${this.currentCity}天气情况`,
path: `/pages/index/index?city=${this.currentCity}`
});
// #endif
// #ifdef APP-PLUS
await uni.share({
type: 0,
href: `https://weather.example.com/?city=${this.currentCity}`,
title: `${this.currentCity}天气情况`
});
// #endif
// #ifdef H5
alert('分享功能在H5端需要自定义实现');
// #endif
}
2. manifest.json配置
{
"name": "天气预报",
"appid": "__UNI__XXXXXX",
"description": "多功能天气预报应用",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
}
},
"mp-weixin": {
"appid": "wxxxxxxxxxxxxxxxx",
"setting": {
"urlCheck": false
},
"usingComponents": true
},
"h5": {
"router": {
"mode": "hash"
},
"template": "index.html"
}
}
五、性能优化技巧
- 图片优化:使用WebP格式,合理压缩图片大小
- 数据缓存:合理使用本地存储减少API请求
- 组件懒加载:使用异步组件减少初始加载时间
- 减少DOM节点:优化页面结构,避免过多嵌套
- API请求优化:合并请求,使用节流防抖
六、常见问题与解决方案
| 问题 | 解决方案 |
|---|---|
| 跨域问题 | 使用uni.request代理或配置服务器CORS |
| 样式兼容性 | 使用rpx单位,测试多端样式表现 |
| API限制 | 实现请求缓存,避免频繁调用 |
| 打包体积过大 | 使用分包加载,压缩静态资源 |
七、总结
本教程详细介绍了如何使用Uniapp开发一个功能完整的天气预报应用,涵盖了从项目创建到多端发布的全过程。通过这个实战项目,您可以学习到:
- Uniapp的基本结构和开发模式
- Vue.js在Uniapp中的应用
- API调用和数据管理
- 多端适配和条件编译
- 性能优化和最佳实践
Uniapp作为跨平台开发框架,大大提高了开发效率,使开发者能够用一套代码同时发布到多个平台。希望本教程能帮助您快速掌握Uniapp开发技能。

