概述

本指南将介绍如何使用Uniapp框架开发一个适配鸿蒙HarmonyOS 5的社交类应用,包含用户资料、动态发布、即时通讯和社交圈等核心功能。

功能设计

  1. ​用户系统​​:注册、登录、个人资料管理
  2. ​动态功能​​:发布、浏览、点赞、评论
  3. ​即时通讯​​:一对一聊天、群聊
  4. ​社交圈​​:好友关系、关注/粉丝
  5. ​发现功能​​:附近的人、兴趣小组

开发准备

1. 环境配置

  1. 安装HUAWEI DevEco Studio
  2. 安装Node.js (建议v14.x或更高版本)
  3. 安装Uniapp开发工具HBuilderX
  4. 配置HarmonyOS SDK

2. 创建Uniapp项目

  1. 打开HBuilderX
  2. 选择"文件" -> "新建" -> "项目"
  3. 选择"uni-app"模板
  4. 项目名称填写"SocialApp"
  5. 选择默认模板

项目结构

SocialApp/
├── common/               # 公共资源
│   ├── css/             # 公共样式
│   ├── icons/           # 图标资源
│   └── js/              # 公共JS
├── components/           # 组件目录
│   ├── post/           # 动态组件
│   ├── chat/           # 聊天组件
│   └── user/           # 用户组件
├── pages/                # 页面目录
│   ├── home/           # 首页
│   ├── discover/       # 发现页
│   ├── message/        # 消息页
│   ├── profile/        # 个人主页
│   └── post/           # 动态发布页
├── static/               # 静态资源
├── manifest.json         # 应用配置
└── pages.json           # 页面路由配置

核心功能实现

1. 首页实现 (pages/home/index.vue)

<template>
  <view class="container">
    <!-- 顶部导航 -->
    <view class="header">
      <text class="title">社交圈</text>
      <image 
        class="search-icon" 
        src="/static/icons/search.png"
        @click="navigateTo('/pages/discover/search')"
      />
    </view>
    
    <!-- 动态列表 -->
    <scroll-view 
      scroll-y 
      class="post-list"
      @scrolltolower="loadMorePosts"
    >
      <post-card 
        v-for="post in posts" 
        :key="post.id"
        :post="post"
        @like="handleLike"
        @comment="handleComment"
      />
      
      <view class="loading" v-if="loading">
        <text>加载中...</text>
      </view>
    </scroll-view>
    
    <!-- 发布按钮 -->
    <view class="fab" @click="navigateTo('/pages/post/create')">
      <image class="fab-icon" src="/static/icons/add.png" />
    </view>
  </view>
</template>

<script>
import PostCard from '@/components/post/Card.vue';

export default {
  components: { PostCard },
  data() {
    return {
      posts: [],
      loading: false,
      page: 1,
      hasMore: true
    }
  },
  onLoad() {
    this.loadPosts();
  },
  methods: {
    async loadPosts() {
      if (this.loading || !this.hasMore) return;
      
      this.loading = true;
      try {
        const res = await this.$api.getPosts({ page: this.page });
        this.posts = [...this.posts, ...res.data];
        this.hasMore = res.hasMore;
        this.page++;
      } catch (error) {
        console.error('加载动态失败', error);
      } finally {
        this.loading = false;
      }
    },
    loadMorePosts() {
      this.loadPosts();
    },
    handleLike(postId) {
      this.$api.likePost(postId).then(() => {
        const post = this.posts.find(p => p.id === postId);
        if (post) {
          post.isLiked = !post.isLiked;
          post.likesCount += post.isLiked ? 1 : -1;
        }
      });
    },
    handleComment(postId) {
      uni.navigateTo({
        url: `/pages/post/detail?id=${postId}`
      });
    },
    navigateTo(url) {
      uni.navigateTo({ url });
    }
  }
}
</script>

<style>
.container {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20rpx 30rpx;
  background-color: #FFFFFF;
  border-bottom: 1rpx solid #F1F1F1;
}

.title {
  font-size: 36rpx;
  font-weight: bold;
}

.search-icon {
  width: 40rpx;
  height: 40rpx;
}

