一、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开发技能。