目录
一、测试环境说明
二、项目简介
本应用采用经典的 MVC(Model - View - Controller)架构,将数据模型(Model)、视图(View)和控制器(Controller)分离,提高代码的可维护性和可扩展性。
Model:负责数据的存储和处理,包括用户信息、饮水记录等。使用 Room 数据库来实现数据的持久化存储。
View:负责界面的展示,包括登录界面、主界面、统计界面等。使用 Android 的布局文件和视图组件来构建界面。
Controller:负责处理用户的交互事件和业务逻辑,协调 Model 和 View 之间的通信。使用 Android 的 Activity 和 Fragment 来实现控制器。
三、项目演示
网络资源模板--基于Android studio喝水提醒App
四、部设计详情(部分)
注册页面
package com.example.waterreminder;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Patterns;
import android.view.View;
import android.view.ViewAnimationUtils;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.textfield.TextInputEditText;
import com.google.android.material.textfield.TextInputLayout;
import com.example.waterreminder.utils.UserManager;
public class RegisterActivity extends AppCompatActivity {
private TextInputLayout tilUsername, tilEmail, tilPassword, tilConfirmPassword;
private TextInputEditText etUsername, etEmail, etPassword, etConfirmPassword;
private MaterialButton btnRegister, btnBack;
private View registerCard;
private UserManager userManager;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_register);
userManager = UserManager.getInstance(this);
initViews();
setupListeners();
}
private void initViews() {
tilUsername = findViewById(R.id.tilUsername);
tilEmail = findViewById(R.id.tilEmail);
tilPassword = findViewById(R.id.tilPassword);
tilConfirmPassword = findViewById(R.id.tilConfirmPassword);
etUsername = findViewById(R.id.etUsername);
etEmail = findViewById(R.id.etEmail);
etPassword = findViewById(R.id.etPassword);
etConfirmPassword = findViewById(R.id.etConfirmPassword);
btnRegister = findViewById(R.id.btnRegister);
btnBack = findViewById(R.id.btnBack);
registerCard = findViewById(R.id.registerCard);
}
private void setupListeners() {
btnRegister.setOnClickListener(v -> attemptRegister());
btnBack.setOnClickListener(v -> finish());
// 添加焦点变化监听器,清除错误提示
setupFocusChangeListener(etUsername, tilUsername);
setupFocusChangeListener(etEmail, tilEmail);
setupFocusChangeListener(etPassword, tilPassword);
setupFocusChangeListener(etConfirmPassword, tilConfirmPassword);
}
private void setupFocusChangeListener(TextInputEditText editText, TextInputLayout inputLayout) {
editText.setOnFocusChangeListener((v, hasFocus) -> {
if (hasFocus) {
inputLayout.setError(null);
}
});
}
private void attemptRegister() {
// 重置错误提示
tilUsername.setError(null);
tilEmail.setError(null);
tilPassword.setError(null);
tilConfirmPassword.setError(null);
String username = etUsername.getText().toString().trim();
String email = etEmail.getText().toString().trim();
String password = etPassword.getText().toString();
String confirmPassword = etConfirmPassword.getText().toString();
boolean cancel = false;
View focusView = null;
// 检查确认密码
if (TextUtils.isEmpty(confirmPassword)) {
tilConfirmPassword.setError("请确认密码");
focusView = etConfirmPassword;
cancel = true;
shakeView(tilConfirmPassword);
} else if (!password.equals(confirmPassword)) {
tilConfirmPassword.setError("两次输入的密码不一致");
focusView = etConfirmPassword;
cancel = true;
shakeView(tilConfirmPassword);
}
// 检查密码
if (TextUtils.isEmpty(password)) {
tilPassword.setError("请输入密码");
focusView = etPassword;
cancel = true;
shakeView(tilPassword);
} else if (password.length() < 6) {
tilPassword.setError("密码长度至少为6位");
focusView = etPassword;
cancel = true;
shakeView(tilPassword);
}
// 检查邮箱
if (TextUtils.isEmpty(email)) {
tilEmail.setError("请输入邮箱");
focusView = etEmail;
cancel = true;
shakeView(tilEmail);
} else if (!email.toLowerCase().contains("@qq.com")) {
tilEmail.setError("请输入有效的QQ邮箱地址");
focusView = etEmail;
cancel = true;
shakeView(tilEmail);
}
// 检查用户名
if (TextUtils.isEmpty(username)) {
tilUsername.setError("请输入用户名");
focusView = etUsername;
cancel = true;
shakeView(tilUsername);
}
if (cancel) {
focusView.requestFocus();
} else {
// 尝试注册
if (userManager.register(username, password, email)) {
showSuccessAnimation();
} else {
showRegisterError();
}
}
}
private void shakeView(View view) {
Animation shake = AnimationUtils.loadAnimation(this, R.anim.shake);
view.startAnimation(shake);
}
private void showSuccessAnimation() {
// 禁用所有输入
setInputsEnabled(false);
// 创建圆形收缩动画
int cx = registerCard.getWidth() / 2;
int cy = registerCard.getHeight() / 2;
float finalRadius = (float) Math.hypot(cx, cy);
Animator anim = ViewAnimationUtils.createCircularReveal(registerCard, cx, cy, finalRadius, 0);
anim.setDuration(500);
// 动画结束后关闭活动
anim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
registerCard.setVisibility(View.INVISIBLE);
Toast.makeText(RegisterActivity.this, "注册成功", Toast.LENGTH_SHORT).show();
finish();
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out);
}
});
anim.start();
}
private void showRegisterError() {
tilUsername.setError("用户名已存在");
shakeView(tilUsername);
}
private void setInputsEnabled(boolean enabled) {
etUsername.setEnabled(enabled);
etEmail.setEnabled(enabled);
etPassword.setEnabled(enabled);
etConfirmPassword.setEnabled(enabled);
btnRegister.setEnabled(enabled);
btnBack.setEnabled(enabled);
}
}
一、用户界面与交互设计
该注册界面采用Material Design组件构建,包含用户名、邮箱、密码和确认密码四个输入字段。
界面交互设计具有以下特点:
1. 错误处理机制:每个输入字段都配备了实时错误提示功能,当用户输入不符合要求时,会在对应字段下方显示错误信息并触发抖动动画(shake动画),增强用户感知。
2. 焦点管理:为所有输入字段设置了焦点变化监听器,当用户点击某个输入框时,自动清除该字段之前的错误提示,避免干扰当前输入。
3. 动画反馈:注册成功时采用圆形收缩动画(CircularReveal)作为视觉反馈,配合渐隐过渡效果,提供流畅的用户体验。
二、输入验证逻辑
注册表单实现了严格的客户端验证逻辑,验证顺序从下往上(确认密码→密码→邮箱→用户名),确保及时发现所有问题:
1. 用户名验证:检查非空,确保用户必须输入用户名。
2. 邮箱验证:检查非空且必须包含"@qq.com"(不区分大小写),专门针对QQ邮箱进行验证。
3. 密码验证:检查非空且长度至少6位,符合基本密码安全要求。
4. 确认密码验证:检查非空且必须与密码字段完全一致,防止用户输入错误。
三、业务逻辑实现
1. 用户管理:通过UserManager单例处理核心注册逻辑,封装了用户数据的持久化操作。
2. 线程处理:注册操作在主线程执行,适合轻量级的本地用户管理。如需网络请求应考虑异步处理。
3. 状态管理:注册过程中禁用所有输入控件,防止重复提交;注册成功后立即关闭当前活动。
四、动画与视觉效果
1. 错误提示动画:使用R.anim.shake资源实现输入错误的抖动效果,增强用户注意力。
2. 成功动画:采用ViewAnimationUtils.createCircularReveal创建圆形收缩动画,视觉上"收起"注册卡片,暗示注册完成。
3. 过渡动画:活动关闭时使用系统提供的淡入淡出效果(fade_in/fade_out),保持界面切换流畅性。
五、代码组织特点
1. 职责分离:将视图初始化、事件监听设置和业务逻辑分离到不同方法中,保持代码清晰。
2. 重用性:通过setupFocusChangeListener方法统一处理所有输入框的焦点变化逻辑,避免重复代码。
3. 可扩展性:验证逻辑模块化设计,便于后续添加更多验证规则或修改现有规则。
首页
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<TextView
android:id="@+id/tv_today_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="今日已喝水"
android:textSize="24sp"
android:textColor="@color/colorPrimary"
android:layout_marginTop="32dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<com.example.waterreminder.ui.custom.WaterGlassView
android:id="@+id/water_glass_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginHorizontal="32dp"
android:layout_marginVertical="32dp"
app:layout_constraintDimensionRatio="1:1.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@id/drinkOptionsLayout"/>
<LinearLayout
android:id="@+id/drinkOptionsLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginBottom="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_water"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_margin="8dp"
android:text="白水"
app:icon="@drawable/ic_water"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_coffee"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_margin="8dp"
android:text="咖啡"
app:icon="@drawable/ic_coffee"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_tea"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_margin="8dp"
android:text="茶"
app:icon="@drawable/ic_tea"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
一、基本架构与数据管理
该代码实现了一个饮水记录应用的主界面功能模块,采用标准的Android开发架构。
核心数据管理通过Room数据库实现,创建了DrinkDatabase单例来管理饮水记录数据的持久化存储。
数据库操作采用异步线程处理,确保数据库访问不会阻塞UI线程,同时使用runOnUiThread回调实现线程安全的UI更新。
二、用户界面交互设计
界面包含三个主要交互元素:白水、咖啡和茶的记录按钮。其中白水按钮触发自定义输入对话框,允许用户输入任意饮水量;咖啡和茶按钮则直接记录预设值(200ml和250ml)。
自定义对话框dialog_custom_amount采用Material Design组件构建,包含输入验证逻辑,确保用户只能输入有效的正整数值。
饮水进度通过自定义视图WaterGlassView可视化展示,以水位上升动画直观反映当前饮水进度与每日目标的比例关系。
三、业务逻辑实现
每日饮水统计功能基于时间范围查询实现,利用StatsUtils.getTodayStartMillis()获取当日0点时间戳作为查询起点。
总饮水量与预设的DAILY_GOAL(2000ml)比较计算完成百分比,驱动水位视图的动画效果。
记录添加操作采用"插入后刷新"模式,每次新增记录后自动触发今日统计数据的重新计算和界面更新,保证数据的实时一致性。
四、代码组织特点
该Fragment遵循了清晰的职责分离原则:视图初始化在onViewCreated中完成,业务逻辑封装在独立方法中,数据库操作全部在后台线程执行。采用lambda表达式简化事件监听器的实现,使代码更加简洁。错误处理方面,对用户输入进行了基本的验证和友好的Toast提示,提升了用户体验。
统计页
package com.example.waterreminder.ui.statistics;
import android.graphics.Color;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.ProgressBar;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.example.waterreminder.DrinkDatabase;
import com.example.waterreminder.R;
import com.example.waterreminder.StatsUtils;
import com.github.mikephil.charting.charts.BarChart;
import com.github.mikephil.charting.components.XAxis;
import com.github.mikephil.charting.components.YAxis;
import com.github.mikephil.charting.data.BarData;
import com.github.mikephil.charting.data.BarDataSet;
import com.github.mikephil.charting.data.BarEntry;
import com.github.mikephil.charting.formatter.ValueFormatter;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.Executors;
import com.example.waterreminder.DrinkRecord;
public class StatisticsFragment extends Fragment {
private DrinkDatabase db;
private TextView tvTotalDrinks, tvAvgInterval, tvConsecutiveDays;
private ViewGroup chartContainer;
private SimpleDateFormat dateFormat;
private TextView tvWaterAmount, tvCoffeeAmount, tvTeaAmount;
private ProgressBar progressWater, progressCoffee, progressTea;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_statistics, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
db = DrinkDatabase.getInstance(requireContext());
dateFormat = new SimpleDateFormat("MM-dd", Locale.getDefault());
// 初始化视图
tvTotalDrinks = view.findViewById(R.id.tv_total_drinks);
tvAvgInterval = view.findViewById(R.id.tv_avg_interval);
tvConsecutiveDays = view.findViewById(R.id.tv_consecutive_days);
chartContainer = view.findViewById(R.id.chart_container);
tvWaterAmount = view.findViewById(R.id.tv_water_amount);
tvCoffeeAmount = view.findViewById(R.id.tv_coffee_amount);
tvTeaAmount = view.findViewById(R.id.tv_tea_amount);
progressWater = view.findViewById(R.id.progress_water);
progressCoffee = view.findViewById(R.id.progress_coffee);
progressTea = view.findViewById(R.id.progress_tea);
// 创建图表
setupChart();
// 加载数据
loadStatistics();
loadDrinkDetails();
}
private void setupChart() {
BarChart chart = new BarChart(requireContext());
chart.setLayoutParams(new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
// 配置图表
chart.getDescription().setEnabled(false);
chart.setDrawGridBackground(false);
chart.setDrawBarShadow(false);
chart.setHighlightFullBarEnabled(false);
// 配置X轴
XAxis xAxis = chart.getXAxis();
xAxis.setPosition(XAxis.XAxisPosition.BOTTOM);
xAxis.setDrawGridLines(false);
xAxis.setGranularity(1f);
xAxis.setValueFormatter(new ValueFormatter() {
@Override
public String getFormattedValue(float value) {
Calendar cal = Calendar.getInstance();
cal.add(Calendar.DAY_OF_YEAR, (int)value - 6); // 从最近7天开始
return dateFormat.format(cal.getTime());
}
});
// 配置Y轴
YAxis leftAxis = chart.getAxisLeft();
leftAxis.setDrawGridLines(true);
leftAxis.setAxisMinimum(0f);
leftAxis.setValueFormatter(new ValueFormatter() {
@Override
public String getFormattedValue(float value) {
return String.format(Locale.getDefault(), "%.0f ml", value);
}
});
chart.getAxisRight().setEnabled(false);
// 添加到容器
chartContainer.addView(chart);
// 加载图表数据
loadChartData(chart);
}
private void loadChartData(BarChart chart) {
Executors.newSingleThreadExecutor().execute(() -> {
// 获取最近7天的数据
List<BarEntry> entries = new ArrayList<>();
Calendar cal = Calendar.getInstance();
cal.set(Calendar.HOUR_OF_DAY, 0);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MILLISECOND, 0);
// 获取7天前的开始时间
cal.add(Calendar.DAY_OF_YEAR, -6);
for (int i = 0; i < 7; i++) {
long startTime = cal.getTimeInMillis();
cal.add(Calendar.DAY_OF_YEAR, 1);
long endTime = cal.getTimeInMillis();
// 获取该天的总饮水量
int totalAmount = db.drinkDao().getTotalAmountBetween(startTime, endTime);
entries.add(new BarEntry(i, totalAmount));
}
// 创建数据集
BarDataSet dataSet = new BarDataSet(entries, "每日饮水量(ml)");
dataSet.setColor(Color.BLUE);
dataSet.setValueTextColor(Color.BLACK);
dataSet.setValueTextSize(10f);
dataSet.setValueFormatter(new ValueFormatter() {
@Override
public String getFormattedValue(float value) {
return String.format(Locale.getDefault(), "%.0f", value);
}
});
// 设置数据
BarData barData = new BarData(dataSet);
barData.setBarWidth(0.7f);
// 在主线程更新UI
requireActivity().runOnUiThread(() -> {
chart.setData(barData);
chart.invalidate();
});
});
}
private void loadStatistics() {
Executors.newSingleThreadExecutor().execute(() -> {
// 获取本月总饮水量
Calendar cal = Calendar.getInstance();
cal.set(Calendar.DAY_OF_MONTH, 1);
cal.set(Calendar.HOUR_OF_DAY, 0);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MILLISECOND, 0);
long monthStart = cal.getTimeInMillis();
int monthlyTotal = db.drinkDao().getTotalAmountBetween(
monthStart,
System.currentTimeMillis()
);
long avgInterval = StatsUtils.getAverageInterval(
db.drinkDao().getRecordsBetween(
StatsUtils.getTodayStartMillis(),
System.currentTimeMillis()
)
);
int consecutiveDays = StatsUtils.getConsecutiveGoalDays(
db.drinkDao(),
requireContext().getSharedPreferences("settings", 0).getInt("daily_goal", 2000)
);
// 更新UI
requireActivity().runOnUiThread(() -> {
tvTotalDrinks.setText("本月总计:" + monthlyTotal + " ml");
tvAvgInterval.setText("平均间隔:" + (avgInterval > 0 ? (avgInterval / 60000) : "暂无数据") + " 分钟");
tvConsecutiveDays.setText("连续达标:" + consecutiveDays + " 天");
});
});
}
private void loadDrinkDetails() {
Executors.newSingleThreadExecutor().execute(() -> {
// 获取最近7天的数据
Calendar cal = Calendar.getInstance();
cal.set(Calendar.HOUR_OF_DAY, 0);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MILLISECOND, 0);
// 获取7天前的开始时间
cal.add(Calendar.DAY_OF_YEAR, -6);
long weekStart = cal.getTimeInMillis();
long now = System.currentTimeMillis();
java.util.List<DrinkRecord> weekRecords = db.drinkDao().getRecordsBetween(weekStart, now);
int total = 0, water = 0, coffee = 0, tea = 0;
for (DrinkRecord r : weekRecords) {
total += r.amount;
if ("白水".equals(r.type)) water += r.amount;
else if ("咖啡".equals(r.type)) coffee += r.amount;
else if ("茶".equals(r.type)) tea += r.amount;
}
int waterPercent = total > 0 ? (water * 100 / total) : 0;
int coffeePercent = total > 0 ? (coffee * 100 / total) : 0;
int teaPercent = total > 0 ? (tea * 100 / total) : 0;
final int fWater = water;
final int fCoffee = coffee;
final int fTea = tea;
final int fWaterPercent = waterPercent;
final int fCoffeePercent = coffeePercent;
final int fTeaPercent = teaPercent;
requireActivity().runOnUiThread(() -> {
tvWaterAmount.setText(fWater + " ml [" + fWaterPercent + "%]");
progressWater.setProgress(fWaterPercent);
tvCoffeeAmount.setText(fCoffee + " ml [" + fCoffeePercent + "%]");
progressCoffee.setProgress(fCoffeePercent);
tvTeaAmount.setText(fTea + " ml [" + fTeaPercent + "%]");
progressTea.setProgress(fTeaPercent);
});
});
}
}
面展示用户的饮水习惯。该模块采用MPAndroidChart库构建七日饮水量柱状图,通过自定义X轴日期标签和带单位的Y轴数值,直观呈现饮水趋势变化。
数据加载采用异步线程处理,通过Executors管理数据库查询任务,确保UI流畅性。
核心统计功能包含三个层次的时间范围分析:当日数据用于计算平均饮水间隔,七日数据支撑图表展示和饮品分类占比,月度数据则统计总饮水量。
统计指标通过Room数据库的高效查询实现,利用@Query注解获取特定时间段的记录,在内存中进行二次聚合计算。
特别设计的连续达标天数算法,结合SharedPreferences存储的每日目标值,动态追踪用户饮水目标的达成情况。
分类统计功能将饮水量按白水、咖啡、茶三种类型细分,不仅显示绝对数值,还通过进度条直观展示各类饮品占比。
数据处理过程中加入严格的空值检查机制,避免零除错误,并对无数据情况显示友好提示。
日期显示统一采用SimpleDateFormat格式化为"MM-dd"格式,保证界面信息的一致性。
性能优化方面,模块采用多线程架构分离数据加载与UI渲染,通过局部变量缓存中间计算结果,有效降低内存占用。
动态创建的图表组件适配不同屏幕尺寸,进度条长度随数据实时调整,形成响应式布局。
整体实现兼顾了数据准确性、视觉表现力和操作流畅度,为用户提供全面的饮水行为分析支持。
五、项目源码
👇👇👇👇👇快捷方式👇👇👇👇👇