.post-list {
  flex: 1;
  padding: 20rpx;
}

.loading {
  display: flex;
  justify-content: center;
  padding: 20rpx;
  color: #999;
}

.fab {
  position: fixed;
  right: 40rpx;
  bottom: 100rpx;
  width: 100rpx;
  height: 100rpx;
  background-color: #007AFF;
  border-radius: 50%;
  display: flex;
  justify-content: center;
  align-items: center;
  box-shadow: 0 4rpx 12rpx rgba(0, 122, 255, 0.3);
}

.fab-icon {
  width: 50rpx;
  height: 50rpx;
}
</style>

2. 动态发布页面 (pages/post/create.vue)

<template>
  <view class="post-create">
    <!-- 顶部工具栏 -->
    <view class="toolbar">
      <text class="cancel" @click="goBack">取消</text>
      <text class="title">发布动态</text>
      <text class="submit" @click="submitPost">发布</text>
    </view>
    
    <!-- 内容编辑区 -->
    <view class="editor">
      <textarea 
        class="content-input" 
        placeholder="分享你的想法..." 
        v-model="content"
        auto-height
      />
      
      <!-- 图片上传 -->
      <view class="image-uploader">
        <view 
          class="image-item" 
          v-for="(image, index) in images" 
          :key="index"
        >
          <image class="image" :src="image" mode="aspectFill" />
          <view class="delete" @click="removeImage(index)">
            <image class="delete-icon" src="/static/icons/close.png" />
          </view>
        </view>
        
        <view class="add-image" v-if="images.length < 9" @click="chooseImage">
          <image class="add-icon" src="/static/icons/image.png" />
        </view>
      </view>
      
      <!-- 位置信息 -->
      <view class="location" @click="chooseLocation" v-if="location">
        <image class="location-icon" src="/static/icons/location.png" />
        <text class="location-text">{{location}}</text>
      </view>
      
      <!-- 添加位置按钮 -->
      <view class="add-location" @click="chooseLocation" v-else>
        <image class="location-icon" src="/static/icons/location-outline.png" />
        <text class="location-text">添加位置</text>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      content: '',
      images: [],
      location: ''
    }
  },
  methods: {
    goBack() {
      uni.navigateBack();
    },
    async chooseImage() {
      try {
        const res = await uni.chooseImage({
          count: 9 - this.images.length,
          sizeType: ['compressed'],
          sourceType: ['album', 'camera']
        });
        
        this.images = [...this.images, ...res.tempFilePaths];
      } catch (error) {
        console.error('选择图片失败', error);
      }
    },
    removeImage(index) {
      this.images.splice(index, 1);
    },
    async chooseLocation() {
      try {
        const res = await uni.chooseLocation();
        this.location = res.name;
      } catch (error) {
        console.error('选择位置失败', error);
      }
    },
    async submitPost() {
      if (!this.content.trim() && this.images.length === 0) {
        uni.showToast({
          title: '内容不能为空',
          icon: 'none'
        });
        return;
      }
      
      uni.showLoading({
        title: '发布中...'
      });
      
      try {
        // 上传图片
        const uploadedImages = [];
        for (const image of this.images) {
          const uploadRes = await this.$api.uploadImage(image);
          uploadedImages.push(uploadRes.url);
        }
        
        // 提交动态
        await this.$api.createPost({
          content: this.content,
          images: uploadedImages,
          location: this.location
        });
        
        uni.showToast({
          title: '发布成功',
          icon: 'success'
        });
        
        setTimeout(() => {
          uni.navigateBack();
        }, 1500);
      } catch (error) {
        console.error('发布失败', error);
        uni.showToast({
          title: '发布失败',
          icon: 'none'
        });
      } finally {
        uni.hideLoading();
      }
    }
  }
}
</script>

<style>
.post-create {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background-color: #FFFFFF;
}

.toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20rpx 30rpx;
  border-bottom: 1rpx solid #F1F1F1;
}

.cancel, .submit {
  color: #007AFF;
  font-size: 32rpx;
}

.title {
  font-size: 36rpx;
  font-weight: bold;
}

.editor {
  flex: 1;
  padding: 30rpx;
}

