基于Android和Java后台的朋友圈的设计和实现

我的简书:东方未曦

前言

这是秋招前做的一个应用,当时是想通过一个完整的项目来向面试官展现项目设计能力和实战能力,不过直到秋招最后很多面试官都没问这个,想来是他们觉得一个应届生做的东西也不会包含什么高深的技术吧。╮(╯▽╰)╭
在本次应用的开发过程中也遇到了很多问题,基本都是通过查阅博客解决的,在此感谢各位分享技术的博客大佬。因此现在我也将应用的实现过程记录下来,希望可以帮助到有需要的人。如果博文里有没有解释清楚的地方可以在评论区告知,我会再做补充。

一、系统截图及说明

1. 首页

首页主要显示背景图和好友动态,采用CoordinatorLayout布局来实现类似微信朋友圈的效果,上方显示用户自己设置的背景图,当用户在首页向下滑动时会逐渐关闭背景图并显示好友动态,向上滑动到顶部时又会逐渐显示背景图。
背景图下方是可以左右滑动的“圈内动态”和“公开动态”标签,本来的想法是如果发表圈内动态,只有好友可见;如果发表公开动态,则所有用户可见。不过最后没有实现公开动态的功能,只有圈内动态,因此必须是好友才能互相查看动态。
主页

2. 动态详情

动态详情包括文字及图片,系统设定最多支持6张图片。在动态详情页下方还有点赞数量以及其他用户的评论。如果当前用户点赞了这条动态,点赞图标就会变为红色。
动态详情

3. 好友

点击屏幕下方的“好友”选项卡可以查看用户的所有好友。
好友
在该页面点击上方的“查找用户”栏跳转到搜索页面,输入用户的账号可以进行精确查询,输入用户名可以进行模糊查询。
好友搜索
点击“好友请求”栏可以查看其它用户发送的好友请求,点击同意之后,该请求的状态就会变为“已同意”。
好友请求列表

4. 个人界面

点击屏幕下方的“我”切换到个人页面。
个人界面
点击个人界面中的“设置”栏可以前往修改头像、用户名和个性签名。
个人设置

5. 发表动态

首页拉到最上方,点击右上角的图标(见图1)可以发送动态。
发布1
动态包括图片和文字。根据设计,图片最多添加6张,因此在添加图片时需要对图片数量进行限制。选择图片时右上角会显示当前已经选择的图片数量和最大数量。
发布2
点击完成后,可以在发布页面看到已经选择的图片的预览。
发布3
此时用户可以再次点击添加图片的按钮,当前的图片最大选择数量变为了5,以保证不管用户分几次添加图片,图片的总数量都不会超过6。
发布4
点击完成,预览界面如下所示。
发布5

二、环境及配置

1. MySQL5.7

鉴于应用是支持中文的,数据库的编码肯定要修改为utf-8,不过数据库的utf-8编码最多占用3个字节,无法支持占用字节更多的emoji,怎么办?幸好从MySQL5.5.3开始,数据库提供了一种新的编码,那就是utf8mb4。因此首先需要将数据库的编码进行修改,修改完数据库的编码后,还需要对表中字段的编码格式进行修改。具体如下所示:
(1)修改mysql配置文件my.cnf(windows为my.ini)
my.cnf一般在etc/mysql/my.cnf位置。找到后请在以下三部分里添加如下内容:
[client]
default-character-set = utf8mb4
[mysql]
default-character-set = utf8mb4
[mysqld]
character-set-client-handshake = FALSE
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
init_connect=‘SET NAMES utf8mb4’
随后重启数据库,通过命令SHOW VARIABLES WHERE Variable_name LIKE 'character_set_%' OR Variable_name LIKE 'collation%';检查编码,得到如下结果表示成功。

Variable_nameValue
character_set_clientutf8mb4
character_set_connectionutf8mb4
character_set_databaseutf8mb4
character_set_filesystembinary
character_set_resultsutf8mb4
character_set_serverutf8mb4
character_set_systemutf8
character_sets_dirC:\Program Files\MySQL\MySQL Server 5.7\share\charsets\
collation_connectionutf8mb4_unicode_ci
collation_databaseutf8mb4_unicode_ci
collation_serverutf8mb4_unicode_ci

(2)修改数据库表和表中的字段的编码格式
目前还未建表,先讲一下修改编码格式的方法,建表之后可以根据需要修改某些字段的编码。本应用中支持emoji的字段为:动态文字、动态评论、用户名和用户签名。
首先需要修改表的编码格式:
ALTER TABLE `tablename` DEFAULT CHARACTER SET utf8mb4;
之后需要修改特定字段的编码格式:
ALTER TABLE `tablename` CHANGE `字段名` `字段名` VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL;
如果要将整个表的字段编码修改,则使用如下语句:
alter table `tablename` convert to character set utf8mb4;

