Android Room如何防止插入空条目 空校验失效问题排查修复
问题描述
已实现的TodoList必填项校验逻辑规则为:当一个或多个必填字段为空时,禁止将Task添加到Room数据库,该逻辑此前运行正常。近期无代码改动的情况下拦截逻辑突然失效,空任务可正常入库,对比功能首次上线的提交版本未发现代码差异,调试日志证实部分/全部字段为空的任务仍可成功写入数据库。
现有代码
Model层
import androidx.room.Entity import androidx.room.PrimaryKey import java.util.* /** 实体类,对应数据库表 **/ @Entity(tableName = "todo_table") data class Todo( @PrimaryKey (autoGenerate = true) val id : Int = 0, var title : String = "", var date : Date = Date(), var time : Date = Date(), var todoCheckBox : Boolean = false )
AddFragment层
import android.graphics.Color import android.os.Bundle import android.text.SpannableString import android.text.TextPaint import android.text.TextUtils import android.text.method.LinkMovementMethod import android.text.style.ClickableSpan import android.util.Log import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController import com.bignerdranch.android.to_dolist.R import com.bignerdranch.android.to_dolist.data.TodoViewModel import com.bignerdranch.android.to_dolist.databinding.FragmentAddBinding import com.bignerdranch.android.to_dolist.fragments.dialogs.DatePickerFragment import com.bignerdranch.android.to_dolist.fragments.dialogs.TimePickerFragment import com.bignerdranch.android.to_dolist.model.Todo import java.text.SimpleDateFormat import java.util.* private const val DIALOG_DATE = "DialogDate" private const val DIALOG_TIME = "DialogTime" const val SIMPLE_DATE_FORMAT = "MMM, d yyyy" const val SIMPLE_TIME_FORMAT = "H:mm" private const val TAG = "AddFragment" class AddFragment : Fragment() { private lateinit var todoViewModel : TodoViewModel private var _binding : FragmentAddBinding? = null private val binding get() = _binding!! private lateinit var todo : Todo override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) todo = Todo() } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentAddBinding.inflate(inflater, container, false) todoViewModel = ViewModelProvider(this)[TodoViewModel::class.java] remindersTextSpan() binding.abCheckYes.setOnClickListener { insertTodoInDatabase() } binding.edDate.setOnClickListener { childFragmentManager.setFragmentResultListener("requestKey", viewLifecycleOwner) {_, bundle -> val result = bundle.getSerializable("bundleKey") as Date todo.date = result if(todo.date.toString().isNotEmpty()) { updateDate() } } DatePickerFragment().show(this@AddFragment.childFragmentManager, DIALOG_DATE) } binding.edTime.setOnClickListener { childFragmentManager.setFragmentResultListener("tRequestKey", viewLifecycleOwner) {_, bundle -> val result = bundle.getSerializable("tBundleKey") as Date todo.time = result if (todo.time.toString().isNotEmpty()) { updateTime() } } TimePickerFragment().show(this@AddFragment.childFragmentManager, DIALOG_TIME) } return binding.root } private fun updateDate() { val dateLocales = SimpleDateFormat(SIMPLE_DATE_FORMAT, Locale.getDefault()) binding.edDate.text = dateLocales.format(todo.date) } private fun updateTime() { val timeLocales = SimpleDateFormat(SIMPLE_TIME_FORMAT, Locale.getDefault()) binding.edTime.text = timeLocales.format(todo.time) } private fun remindersTextSpan() { val spannableString = SpannableString("Set Reminders") val clickableSpan = object : ClickableSpan() { override fun onClick(widget: View) { Toast.makeText(context, "Set Reminders!", Toast.LENGTH_LONG).show() } override fun updateDrawState(ds: TextPaint) { super.updateDrawState(ds) ds.color = Color.BLUE } } spannableString.setSpan(clickableSpan, 0, 13, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE) binding.tvReminders.text = spannableString binding.tvReminders.movementMethod = LinkMovementMethod.getInstance() } private fun insertTodoInDatabase() { val title = binding.edTaskTitle.text.toString() val date = binding.edDate.text.toString() val time = binding.edTime.text.toString() if (inputCheck(title, date, time)) { val todo = Todo(0, title) todoViewModel.addTodo(todo) Toast.makeText(requireContext(), R.string.task_add_toast, Toast.LENGTH_LONG).show() findNavController().navigate(R.id.action_addFragment_to_listFragment) } else { Toast.makeText(requireContext(), R.string.no_task_add_toast, Toast.LENGTH_LONG).show() } Log.d(TAG, "Our todo widgets are $title $date and $time") } private fun inputCheck(title : String, date: String, time: String) : Boolean { return !(TextUtils.isEmpty(title) && TextUtils.isEmpty(date) && time.isEmpty()) } override fun onDestroy() { super.onDestroy() _binding = null } }
DAO层
@Dao interface TodoDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun addTodo(todo: Todo) @Query("SELECT * FROM todo_table ORDER BY id ASC") fun readAllData() : LiveData<List<Todo>> @Query("DELETE FROM todo_table WHERE id IN (:idList)") suspend fun deleteSelectedTasks(idList : Long) @Query("DELETE FROM todo_table") suspend fun deleteAllTasks() }
根因分析
拦截失效的核心问题是inputCheck方法的逻辑运算符使用错误:
现有逻辑!(TextUtils.isEmpty(title) && TextUtils.isEmpty(date) && time.isEmpty())的实际判定规则是仅当title、date、time三个字段全部为空时才拦截入库,只要任意一个字段有值就允许插入,和需求中「任意必填项为空就拦截」的规则完全相反。
比如用户只选了日期没填标题,该判断会返回true,空标题的任务就会被直接写入数据库。你觉得代码无变动但逻辑失效,本质是之前测试时只覆盖了三个字段全空的场景,没有测试部分字段为空的情况,后续使用中碰到部分空值场景问题就暴露了。
除此之外代码还存在三个隐藏问题:
- 构造入库的Todo实体时只传入了title字段,date、time直接使用实体类默认的当前时间,用户选择的日期时间根本没有存入数据库
- 在点击事件中重复注册FragmentResultListener,会导致选择日期/时间后回调多次触发
todo.date.toString().isNotEmpty()、todo.time.toString().isNotEmpty()判断完全无效,Date对象的toString方法永远不会返回空字符串
修复方案
1. 修正校验逻辑
将inputCheck中的逻辑与&&替换为逻辑或||,实现任意字段为空即拦截的规则,推荐用Kotlin内置的isNotBlank同时过滤全空格的无效输入:
private fun inputCheck(title : String, date: String, time: String) : Boolean { // 所有字段都非空且不是全空格时才允许入库 return title.isNotBlank() && date.isNotBlank() && time.isNotBlank() }
2. 修正实体构造逻辑
入库时传入用户实际选择的日期、时间,不要使用默认值,同时对输入的标题做首尾空格裁剪:
if (inputCheck(title, date, time)) { val newTodo = Todo( title = title.trim(), date = todo.date, time = todo.time ) todoViewModel.addTodo(newTodo) Toast.makeText(requireContext(), R.string.task_add_toast, Toast.LENGTH_LONG).show() findNavController().navigate(R.id.action_addFragment_to_listFragment) }
3. 优化回调注册逻辑
将FragmentResultListener的注册移到onCreateView中统一执行,不要在点击事件里重复注册,同时删除无效的空值判断:
override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentAddBinding.inflate(inflater, container, false) todoViewModel = ViewModelProvider(this)[TodoViewModel::class.java] remindersTextSpan() // 统一注册日期选择回调 childFragmentManager.setFragmentResultListener("requestKey", viewLifecycleOwner) {_, bundle -> val result = bundle.getSerializable("bundleKey") as Date todo.date = result updateDate() } // 统一注册时间选择回调 childFragmentManager.setFragmentResultListener("tRequestKey", viewLifecycleOwner) {_, bundle -> val result = bundle.getSerializable("tBundleKey") as Date todo.time = result updateTime() } binding.abCheckYes.setOnClickListener { insertTodoInDatabase() } binding.edDate.setOnClickListener { DatePickerFragment().show(this@AddFragment.childFragmentManager, DIALOG_DATE) } binding.edTime.setOnClickListener { TimePickerFragment().show(this@AddFragment.childFragmentManager, DIALOG_TIME) } return binding.root }
内容的提问来源于stack exchange,提问作者Daniel Iroka