.content-input {
  width: 100%;
  min-height: 200rpx;
  font-size: 32rpx;
  margin-bottom: 30rpx;
}

.image-uploader {
  display: flex;
  flex-wrap: wrap;
  margin-bottom: 30rpx;
}

.image-item, .add-image {
  width: 220rpx;
  height: 220rpx;
  margin-right: 10rpx;
  margin-bottom: 10rpx;
  position: relative;
}

.image {
  width: 100%;
  height: 100%;
  border-radius: 8rpx;
}

.add-image {
  display: flex;
  justify-content: center;
  align-items: center;
  border: 1rpx dashed #CCCCCC;
  border-radius: 8rpx;
}

.add-icon {
  width: 80rpx;
  height: 80rpx;
  opacity: 0.5;
}

.delete {
  position: absolute;
  right: -10rpx;
  top: -10rpx;
  width: 40rpx;
  height: 40rpx;
  background-color: #FF0000;
  border-radius: 50%;
  display: flex;
  justify-content: center;
  align-items: center;
}

.delete-icon {
  width: 20rpx;
  height: 20rpx;
}

.location, .add-location {
  display: flex;
  align-items: center;
  padding: 20rpx;
  border-radius: 8rpx;
  background-color: #F7F7F7;
}

.location-icon {
  width: 30rpx;
  height: 30rpx;
  margin-right: 10rpx;
}

.location-text {
  color: #333333;
}
</style>

3. 即时通讯页面 (pages/message/index.vue)

<template>
  <view class="message-container">
    <!-- 消息列表 -->
    <scroll-view scroll-y class="message-list">
      <view 
        class="message-item" 
        v-for="conversation in conversations" 
        :key="conversation.id"
        @click="openConversation(conversation)"
      >
        <image class="avatar" :src="conversation.avatar" />
        <view class="content">
          <view class="header">
            <text class="name">{{conversation.name}}</text>
            <text class="time">{{conversation.time}}</text>
          </view>
          <text class="last-message">{{conversation.lastMessage}}</text>
        </view>
        <view class="badge" v-if="conversation.unread > 0">
          <text>{{conversation.unread}}</text>
        </view>
      </view>
    </scroll-view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      conversations: [
        {
          id: 1,
          name: '张三',
          avatar: '/static/avatars/1.jpg',
          lastMessage: '你好,最近怎么样?',
          time: '10:30',
          unread: 2,
          type: 'private'
        },
        {
          id: 2,
          name: '技术交流群',
          avatar: '/static/avatars/group.png',
          lastMessage: '李四: 这个问题可以这样解决...',
          time: '昨天',
          unread: 5,
          type: 'group'
        }
        // 更多会话...
      ]
    }
  },
  methods: {
    openConversation(conversation) {
      uni.navigateTo({
        url: `/pages/message/chat?id=${conversation.id}&type=${conversation.type}`
      });
      
      // 标记为已读
      conversation.unread = 0;
    }
  }
}
</script>

<style>
.message-container {
  height: 100vh;
  background-color: #FFFFFF;
}

.message-list {
  height: 100%;
}

.message-item {
  display: flex;
  padding: 20rpx 30rpx;
  position: relative;
  border-bottom: 1rpx solid #F1F1F1;
}

.avatar {
  width: 100rpx;
  height: 100rpx;
  border-radius: 50%;
  margin-right: 20rpx;
}