2. Eclipse中添加服务器包

本应用的服务器接口使用Servlet编写,熟悉框架的朋友可以使用Spring。
在向服务器添加库的时候,有的朋友会从Tomcat下复制jar包到项目的lib路径下,但是最好的办法是将服务器的环境直接添加。
在Eclipse点击上方选项栏的Project->Properties->Java Build Path右方Add Library->Server Runtime选择Tomcat对应的版本即可。

3. 云服务器及应用部署

本次应用使用的是阿里云服务器,如果是学生的话,直接使用阿里云学生特权购买即可,大概¥120一年。在云服务器上需要安装JDK8、Tomcat以及MySQL数据库。
在本地编写完服务器程序后打包成war包放置到服务器tomcat/webapps/目录下,随后在tomcat/bin/目录下点击startup即可启动服务器。

三、系统功能模块划分

1. 注册登录

注册较为简单,用户在客户端填写账号和密码即可,系统会为该用户安排一个默认的用户名和头像。
用户使用注册的账号密码登录之后,客户端会在本地保存登录凭证,以便用户下一次打开APP时免去登录。

2. 用户信息管理

信息管理主要为背景图修改、头像修改、用户名修改、个性签名修改。这些信息都存储在user表中,用户信息管理都是修改user表中的字段。

3. 添加好友

用户可以通过搜索账号或用户名来检索用户,通过账号搜索时为精确查询,输入的账号必须和用户账号完全相同;通过用户名搜索时是模糊查询,有一部分匹配即可。搜索到用户后即可向其发送好友申请等待通过。
用户可以在“好友申请”界面查看其他用户发来的申请,如果点击“同意”,则两位用户之间建立好友关系,可以互相之间查看动态。好友关系通过用户关系表来保存,如果ID为1的用户和ID为2的用户是好友关系,那么表中会存储(1, 2)和(2, 1)这两组数据。

4. 发送动态

在Android客户端上传动态时,将用户输入的文字作为一个参数,将选择的每张图片各作为一个参数再调用接口。
在服务器端,接口循环接收参数并判断当前上传的参数是文字还是文件,如果是文字则保存到动态表中的文字字段;如果是文件,则按顺序保存到指定目录下并在表中保存图片的路径或名字。

5. 时间线

那么一个用户发送的动态怎么显示在他好友的动态列表中呢?这里就需要时间线这张表的帮助。当某位用户发表动态的时候,系统会先将该动态的文字和图片保存,得到该动态的主键ID。再搜索该用户的所有好友,对每个好友,以(好友ID, 动态ID)的形式在时间线表中添加一条记录。
当一个用户在首页请求好友的动态列表时,后台会在时间线表中搜索“好友ID”字段等于该用户ID的记录,拉取最近的N条记录并倒序返回。

6. 评论管理

本应用的评论系统较为简单,用户只能对动态进行评论,无法回复在动态下评论的其他用户。当然,如果要实现回复功能,只需要在每条评论上再加个字段表明该评论是回复哪条评论的即可。

7. 点赞管理

由于每个用户只能为某条动态点赞一次,因此需要一个表来保存动态的点赞情况。这里为了简化业务,将点赞设置为无法取消。当用户点赞某条动态时,动态表中对应动态的点赞数量字段+1,并在点赞表中保存(动态ID, 用户ID)形式的记录。用户查看某条动态时,如果(动态ID, 用户ID)记录存在,则将点赞图标设置为红色。
虽然本应用采用了上述的方式管理点赞,但是很容易产生大量记录降低运行效率。一种解决办法是使用非关系型数据库例如redis,通过键值对来存储为某条动态点赞的所有用户。如果坚持用关系型数据库处理点赞,可以使用类似(动态ID, [用户1, 用户2, 用户3…])的方式处理。

四、数据库设计

1. 用户表user

用户表需要记录用户的登陆凭证(账号、密码)以及用户的个人信息,包括用户名、头像、背景和签名等。

字段名数据类型长度约束描述
idint11主键, 自增用户ID
accountvarchar20主键账号
passwordvarchar30非空密码
usernamevarchar30用户名
iconvarchar100头像文件的名字
backgroundvarchar100背景图片的名字
signaturevarchar60个性签名
2. 好友关系表user_link
字段名数据类型长度约束描述
idint11主键, 自增唯一ID
useridint11主键用户ID
linkidint11非空好友ID
remarkvarchar20备注名
3. 好友申请表friendrequest

用户的好友请求页面会显示该用户收到的请求,由于请求分为“未同意”和“已同意”两种,因此表中需要一个status字段标注当前请求的状态,0表示未同意,1表示已同意。如果用户同意了某个好友请求,那么该请求的状态就会从0变为1,同时两人建立好友关系。