.content {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.header {
  display: flex;
  justify-content: space-between;
  margin-bottom: 10rpx;
}

.name {
  font-size: 34rpx;
  font-weight: bold;
}

.time {
  font-size: 24rpx;
  color: #999999;
}

.last-message {
  font-size: 28rpx;
  color: #666666;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.badge {
  position: absolute;
  right: 30rpx;
  top: 50rpx;
  min-width: 36rpx;
  height: 36rpx;
  padding: 0 10rpx;
  background-color: #FF0000;
  border-radius: 18rpx;
  display: flex;
  justify-content: center;
  align-items: center;
}

.badge text {
  color: #FFFFFF;
  font-size: 22rpx;
}
</style>

4. 个人主页 (pages/profile/index.vue)

<template>
  <view class="profile-container">
    <!-- 用户信息 -->
    <view class="user-info">
      <image class="avatar" :src="user.avatar" />
      <view class="details">
        <text class="name">{{user.name}}</text>
        <text class="bio">{{user.bio || '暂无简介'}}</text>
      </view>
      <view class="edit" @click="navigateTo('/pages/profile/edit')">
        <image class="edit-icon" src="/static/icons/edit.png" />
      </view>
    </view>
    
    <!-- 统计数据 -->
    <view class="stats">
      <view class="stat-item" @click="navigateTo('/pages/profile/following')">
        <text class="count">{{user.followingCount}}</text>
        <text class="label">关注</text>
      </view>
      <view class="stat-item" @click="navigateTo('/pages/profile/followers')">
        <text class="count">{{user.followersCount}}</text>
        <text class="label">粉丝</text>
      </view>
      <view class="stat-item" @click="navigateTo('/pages/profile/posts')">
        <text class="count">{{user.postsCount}}</text>
        <text class="label">动态</text>
      </view>
    </view>
    
    <!-- 个人动态 -->
    <view class="tab-bar">
      <text 
        class="tab-item" 
        :class="{active: activeTab === 'posts'}"
        @click="activeTab = 'posts'"
      >
        我的动态
      </text>
      <text 
        class="tab-item" 
        :class="{active: activeTab === 'likes'}"
        @click="activeTab = 'likes'"
      >
        我的点赞
      </text>
    </view>
    
    <view class="tab-content">
      <post-list v-if="activeTab === 'posts'" :posts="posts" />
      <post-list v-else :posts="likedPosts" />
    </view>
  </view>
</template>

<script>
import PostList from '@/components/post/List.vue';

export default {
  components: { PostList },
  data() {
    return {
      activeTab: 'posts',
      user: {
        id: 1,
        name: '用户昵称',
        avatar: '/static/avatars/default.jpg',
        bio: '这个人很懒,什么都没写',
        followingCount: 123,
        followersCount: 456,
        postsCount: 78
      },
      posts: [
        // 用户动态数据...
      ],
      likedPosts: [
        // 点赞动态数据...
      ]
    }
  },
  methods: {
    navigateTo(url) {
      uni.navigateTo({ url });
    }
  }
}
</script>

<style>
.profile-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background-color: #FFFFFF;
}

.user-info {
  display: flex;
  padding: 40rpx 30rpx;
  position: relative;
}

.avatar {
  width: 150rpx;
  height: 150rpx;
  border-radius: 50%;
  margin-right: 30rpx;
}

.details {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.name {
  font-size: 40rpx;
  font-weight: bold;
  margin-bottom: 10rpx;
}

.bio {
  font-size: 28rpx;
  color: #666666;
}

.edit {
  position: absolute;
  right: 30rpx;
  top: 60rpx;
}

.edit-icon {
  width: 40rpx;
  height: 40rpx;
}

.stats {
  display: flex;
  justify-content: space-around;
  padding: 30rpx 0;
  border-top: 1rpx solid #F1F1F1;
  border-bottom: 1rpx solid #F1F1F1;
}

.stat-item {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.count {
  font-size: 36rpx;
  font-weight: bold;
  margin-bottom: 5rpx;
}

.label {
  font-size: 26rpx;
  color: #666666;
}

.tab-bar {
  display: flex;
  border-bottom: 1rpx solid #F1F1F1;
}

.tab-item {
  flex: 1;
  text-align: center;
  padding: 20rpx 0;
  font-size: 32rpx;
  color: #666666;
}

.tab-item.active {
  color: #007AFF;
  font-weight: bold;
  border-bottom: 4rpx solid #007AFF;
}

.tab-content {
  flex: 1;
  overflow: hidden;
}
</style>

鸿蒙特有功能适配

1. 鸿蒙分布式能力实现多设备协同

// 在app.vue中
export default {
  onLaunch() {
    // 初始化分布式能力
    this.$harmony.distributed.init({
      appId: 'com.example.socialapp',
      onDeviceConnected(device) {
        console.log('设备已连接:', device);
      },
      onDeviceDisconnected(device) {
        console.log('设备已断开:', device);
      }
    });
  },
  
  methods: {
    // 跨设备分享内容
    shareToDevice(device, content) {
      this.$harmony.distributed.share({
        deviceId: device.id,
        content: content,
        success: () => {
          uni.showToast({ title: '分享成功' });
        },
        fail: (error) => {
          uni.showToast({ title: '分享失败', icon: 'none' });
        }
      });
    }
  }
}

2. 鸿蒙原子化服务实现卡片功能

// 在manifest.json中配置
{
  "harmonyos": {
    "abilities": [
      {
        "name": "MainAbility",
        "type": "page",
        "forms": [
          {
            "name": "post_card",
            "description": "动态卡片",
            "type": "JS",
            "jsComponentName": "PostCard",
            "colorMode": "auto",
            "isDefault": true,
            "supportDimensions": ["2 * 2", "2 * 4"],
            "updateEnabled": true,
            "scheduledUpdateTime": "10:30",
            "updateDuration": 1
          }
        ]
      }
    ]
  }
}

// 创建卡片组件 components/card/PostCard.vue
<template>
  <view class="post-card">
    <image class="avatar" :src="post.user.avatar" />
    <text class="name">{{post.user.name}}</text>
    <text class="content">{{post.content}}</text>
    <image 
      v-if="post.images.length > 0" 
      class="image" 
      :src="post.images[0]" 
      mode="scaleToFill"
    />
  </view>
</template>

<script>
export default {
  props: {
    post: {
      type: Object,
      default: () => ({
        user: {
          avatar: '/static/avatars/default.jpg',
          name: '用户名'
        },
        content: '动态内容',
        images: []
      })
    }
  }
}
</script>

3. 鸿蒙通知能力实现消息提醒

// 在收到新消息时触发通知
this.$harmony.notification.show({
  id: 1,
  contentTitle: '新消息',
  contentText: `${sender}:${message}`,
  clickAction: {
    abilityName: "MainAbility",
    params: {
      route: '/pages/message/chat',
      id: conversationId
    }
  }
});

项目构建与发布

1. 构建HarmonyOS应用

  1. 在HBuilderX中选择"发行" -> "原生App-云打包"
  2. 选择"HarmonyOS"平台
  3. 配置证书和签名信息
  4. 点击"打包"生成HAP文件

2. 应用上架

  1. 登录华为开发者联盟
  2. 进入"我的项目"创建新应用
  3. 上传生成的HAP文件
  4. 填写应用信息和截图
  5. 提交审核

性能优化建议

  1. ​图片资源优化​​:

    • 使用WebP格式替代PNG/JPG
    • 实现懒加载和渐进式加载
    • 根据设备分辨率加载不同尺寸的图片
  2. ​数据缓存策略​​:

    • 使用本地存储缓存常用数据
    • 实现离线消息缓存
    • 设置合理的API缓存策略
  3. ​即时通讯优化​​:

    • 使用WebSocket实现实时通讯
    • 实现消息队列和去重
    • 优化大文件传输机制
  4. ​鸿蒙特有优化​​:

    • 使用鸿蒙的分布式能力实现多设备协同
    • 利用鸿蒙的原子化服务特性
    • 适配鸿蒙的卡片式交互

总结

通过Uniapp框架开发鸿蒙HarmonyOS 5社交类应用,我们实现了以下核心功能:

  1. ​用户系统​​:完整的注册、登录和个人资料管理
  2. ​动态功能​​:支持图文发布、点赞和评论
  3. ​即时通讯​​:实现了一对一聊天和群聊功能
  4. ​社交关系​​:关注/粉丝系统和好友管理
  5. ​鸿蒙特性​​:分布式能力、原子化服务和卡片功能

这种开发方式既保留了跨平台开发的效率优势,又能充分利用鸿蒙系统的特性,为用户提供高质量的社交体验。您可以根据实际需求进一步扩展功能,如添加视频通话、直播功能或AR社交等高级功能。

Logo

社区规范:仅讨论OpenHarmony相关问题。

更多推荐