字段名数据类型长度约束描述
idint11主键, 自增唯一ID
senderint11非空发送请求的用户ID
receiverint11非空接到请求的用户ID
statusint1该请求的状态
4. 动态详情表moments_message
字段名数据类型长度约束描述
idint11主键, 自增唯一ID
useridint11非空用户ID
contentvarchar200非空动态的文字内容
picture1varchar100图片1
picture2varchar100图片2
picture3varchar100图片3
picture4varchar100图片4
picture5varchar100图片5
picture6varchar100图片6
createtimedatetime创建时间
likesint11点赞数量(默认0)
commentsint11评论数量(默认0)
5. 时间线表timeline
字段名数据类型长度约束描述
idint11主键, 自增唯一ID
useridint11非空用户ID
momentidint11非空动态ID
6. 评论表moments_comment
字段名数据类型长度约束描述
idint11主键, 自增唯一ID
momentidint11非空动态ID
useridint11非空用户ID
contentvarchar100非空评论内容
7. 点赞表moments_like
字段名数据类型长度约束描述
idint11主键, 自增唯一ID
momentidint11非空动态ID
useridint11非空用户ID

五、后台的部分实现

本应用后台的实现比较基础,主要是两个方面,一是通过数据库连接池和DAO实现数据的增删改查;二是通过Servlet实现数据的上传下载。这里挑选一部分介绍,其他大同小异。

1. 数据库连接池

本应用使用的数据库连接池是druid连接池,由于数据库连接池本身依赖于jdbc的驱动,所以需要在项目的WebContent/WEB-INF/lib下添加mysql-connector-java的包,为了支持uft8mb4编码,建议使用高版本的jar包(这里使用的版本是5.1.34),然后添加druid连接池的jar包,不要忘了对jar包执行add to build path操作。数据库连接池一般通过文件进行配置,在项目的src文件下添加配置文件druid.properties,如下所示。
第一行声明了数据库连接的驱动,最后一行声明了连接的编码为utf8mb4,其余url、username和password等信息注意修改。

driverClassName=com.mysql.jdbc.Driver
url=jdbc\:mysql\://localhost\:3306/moments
username=root
password=root
filters=stat
initialSize=2
minIdle=1
maxActive=300
maxWait=60000
timeBetweenEvictionRunsMillis=60000
minEvictableIdleTimeMillis=300000
validationQuery=SELECT 'x'
testWhileIdle=true
testOnBorrow=false
testOnReturn=false
poolPreparedStatements=false
maxPoolPreparedStatementPerConnectionSize=20
connectionInitSqls=[set names utf8mb4]

添加完配置文件之后,需要一个类对数据库连接池的基本操作进行封装。新建DbPoolConnection类,该类使用单例模式,保证在程序的运行过程中只存在一个DbPoolConnection实例。类中通过静态方法loadPropertyFile()来加载druid.properties文件中的配置,通过getConnection()方法获取一个连接,通过releaseConnection(Connection connection)方法释放一个连接。

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLDecoder;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Properties;

import com.alibaba.druid.pool.DruidDataSource;
import com.alibaba.druid.pool.DruidDataSourceFactory;
import com.alibaba.druid.pool.DruidPooledConnection;

public class DbPoolConnection {

	private static DbPoolConnection databasePool = null;
	private static DruidDataSource dds = null;

	static {
		Properties properties = loadPropertyFile("druid.properties");
		try {
			dds = (DruidDataSource) DruidDataSourceFactory.createDataSource(properties);
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

	private DbPoolConnection() { }

	public static synchronized DbPoolConnection getInstance() {
		if (null == databasePool) {
			databasePool = new DbPoolConnection();
		}
		return databasePool;
	}

	public static DruidPooledConnection getConnection() throws SQLException {
		return dds.getConnection();
	}
	
	public static void releaseConnection(Connection connection) throws SQLException {
		connection.close();
	}

	public static Properties loadPropertyFile(String fullFile) {
		String webRootPath = null;
		if (null == fullFile || fullFile.equals(""))
			throw new IllegalArgumentException("Properties file path can not be null: " + fullFile);
		webRootPath = DbPoolConnection.class.getClassLoader().getResource("").getPath();
		InputStream inputStream = null;
		Properties p = null;
		try {
			String sglPath = webRootPath + File.separator + fullFile;
			sglPath = URLDecoder.decode(sglPath, "utf-8"); // 关键
			inputStream = new FileInputStream(new File(sglPath));
			p = new Properties();
			p.load(inputStream);
		} catch (FileNotFoundException e) {
			throw new IllegalArgumentException("Properties file not found: " + fullFile);
		} catch (IOException e) {
			throw new IllegalArgumentException("Properties file can not be loading: " + fullFile);
		} finally {
			try {
				if (inputStream != null)
					inputStream.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
		return p;
	}
}
2. 数据的增删改查

这里定义一个泛型类DAO并使用QueryRunner 封装数据库增删改查的基本操作,这里的T可以代表任何数据实体,如果需要对某个实体进行操作,定义一个继承自DAO的类即可。DAO如下所示:

import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.List;
import org.apache.commons.dbutils.QueryRunner;
import org.apache.commons.dbutils.handlers.BeanHandler;
import org.apache.commons.dbutils.handlers.BeanListHandler;
import org.apache.commons.dbutils.handlers.ScalarHandler;
import com.lister.db.DbPoolConnection;

public class DAO<T> {
	
	private QueryRunner queryRunner = new QueryRunner();
	private Class<T> clazz;
	
	@SuppressWarnings("unchecked")
	public DAO() {
		// 获得带有泛型的父类
		Type superClass = getClass().getGenericSuperclass();
		// ParameterizedType 是参数化类型,即泛型
		if (superClass instanceof ParameterizedType) {
			ParameterizedType parameterizedType = (ParameterizedType) superClass;
			// 因为泛型可能有多个,所以使用参数类型数组保存
			Type[] typeArgs = parameterizedType.getActualTypeArguments();
			if (typeArgs != null && typeArgs.length > 0) {
				if (typeArgs[0] instanceof Class) {
					clazz = (Class<T>) typeArgs[0];
				}
			}
		}
	}
	
	/**
	 * 获取到数据库中的某个字段的值
	 * @throws SQLException 
	 */
	@SuppressWarnings("unchecked")
	public <E> E getValue(String sql, Object ... args) throws SQLException {
		Connection connection = null;
		try {
			connection = DbPoolConnection.getConnection();
			return (E) queryRunner.query(connection, sql, new ScalarHandler(), args);
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			DbPoolConnection.releaseConnection(connection);
		}
		return null;
	}

	/**
	 * 获取到数据库中的 N 条记录的列表
	 * @throws SQLException 
	 */
	public List<T> getList(String sql, Object ... args) throws SQLException {
		Connection connection = null;
		try {
			connection = DbPoolConnection.getConnection();
			return queryRunner.query(connection, sql, new BeanListHandler<>(clazz), args);
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			DbPoolConnection.releaseConnection(connection);
		}
		return null;
	}
	
	/**
	 * 获取到数据库中的一条记录
	 * @throws SQLException 
	 */
	public T get(String sql, Object ... args) throws SQLException {
		Connection connection = null;
		try {
			connection = DbPoolConnection.getConnection();
			return queryRunner.query(connection, sql, new BeanHandler<>(clazz), args);
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			DbPoolConnection.releaseConnection(connection);
		}
		return null;
	}
	
	/**
	 * 进行 insert, delete, update 操作
	 * @throws SQLException 
	 */
	public void update(String sql, Object ... args) throws SQLException {
		Connection connection = null;
		try {
			connection = DbPoolConnection.getConnection();
			queryRunner.update(connection, sql, args);
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			DbPoolConnection.releaseConnection(connection);
		}
	}
	
}

有了DAO这个基类之后,就可以通过继承自它的类来对指定的表的数据进行操作。
举个例子,假如要对moments_comment表进行操作,首先定义实体类Comment,实体类中的字段需要与数据库表内的字段一一对应。

public class Comment {
	Integer id;
	Integer momentid;
	Integer userid;
	String content;
	
	// getters and setters
}

那么对评论表有哪些操作呢?无非就是两个:1. 为某条动态下添加一条评论; 2. 获取某条动态下的所有评论。根据对表的操作写出如下接口。

import java.util.List;
import com.lister.model.Comment;

public interface CommentDAO {
	
	// 为某条动态添加评论
	public void addComment(Integer momentid, Integer userid, String content) throws Exception;
	
	//获取某条动态所有评论
	public List<Comment> getComments(Integer id) throws Exception;
}

有了实体类和接口之后,可以编写具体的操作来实现接口了。定义一个继承自DAO并实现了CommentDAO接口的类。方法中的?表示占位符,在最后调用DAO基类的方法时用参数填充SQL语句里的占位符。

import java.util.List;
import com.lister.dao.CommentDAO;
import com.lister.dao.DAO;
import com.lister.model.Comment;

public class CommentDAOImpl extends DAO<Comment> implements CommentDAO {

	@Override
	public void addComment(Integer momentid, Integer userid, String content) throws Exception {
		String sql = "insert into moments_comment values(0, ?, ?, ?)";
		update(sql, momentid, userid, content);
	}

	@Override
	public List<Comment> getComments(Integer momentid) throws Exception {
		String sql = "select * from moments_comment where momentid = ? order by id desc";
		return getList(sql, momentid);
	}

}

之后即可通过CommentDAOImpl的实例来操作评论表。对于其他表的操作,也可以使用类似的方式。

3. 文件的上传

应用中的头像修改和发表动态都涉及到图片文件的上传,这里以头像修改为例介绍文件上传的方式。
为了支持文件上传,需要添加jar包,文件上传依赖common-fileupload和common-io这两个包。而在服务器端接收文件,与平时的接收参数有一点差别,如果只有文字参数,一般通过参数名来获取参数值;如果带有文件参数,则通过一个循环接收参数并判断该参数是普通参数(文字)还是文件参数,再进行对应的操作。服务器端头像修改的代码如下,已通过注释详细介绍。

    /**
	 * 接口:处理 changeicon.user 请求 修改头像
	 * @throws Exception 
	 */
	public void changeicon(HttpServletRequest request, HttpServletResponse response)
			throws Exception {
		request.setCharacterEncoding("utf-8");
		response.setCharacterEncoding("utf-8");
		response.setContentType("text/html;charset=utf-8");
		PrintWriter writer = response.getWriter();
		/**
		 * 由于上传头像时包含文件, 使用 FileItem 获取参数
		 */
		String idString = "0";
		String account = "";
		String picpath = "";
		try {
			// 得到上传文件的保存目录
			String realPath = this.getServletContext().getRealPath("/upload");
			String tempPath = "C:\\tempPath"; // 临时目录
			// 判断存放上传文件的目录是否存在
			File f = new File(realPath);
			if (!f.exists() && !f.isDirectory()) {
				f.mkdir();
			}
			// 判断临时目录是否存在
			File tempFilePath = new File(tempPath);
			if (!tempFilePath.isDirectory()) {
				tempFilePath.mkdir();
			}
			/**
			 * 使用Apache文件上传组件处理文件上传步骤
			 */
			// 1. 设置环境:创建一个DiskFileItemFactory工厂
			DiskFileItemFactory factory = new DiskFileItemFactory();
			// 设置上传文件的临时目录
			factory.setRepository(tempFilePath);
			// 2. 核心操作类: 创建一个文件上传解析器。
			ServletFileUpload upload = new ServletFileUpload(factory);
			// 解决上传"文件名"的中文乱码
			upload.setHeaderEncoding("UTF-8");
			// 3. 判断 enctype:判断提交上来的数据是否是上传表单的数据
			if (!ServletFileUpload.isMultipartContent(request)) {
				System.out.println("不是上传文件,终止");
				// 按照传统方式获取数据
				return;
			}
			// 4. 使用ServletFileUpload解析器解析上传数据,
			// 解析结果返回的是一个List<FileItem>集合
			// 每一个FileItem对应一个Form表单的输入项
			List<FileItem> items = upload.parseRequest(request);
			for (FileItem item : items) {
				// 如果 fileItem 中封装的是普通输入项的数据
				if (item.isFormField()) {
					String filedName = item.getFieldName();// 普通输入项数据的名
					// 解决普通输入项的数据的中文乱码问题
					String filedValue = item.getString("UTF-8");
					if (filedName.equals("account")) {
						account = filedValue;
					} else if (filedName.equals("id")) {
						idString = filedValue;
					}
				} else {
					// 如果 fileItem 中封装的是上传文件, 得到上传的文件名称
					String fileName = item.getName();
					// 多个文件上传输入框有空 的 异常处理
					if (fileName == null || "".equals(fileName.trim())) {
						continue;
					}
					// 处理上传文件的文件名的路径, 截取字符串只保留文件名部分
					// 截取留最后一个"\"之后,+1 截取向右移一位 "\a.txt"-->"a.txt"
					fileName = fileName.substring(fileName.lastIndexOf("\\") + 1);
					String suffix = fileName.substring(fileName.lastIndexOf("."));
					// 使用当前时间作为新的文件名
					SimpleDateFormat df = new SimpleDateFormat("yyyyMMddHHmmss");
					fileName = account + df.format(new Date()) + suffix;
					// 拼接上传路径: 存放路径 + 上传的文件名
					String filePath = realPath + "\\" + fileName;
					// 构建输入输出流
					InputStream in = item.getInputStream();
					OutputStream out = new FileOutputStream(filePath);
					byte b[] = new byte[1024];
					int len = -1;
					while ((len = in.read(b)) != -1) {
						out.write(b, 0, len);
					}
					out.close(); in.close();
					// 删除临时文件
					try {
						Thread.sleep(1000);
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
					item.delete();
					// picpath 赋值
					picpath = fileName;
				}
			}
			// 循环完毕, 添加记录
			Integer id = Integer.parseInt(idString);
			if (id > 0 && !account.trim().equals("")) {
				if (userDAO.isUserExist(id, account) != 0) {
					userDAO.changeUserIcon(id, picpath);
					RevertMessage revertMessage = new RevertMessage(true, "头像上传成功");
					writer.print(gson.toJson(revertMessage));
				} else {
					// 用户不存在
					RevertMessage revertMessage = new RevertMessage(false, "用户不存在");
					writer.print(gson.toJson(revertMessage));
				}
			} else {
				// 账号为空
				RevertMessage revertMessage = new RevertMessage(false, "账号为空");
				writer.print(gson.toJson(revertMessage));
			}
		} catch (FileUploadException e) {
			// 上传异常
			System.out.println(e.getMessage());
			RevertMessage revertMessage = new RevertMessage(false, "文件上传异常");
			writer.print(gson.toJson(revertMessage));
		}
	}

六、Android的部分实现

1. 首页布局

用户进入应用后,屏幕下方的选项卡默认为“动态”,上方显示用户自定义的背景和头像,点击右上角的图案可发送动态。当用户在首页向下滑动查看好友的动态时,上方的背景图会逐渐消失,具体效果可见第一张图。那么怎么实现呢?
Android的CoordinatorLayout就可以实现这类效果。在布局时,使用CoordinatorLayout布局包裹CollapsingToolbarLayout,从CollapsingToolbarLayout这个布局的名字我们可以看出一些端倪。Collapse什么意思?坍塌,折叠。因此这是一个会折叠的布局,只要对它的行为做一些设置,就可以实现滑动后折叠的效果。因此可以在CollapsingToolbarLayout中放置背景图、用户名和头像等控件。布局效果如下:
首页布局
布局文件如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.lister.momentsandroid.activity.MomentsActivity">

    <android.support.design.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <android.support.design.widget.AppBarLayout
            android:id="@+id/moments_appbar_layout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:fitsSystemWindows="true">
            <android.support.design.widget.CollapsingToolbarLayout
                android:id="@+id/moments_collapse_layout"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                app:collapsedTitleTextAppearance="@style/ToolBarTitleText"
                app:contentScrim="@color/mainPurple"
                app:expandedTitleMarginEnd="48dp"
                app:expandedTitleMarginStart="48dp"
                app:expandedTitleTextAppearance="@style/transparentText"
                app:layout_scrollFlags="scroll|exitUntilCollapsed"
                android:fitsSystemWindows="true">
                <LinearLayout
                    android:id="@+id/moments_info_linear"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:orientation="vertical"
                    app:layout_collapseMode="pin"
                    app:layout_collapseParallaxMultiplier="0.7">
                    <RelativeLayout
                        android:layout_width="match_parent"
                        android:layout_height="240dp"
                        android:background="@color/light_gray">
                        <ImageView
                            android:id="@+id/moments_background"
                            android:layout_width="match_parent"
                            android:layout_height="match_parent"
                            android:scaleType="centerCrop"/>
                        <LinearLayout
                            android:id="@+id/moments_post"
                            android:layout_width="80dp"
                            android:layout_height="80dp"
                            android:layout_alignParentTop="true"
                            android:layout_alignParentRight="true"
                            android:clickable="true"
                            android:focusable="true">
                            <ImageView
                                android:layout_width="24dp"
                                android:layout_height="24dp"
                                android:layout_marginTop="30dp"
                                android:layout_marginLeft="30dp"
                                android:src="@drawable/moments_post_icon"/>
                        </LinearLayout>

                        <LinearLayout
                            android:layout_width="wrap_content"
                            android:layout_height="wrap_content"
                            android:layout_alignParentBottom="true"
                            android:layout_alignParentEnd="true"
                            android:layout_marginBottom="15dp"
                            android:layout_marginRight="20dp"
                            android:orientation="vertical">
                            <TextView
                                android:id="@+id/moments_text_name"
                                android:layout_width="wrap_content"
                                android:layout_height="wrap_content"
                                android:textSize="14sp"
                                android:textColor="@color/white"
                                android:layout_marginBottom="8dp"/>
                            <ImageView
                                android:id="@+id/moments_icon"
                                android:layout_width="80dp"
                                android:layout_height="80dp"
                                android:background="@color/white"
                                android:padding="2dp"/>
                        </LinearLayout>
                    </RelativeLayout>
                </LinearLayout>

                <android.support.v7.widget.Toolbar
                    android:id="@+id/toolbar"
                    android:layout_width="match_parent"
                    android:layout_height="45dp"
                    android:clickable="true"
                    app:layout_collapseMode="pin"
                    app:layout_scrollFlags="scroll|enterAlways"
                    app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
                    app:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
                    app:titleTextAppearance="@style/Toolbar.TitleText"/>
            </android.support.design.widget.CollapsingToolbarLayout>

            <android.support.design.widget.TabLayout
                android:id="@+id/moments_tab_layout"
                android:layout_width="match_parent"
                android:layout_height="50dp"
                android:layout_gravity="bottom"
                android:background="#ffffff"
                android:fillViewport="false"
                app:layout_scrollFlags="scroll"
                app:tabIndicatorColor="@color/mainPurple"
                app:tabIndicatorHeight="2dp"
                app:tabSelectedTextColor="@color/mainPurple"
                app:tabTextColor="#ced0d3">
                <android.support.design.widget.TabItem
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:text="圈内动态"/>
                <android.support.design.widget.TabItem
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:text="公开动态"/>
            </android.support.design.widget.TabLayout>

        </android.support.design.widget.AppBarLayout>

        <android.support.v4.view.ViewPager
            android:id="@+id/moments_pager"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="#ffffff"
            app:layout_behavior="@string/appbar_scrolling_view_behavior"/>
    </android.support.design.widget.CoordinatorLayout>

</LinearLayout>

布局文件中用到了两个样式:

<style name="ToolBarTitleText" parent="TextAppearance.AppCompat.Medium">
      <item name="android:textColor">#ffffffff</item>
      <item name="android:textSize">16sp</item>
      <item name="android:textStyle">bold</item>
</style>

<style name="transparentText" parent="TextAppearance.AppCompat.Small">
      <item name="android:textColor">#00000000</item>
</style>

在Activity文件中,需要对ToolBar和AppBarLayout进行设置。在onOffsetChanged方法中监听折叠的程度并逐渐改变ToolBar的颜色,当背景图区域折叠超过一半的时候,屏幕上方的ToolBar显示“动态”,否则不显示文字。

    @BindView(R.id.toolbar) Toolbar mToolbar;
    @BindView(R.id.moments_collapse_layout) CollapsingToolbarLayout mCollapsingToolbarLayout;
    @BindView(R.id.moments_appbar_layout) AppBarLayout mAppBarLayout;
    @BindView(R.id.moments_info_linear) LinearLayout mInfoLinear;
    // ......

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_moments);
        ButterKnife.bind(this);
        // 注册 EventBus
        EventBus.getDefault().register(this);
        // 设置 ToolBar
        setSupportActionBar(mToolbar);
        // AppBarLayout
        mAppBarLayout.addOnOffsetChangedListener(new AppBarLayout.OnOffsetChangedListener() {
            @Override
            public void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) {
                // 逐渐修改颜色
                mToolbar.setBackgroundColor(changeAlpha(getResources().getColor(R.color.mainPurple),
                        Math.abs(verticalOffset * 1.0f) / appBarLayout.getTotalScrollRange()));
                if (verticalOffset <= -mInfoLinear.getHeight() / 2) {
                    mCollapsingToolbarLayout.setTitle("动态");
                } else {
                    mCollapsingToolbarLayout.setTitle("");
                }
            }
        });
        // ......
    }
2. 联网操作封装

本应用使用OKHTTP框架进行联网操作,新建HttpUtils类封装http请求。首先在类中定义一些私有变量来做缓存控制,如下所示:

private static final CacheControl FORCE_NETWORK = new CacheControl.Builder().noCache().build();
private static final CacheControl FORCE_CACHE = new CacheControl.Builder()
            .onlyIfCached()
            .maxStale(Integer.MAX_VALUE, TimeUnit.SECONDS)
            .build();
// 缓存控制
public static final String TYPE_FORCE_CACHE = "TYPE_FORCE_CACHE";
public static final String TYPE_FORCE_NETWORK = "TYPE_FORCE_NETWORK";
public static final String TYPE_CACHE_CONTROL = "TYPE_CACHE_CONTROL";

来看最基础的post请求,在上传的参数只有文字的情况下调用此方法。

    public static Object[] postHttp(Context context, String url, HashMap<String, String> params, String cacheType, int cacheSeconds) {
        try {
            // 缓存文件夹
            File cacheFile = new File(context.getExternalCacheDir().toString(), "cache");
            // 缓存大小为50M
            int cacheSize = 50 * 1024 * 1024;
            // 创建缓存对象
            final Cache cache = new Cache(cacheFile, cacheSize);

            OkHttpClient mOkHttpClient = new OkHttpClient.Builder()
                    .connectTimeout(10, TimeUnit.SECONDS)
                    .readTimeout(20, TimeUnit.SECONDS)
                    .cache(cache)
                    .build();

            FormBody.Builder formBodyBuilder = new FormBody.Builder();
            for (Map.Entry<String, String> entry : params.entrySet())
                formBodyBuilder.add(entry.getKey(), entry.getValue());
            RequestBody formBody = formBodyBuilder.build();

            CacheControl cacheControl = null;
            if (cacheType.equals(TYPE_CACHE_CONTROL)) {
                cacheControl = new CacheControl.Builder()
                        .maxAge(cacheSeconds, TimeUnit.SECONDS).build();
            }
            if (cacheType.equals(TYPE_FORCE_CACHE)) {
                cacheControl = FORCE_CACHE;
            }
            if (cacheType.equals(TYPE_FORCE_NETWORK)) {
                cacheControl = FORCE_NETWORK;
            }

            Request request = new Request.Builder()
                    .cacheControl(cacheControl)
                    .url(url)
                    .post(formBody)
                    .build();
            Response response = mOkHttpClient.newCall(request).execute();
            String result = response.body().string();
            // 处理result并return
        } catch (Exception e) {
            // ......
        }
    }

调用示例:

// 参数分别为 Context, 接口地址, HashMap<String, String>参数, 缓存控制, 缓存时间
// 这里的TYPE_FORCE_NETWORK指不用缓存, 强制联网获取资源
return HttpUtils.postHttp(ChangeNameActivity.this,
                        IPConstant.CHANGE_USER_NAME, params,
                        HttpUtils.TYPE_FORCE_NETWORK, 0);

那么如果上传的参数里包含图片呢?那么就需要两类参数,也就是两个Map来保存参数,一个保存文字,一个保存文件。

    public static Object[] postHttpJPGLinked(
            Context context, String url, LinkedHashMap<String, String> params, LinkedHashMap<String, File> pictures) {
        try {
            OkHttpClient mOkHttpClient = new OkHttpClient.Builder()
                    .connectTimeout(10, TimeUnit.SECONDS)
                    .readTimeout(20, TimeUnit.SECONDS)
                    .build();
            MultipartBody.Builder multipartBody = new MultipartBody.Builder();
            multipartBody.setType(MultipartBody.FORM);
            for (Map.Entry<String, String> entry : params.entrySet())
                multipartBody.addFormDataPart(entry.getKey(), entry.getValue());
            for (Map.Entry<String, File> entry : pictures.entrySet()) {
                RequestBody fileBody = RequestBody.create(MediaType.parse("image/jpg"), entry.getValue());
                multipartBody.addFormDataPart(entry.getKey(), entry.getKey() + ".jpg", fileBody);
            }
            RequestBody requestBody = multipartBody.build();

            Request request = new Request.Builder()
                    .url(url)
                    .post(requestBody)
                    .build();
            Response response = mOkHttpClient.newCall(request).execute();
            String result = response.body().string();
            // ......
        } catch (Exception e) {
            // ......
        }
    }
3. 发送动态在相册选取图片

从相册选取图片主要是参考下方的第10篇博客,之后将图片添加到一个List里方便上传。由于鸿洋大神的博客已经讲得很详细了,我就不多说了。

总结

做这个应用的时候赶着秋招,一直剩下几个坑没填,例如上传图片前没有压缩、密码没有加密和没有使用token等······但是做应用的过程中,我更多思考的是朋友圈如何实现组群权限以及高并发。如果有朋友了解组群权限和集群这方面内容的,希望能分享一下,拜谢~
另:最近又有个新的项目构思,是做一个主要功能为视频上传和观看的应用,这次有充分的时间将之前没完善的方面全部做好,完成以后依旧会以博客的形式分享。

参考:
  1. 微信朋友圈架构:https://www.jianshu.com/p/3fb3652ff450
  2. 数据库设计:https://www.zhihu.com/question/21909660
  3. MySQL修改编码为utf8mb4:https://www.cnblogs.com/shihaiming/p/5855616.html
  4. 将表字段编码修改为utf8mb4:https://blog.csdn.net/luo4105/article/details/50804148
  5. 添加服务器包:https://blog.csdn.net/evan_leung/article/details/50647112
  6. Servlet注解:https://blog.csdn.net/mytt_10566/article/details/71077154
  7. 文件上传:https://www.cnblogs.com/liuyangv/p/8298997.html
  8. Tomcat部署项目:https://www.cnblogs.com/ysocean/p/6893446.html
  9. CoordinatorLayout打造详情页:https://www.jianshu.com/p/5287d090e777
  10. 仿微信图片选择器:https://blog.csdn.net/lmj623565791/article/details/39943731/
  11. OKHTTP:https://blog.csdn.net/lmj623565791/article/details/47911083
  • 4
    点赞
  • 39
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值