简介:Android开发中,登录与注册界面是用户进入App的第一道门槛,其美观度与易用性直接影响产品留存。这套源码项目面向有基础的开发者,提供了可直接复用的精美登录/注册界面模板,能解决自绘UI配色生硬、输入反馈缺失、交互过渡平淡等常见问题。压缩包共52个文件,大小仅1.27MB,主要由XML布局、PNG切图、Java源码与class文件构成,还附带一个APK,方便真机预览效果。目前已有6483人学习浏览,人气较高。项目完整实现登录、注册两个主界面,运用Material Design规范,包括TextInputLayout浮动标签、密码明文密文切换、输入校验提示,并加入动画过渡提升操作体验。源码清晰梳理了Activity跳转、界面数据传递等逻辑,同时为本地存储和网络请求预留扩展位,整体目录区分布局、资源与代码,适合学习Android UI设计及组件化开发,也可快速换色换图后接入实际项目。
1. 为什么 Android 漂亮好看的登陆注册界面值得你亲自写一套
打开任何一个需要用户身份的产品,第一见面就是登录页。很多开发者从网上拖一套现成模板下来,结果适配一坑接一坑,旧代码里还堆着一堆用不上的自定义控件,删都不敢删。标题里要的“Android漂亮好看的登陆,注册界面”,本质上不是拿来即用的黑匣子,而是一套可以真正落到工程里的界面骨架:登录页加注册页、统一视觉规范、输入校验与状态切换,还有最容易被忽略的键盘和主题适配。对新手,这套源码的价值是照着敲出第一个能看的完整页面;对熟手,价值是可以直接抽取颜色、圆角、间距和切换状态,沉淀成团队自己的登录组件。下面从选型开始,把每个环节拆开讲。
2. 设计选型:从界面层次到视觉规范,先把“漂亮”的定义拆开
每一套让人眼前一亮的登录注册源码,都会先做设计决策再做控件堆叠。第一件事是确立视觉层级:背景在最底层,承载渐变或插画;输入区域和卡片在中间层;按钮、错误提示和浮动标签在最高层。这个层级决定了我后面写 XML 时所有约束的挂载方式,也决定了键盘弹起时系统知道哪些内容需要被推走,哪些可以保持不变。
登录注册不涉及复杂动效,但涉及三类操作状态:初始态、聚焦态、错误态。初始态用浅色描边和辅助文字,聚焦态用主色描边,错误态用红色描边和提示文字。为了让这套状态在所有页面生效,输入框统一用官方 Material 组件里的 TextInputLayout,而不是自己画边框去拼状态切换,否则十几个控件就要维护十几种状态。
2.1 视觉层的四个基本构件:配色、圆角、阴影、间距
“漂亮”不能靠堆装饰,作为 UI 工程,它由四件没有主观争议的小事撑起来。第一是配色,登录页通常只需要主色、中性色和两个功能色:主色用于按钮和选中态,中性色用于背景和输入框,错误色和成功色用于校验反馈。把颜色数量控制在三到五个,一个页面就能避免“花哨但不耐看”的问题。第二是圆角,输入框和按钮建议用 8dp、12dp、16dp 三层规范,不要一个页面同时出现 4dp 和 40dp 的圆角,那样视觉会很乱。第三是阴影,Material 组件的默认阴影不会出现在状态栏区域,操作按钮只需要轻微海拔,0dp 到 3dp 就够,代码里到处复用 elevation=10dp 是很多界面显得脏的原因。第四是间距,用 8dp 的倍数排布上下左右,视觉上就有节奏。登录页的输入框间距我习惯用 16dp,按钮到输入框用 24dp,按钮下方的辅助文字用 8dp。
这四件事定下来以后,后面写 XML 几乎不用临时拍脑袋。很多人拿到一类“登录注册界面”的源码,第一件事是把背景图换成自己的,但那只是表面。真正能复用的漂亮,是让这四个参数可以被全局主题修改,而不是写在每个控件的属性里。圆角如果写死在 drawable 里,后面换风格就要逐个文件改;改成主题属性后,各页面自动跟随,这是做值和贴图的分水岭。
我自己在铺页面之前,会先列一个最小清单:主色是什么、错误色是什么、全局圆角有几档、页面左右边距多少。这份清单不超过五行,但能解决一半后面视觉返工。另外,有几个项目还要求登录文字支持英文、阿拉伯语,这时登录和注册的字符串不能写死,必须走资源引用,布局长度也不一样,间距至少留 30% 余量。
2.2 布局选型为什么用 ConstraintLayout + TextInputLayout
布局选型决定后面键盘适配的工作量。常见做法是外层用可滚动的容器,内容用 ConstraintLayout,输入框用官方组件里的 TextInputLayout 包裹 TextInputEditText。TextInputLayout 自带浮动标签、密码可见性切换和错误提示图标,这三样都是登录页高频功能,自己实现至少要多写三四个状态控制。ConstraintLayout 的用途是减少嵌套,比如按钮底部对齐、验证码按钮和输入框水平排布,用约束关系代替线性布局里的多层嵌套,键盘弹起时重排成本更低。
我一般会规避两种用法:一是用 LinearLayout 一层套一层,键盘弹起来时整棵树重新测量,中低端机器掉帧明显;二是输入框不带 TextInputLayout 直接裸奔,错误只弹 Toast,用户看不到输入框旁边的对错反馈,视觉上很容易被打回。做界面的人最容易翻车的键盘遮挡问题,在布局层级简单时也会少一些莫名其妙的边界情况。
具体落地时,我会把 ScrollView 的 android:fillViewport 设为 true,让内容在页面撑不满时仍然占满视口,背景渐变不会在底部露出难看的截断。容器内部切到 ConstraintLayout 后,标题、输入框、按钮、辅助说明全部靠约束挂接,每个控件之间的垂直间距明确,后面要插一个“忘记密码”链接,只需要加约束,不用动其他结构的嵌套层级。真机调的时候,重点看两个横向约束为 0dp 的父输入框是否两端对齐,这一步经常有人漏掉,导致输入框左右边距不一致。
2.3 亮色/暗色主题与无障碍对比度
登录注册界面跑到用户手机,有很大概率会因为系统开启了深色模式而完全变样。白色背景上写死白色文字,或者深色主色按钮前景没跟着切,是两种最常见的灾难。解决办法很土但也最可靠:颜色资源按 values 和 values-night 两套放置,布局文件里一律引用颜色资源,不出现 #FFFFFF 这种裸写。对于对比度,输入框的提示文字与背景的对比度建议至少 4.5:1,错误提示的红色不能太浅,我在多款机子上验证过,深色模式下亮度低于 30% 的红色文字基本看不清。
这些规范要落进后面的 drawable 和 style,而不是靠视觉稿临时给值。比如 minSdk 定了之后,暗色主题用 values-night 覆盖,启动器图标也单独切一套深色背景,这样源码拿到手,换成任何品牌主色只需要改三四处资源,而不是满屏找魔法值。后面第四章的代码也会沿这套规范写,不再重复提颜色取值问题。无障碍方面,登录按钮和复选框都应该设置明确的内容描述,不要只放一个纯图标按钮,读屏用户才能顺畅走完整套流程。
3. 把界面搭出来:XML 布局、形状资源与渐变背景
这个章节的每一步都直接对应源码里的可编辑文件。先出登录页布局,再出注册页差异,最后补上背景、按钮样式和键盘适配,按这个顺序往下做,基本不会返工。
3.1 登录页 XML:从底部分层到输入框和按钮
登录页我用的结构是 NestedScrollView 作为根,里面一个 ConstraintLayout 作为内容区。NestedScrollView 既能应对小屏键盘遮挡,也能保证背景渐变填满整个可用区域。下面这份布局是简化可直接用的版本,省略了顶部品牌 Logo 和忘记密码入口,重点展示输入框与按钮的约束方式。
<?xml version="1.0" encoding="utf-8"?> <androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/login_root" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:background="@drawable/bg_login_gradient"> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingHorizontal="24dp" android:paddingTop="48dp" android:paddingBottom="32dp"> <TextView android:id="@+id/tv_title" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="欢迎回来" android:textSize="32sp" android:textStyle="bold" android:textColor="@color/primary" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/tv_subtitle" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="新用户请点击底部注册" android:textSize="14sp" android:textColor="@color/on_surface_muted" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/tv_title" /> <com.google.android.material.textfield.TextInputLayout android:id="@+id/layout_account" style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="32dp" android:hint="手机号 / 邮箱" app:boxCornerRadiusBottomEnd="12dp" app:boxCornerRadiusBottomStart="12dp" app:boxCornerRadiusTopEnd="12dp" app:boxCornerRadiusTopStart="12dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/tv_subtitle"> <com.google.android.material.textfield.TextInputEditText android:id="@+id/et_account" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="textEmailAddress" android:maxLines="1" android:imeOptions="actionNext" /> </com.google.android.material.textfield.TextInputLayout> <com.google.android.material.textfield.TextInputLayout android:id="@+id/layout_password" style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:hint="密码" app:boxCornerRadiusBottomEnd="12dp" app:boxCornerRadiusBottomStart="12dp" app:boxCornerRadiusTopEnd="12dp" app:boxCornerRadiusTopStart="12dp" app:endIconMode="password_toggle" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/layout_account"> <com.google.android.material.textfield.TextInputEditText android:id="@+id/et_password" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="textPassword" android:maxLines="1" android:imeOptions="actionDone" /> </com.google.android.material.textfield.TextInputLayout> <CheckBox android:id="@+id/ck_remember" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:buttonTint="@color/primary" android:text="记住我" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/layout_password" /> <com.google.android.material.button.MaterialButton android:id="@+id/btn_login" android:layout_width="0dp" android:layout_height="56dp" android:layout_marginTop="32dp" android:text="登 录" android:textSize="16sp" app:cornerRadius="16dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/ck_remember" /> </androidx.constraintlayout.widget.ConstraintLayout> </androidx.core.widget.NestedScrollView>这里有三个参数需要重点说明。第一个是 TextInputLayout 的 style,用了 OutlinedBox 而不是 FilledBox,配合四个 cornerRadius 可以加工成圆角输入框,比 FilledBox 在浅色和深色下的对比度更稳。第二个是 TextInputEditText 的 inputType,手机号/邮箱统一用 textEmailAddress,密码用 textPassword,注意 inputType 决定软键盘弹出的按键类型,不要为了偷懒全部写成 text。第三个是 ConstraintLayout 中的 layout_width=“0dp”,水平方向挂约束后,0dp 表示按约束撑满,保证输入框两端对齐,不依赖父控件 padding。
这段布局底部没有放“忘记密码”,注册页切换入口我在第四章用 Fragment 的方式补。如果你只需要两个独立 Activity,把 tv_switch 放到登录页底部即可,数据传递逻辑会简单不少。
3.2 注册页复用同一套风格:字段差异和切换逻辑
注册页不需要重新设计,我通常会把登录页的输入框复制过来,再加一个确认密码和一个滚到底部的协议条。真正的差异有两个地方:确认密码的 TextInputLayout 要用同一个圆角风格,以及底部需要插入一个“同意用户协议”的复选框。协议复选框如果直接放在页面最下方,在软键盘弹出后很可能被顶出屏幕,所以注册页外层必须在 NestedScrollView 里,并且内部要有足够的滚动余量。
切换逻辑最简单的方法是复用同一个 Activity 容器,用两个 Fragment 做登录和注册的切换;如果项目没有引入 Fragment,也可以用 Intent 跳转第二个 Activity,但这样返回键处理、登录状态回填都得重写一遍。我倾向于 Fragment,因为后续接入第三方登录或者找回密码,可以把每个业务都拆成一个 Fragment,不会让 Activity 膨胀。
3.3 形状、选择器与渐变:三层 drawable 让按钮“活”起来
登录注册界面最出效果的部分是背景渐变和按钮按压反馈。渐变我会放在 NestedScrollView 的 background 上,让页面滚动时背景固定,而不是放在约束布局里跟着内容跑。下面是背景和按钮的两个核心 drawable。
<!-- res/drawable/bg_login_gradient.xml --> <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <item> <shape android:shape="rectangle"> <gradient android:angle="135" android:startColor="#3A7BD5" android:centerColor="#2A5CAA" android:endColor="#1E3B77" /> </shape> </item> </layer-list><!-- res/drawable/bg_btn_primary.xml --> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:state_enabled="true" android:state_pressed="true"> <shape> <solid android:color="@color/primary_pressed" /> <corners android:radius="16dp" /> </shape> </item> <item android:state_enabled="true"> <shape> <solid android:color="@color/primary" /> <corners android:radius="16dp" /> </shape> </item> <item> <shape> <solid android:color="@color/primary_disabled" /> <corners android:radius="16dp" /> </shape> </item> </selector>注意 bg_btn_primary 里最后一个 item 没有写 state_enabled,即默认状态,用于按钮禁用时显示灰底。selector 的作用是当按钮 enabled 属性变化时自动切换背景,比在代码里不断 setBackground 要干净很多。把 MaterialButton 的 app:cornerRadius 和 android:background 同时设置偶尔会出现圆角被覆盖的怪问题,所以很多项目里会直接放弃 MaterialButton 的自带圆角属性,改用这个 selector 背景,然后设置 android:stateListAnimator=“@null” 去掉默认阴影,这样按压反馈完全交给 drawable 控制。如果你的工程没有引入 Material Components,普通 EditText 也可以直接用一层圆角背景模拟输入框描边,效果接近。
3.4 页面骨架:为状态栏和键盘留出空间
界面骨架的最后一步是在 Manifest 里声明键盘行为。我会给登录注册页面设置 android:windowSoftInputMode=“adjustResize”,并让 Activity 主题不加状态栏沉浸模式,这样键盘弹起时页面整体被压缩,NestedScrollView 可以滚动到当前输入框,而不是被输入法直接盖住。如果用了透明状态栏,要在布局根节点设置 android:fitsSystemWindows=“true”,但 fitsSystemWindows 和 adjustResize 同时使用时,某些定制 ROM 会把状态栏高度重复计算,导致顶部多出一截空白,这块属于高发坑,第五章单独讲。
另一种做法是 adjustNothing,让键盘直接盖住内容而不调整窗口,这种情况页面底部的按钮会被盖住,必须手动监听键盘高度来滚动,复杂度高不少。除非你是做聊天类应用,否则登录注册场景不要选 adjustNothing。
4. 写登录注册逻辑:校验、状态保存与界面切换
界面好看是第一步,能正常走通登录注册流程才是代码能被业务使用的关键。这一章我从输入校验、记住我和界面切换三个点来写,基本对应一个最小可用登录注册模块的全部核心逻辑。
4.1 输入校验规则:手机号、密码、验证码的边界
校验不通过时,UI 要给出具体反馈,而不是只弹一个通用 Toast。TextInputLayout 自带 error 属性,可以直接把错误文案显示在输入框下方,同时还把边框变成错误色,这是登录注册界面最有用的反馈机制。下面的 Kotlin 代码就是一个小型校验函数,负责手机号/邮箱和密码长度。
class LoginFragment : Fragment() { private var _binding: FragmentLoginBinding? = null private val binding get() = _binding!! override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentLoginBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding.btnLogin.setOnClickListener { val account = binding.etAccount.text?.toString()?.trim() ?: "" val password = binding.etPassword.text?.toString() ?: "" if (validate(account, password)) { login(account, password) } } } private fun validate(account: String, password: String): Boolean { val accountRegex = Regex("^(1[3-9]\\d{9}|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})$") return when { account.isEmpty() -> { binding.layoutAccount.error = "请输入手机号或邮箱" false } !accountRegex.matches(account) -> { binding.layoutAccount.error = "手机号或邮箱格式不正确" false } password.isEmpty() -> { binding.layoutPassword.error = "请输入密码" false } password.length < 8 -> { binding.layoutPassword.error = "密码至少 8 位" false } else -> { binding.layoutAccount.error = null binding.layoutPassword.error = null true } } } private fun login(account: String, password: String) { // 此处做真实网络请求或 Demo 假登录 } }这个函数把手机号正则和邮箱正则用|合并,手机号覆盖 13 到 19 开头的 11 位号码,邮箱按常规格式校验,不做深度解析。账户被 trim 后保存,密码不 trim,因为用户输入的密码允许包含首尾空格正是为了通过校验。如果项目要求首次登录强制数字密码,需要再叠加一个纯数字判断,但绝大多数业务场景不需要。
4.2 “记住我”与登录态存储的正确姿势
“记住我”在 Android 里的常见实现是 SharedPreferences 存一个布尔值和一个最近使用的账号。记住账号不等于记住密码,实际开发中密码不持久化,密码只存在于内存中的输入框里,这是默认的安全底线。
object LoginStorage { private const val PREFS_NAME = "login_prefs" private const val KEY_REMEMBER = "remember_me" private const val KEY_ACCOUNT = "last_account" fun saveRemember(context: Context, remember: Boolean, account: String) { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) .edit() .putBoolean(KEY_REMEMBER, remember) .putString(KEY_ACCOUNT, if (remember) account else "") .apply() } fun restoreAccount(context: Context): String? { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return if (prefs.getBoolean(KEY_REMEMBER, false)) { prefs.getString(KEY_ACCOUNT, null) } else { null } } }这里的 apply 是异步写盘,不会阻塞主线程,适合这种低频数据;但要注意保存时机应该在登录成功之后,而不是用户勾选后立刻保存。如果登录请求返回失败,还保留账号会让用户误以为上次登录成功。登录态 token 本身不应该放进 SharedPreferences,建议放到加密存储,或者至少配合 KeyStore 加密后再写入。标题里的演示源码往往简化成明文存储,但落到正式项目时必须补这一层。
4.3 登录注册二合一:Fragment 切换与数据传递
同一个 Activity 里放两个 Fragment 是常见的登录注册组合方案。Activity 提供容器和一个切换按钮,Fragment 各自管理自己的 UI 和输入状态,避免两个页面逻辑互相污染。下面的代码是一个简单切换骨架。
class AuthActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_auth) if (savedInstanceState == null) { openFragment(LoginFragment()) } findViewById<TextView>(R.id.tv_switch).setOnClickListener { val current = supportFragmentManager.findFragmentById(R.id.auth_container) if (current is LoginFragment) { openFragment(RegisterFragment()) } else { openFragment(LoginFragment()) } } } private fun openFragment(fragment: Fragment) { supportFragmentManager.beginTransaction() .replace(R.id.auth_container, fragment) .addToBackStack(null) .commit() } }replace 而不是 add,是避免 Fragment 叠加后隐藏层仍占用内存或干扰背景;addToBackStack 是为了让用户按返回键时能在登录和注册之间回退,而不是直接退出整个 Activity。如果注册成功需要带回账号到登录页,最方便的是用 FragmentManager 的 setFragmentResult 传递,或者给 RegisterFragment 加一个回调接口;两个 Activity 之间则用 putExtra 回传结果,逻辑更直观但耦合会高一点。
4.4 登录按钮防重复提交与 loading 态
登录注册按钮连点两次会发出两个重复请求,后端不校验时还会创建同样账号。规范做法是点击后立刻禁用按钮并显示加载状态,请求结束后再恢复。下面这段和上一节的 login 方法配合使用。
private fun setLoginLoading(loading: Boolean) { binding.btnLogin.isEnabled = !loading binding.btnLogin.text = if (loading) "登录中…" else "登 录" } private fun login(account: String, password: String) { setLoginLoading(true) viewModel.login(account, password) { success -> if (isAdded) { setLoginLoading(false) if (success) showHome() else showLoginError() } } }这里isAdded写在 Fragment 中,防止请求返回时用户已经切走了 Fragment,直接更新 UI 会触发空指针。loading 态里还要把两个输入框一起 disable,否则用户在请求期间改密码,返回后页面上的输入和实际提交的数据不一致。这个细节经常被演示源码忽略,所以很多网上的模板才会出现重复登录的现象。
5. 常见问题:登录注册界面的 5 个翻车现场和排查方法
界面搭完,逻辑写完,真正消耗体力的部分是适配和状态冲突。下面这五个问题是我被问得最多、也最常见的地雷。
5.1 弹键盘后按钮被顶飞
现象:输入框聚焦后软键盘弹出,页面底部按钮要么被完全盖住,要么页面顶部出现一大段空白,甚至标题跑到状态栏后面。
原因:Manifest 里没设置 windowSoftInputMode,默认行为在不同定制 ROM 上不一致,有的直接调整尺寸,有的不调整。把透明状态栏和 fitsSystemWindows 同时放在根布局上时,状态栏高度也会被重复计算。
解决:先把 LoginActivity 和 RegisterActivity 的 windowSoftInputMode 显式写成 adjustResize,再把布局根节点换成 NestedScrollView,并去掉根节点的 fitsSystemWindows;如果项目必须用透明状态栏,就改成在代码里监听输入法高度来滚动。建议在测试时开最大字体和最小屏占比,这个场景能复现一半的键盘问题。
5.2 深色模式下白色输入框刺眼
现象:用户开启系统深色模式后,登录页显示浅色背景加白底输入框,文字看不清,白色输入框像一块补丁。
原因:drawable 或布局里直接写死了 #FFFFFF,或者只在 values 里定义颜色,没有提供 values-night 对应的深色资源。页面主题跟随系统,但背景和输入框没有跟随。
解决:把所有颜色字面量改成 @color/xxx,在 values 和 values-night 各放一套。输入框底色用 colorSurface,深色模式下自动变成深灰色。对于渐变背景,如果两端颜色在深色下变化不大,可以单独准备一套深色渐变 drawable,在 values-night 里用同名资源覆盖。检查颜色有没有改干净,最简单的方法是把布局文件里搜一遍 “#”。
5.3 点击登录没反应
现象:按钮没有点击视觉反馈,点击后也不触发任何校验,甚至挂上调试器也没走进按钮的点击回调。
原因:最常见的是按钮上方盖了一个透明控件,比如带背景的 ConstraintLayout 或者某个 TextView 的尺寸超出预期,把点击事件吞了。第二种是 MaterialButton 在 XML 里同时设置了 android:background 和 app:backgroundTint,内部绘制层把点击事件以外的视觉层盖住,看起来像是没反应。第三种是按钮的 enabled 被初始化为 false,但没有在所有输入校验通过后重新置为 true。
解决:先打开布局边界调试,看按钮实际可点击区域是否被遮挡;然后在点击回调里打日志确认;最后检查 enabled 初始值。如果问题是 backgroundTint,去掉 app:backgroundTint,把颜色交给选择器管理。这个坑我在模拟项目X里踩过,排查半小时最后发现是布局里一个 1dp 高度的横线拽住了点击区域。
5.4 圆角按钮白边
现象:按钮和输入框在浅色背景下有小一圈的白边,换成深色背景时白边更明显。
原因:selector 里的 shape 背景默认没有填充色,系统给出白色兜底,按钮的 elevation overlay 或父容器背景透过来也容易形成边缘高光。如果 shape 里只写了圆角没写 solid,就会默认白色填充。
解决:在 shape 的每个 state 里显式指定 solid 颜色,不要依赖默认填充;按钮设置 android:elevation=“0dp” 或 stateListAnimator=“@null”,去掉控件自身的高光层;输入框同理,OutlinedBox 的底色需要显式覆盖。特别注意浅色和深色两套主题都要看,白边在深色背景上通常是浅色边框。
5.5 验证码按钮和输入框重叠
现象:注册页的验证码按钮在部分机型上盖住输入框一半,或者输入框文字被按钮遮住。
原因:输入框用了 match_parent 或者约束到父容器左右,验证码按钮又放在输入框的下层或同一坐标区域。开发时只针对主流尺寸调好,换到大屏或字体缩放后就暴露出布局比例问题。
解决:把验证码输入框和“获取验证码”按钮放进同一层,用水平链或让输入框宽度为 0dp,约束到按钮的 start,按钮宽度用 wrap_content 并固定最小宽度。不要把验证码按钮放在输入框下层,也不要给按钮设固定大宽。最后在开发者选项里把最小宽度调小一档,再走一遍注册流程,基本能灭掉这类重叠。
6. 再进一步:把登录界面做成可配置的主题组件
6.1 用 style 把配色和形状抽成主题属性
当项目里有多套不同入口都要共用登录注册页时,直接在布局里写颜色和圆角就不够了。我会把主色、按钮圆角、输入框圆角、错误色全部提取到 attrs.xml,再在 themes.xml 里给默认值。布局文件则用?attr/loginPrimaryColor这类写法引用。这样换肤时只需要改主题,不需要动四个布局 xml。
6.2 动态切换品牌色而不重建 Activity
登录页要支持运营配置品牌色,常见做法是拿到配置后调 MaterialButton.setBackgroundTintList 和 TextInputLayout.setBoxStrokeColor。不要遍历所有控件,而是把可变化的控件 id 放进一个列表,统一刷新。注意切换时机要避开输入框 focus 的动画窗口,否则会出现颜色闪一下又弹回去的视觉 bug。
6.3 回归验证:用截图对比和状态机测一遍
登录注册是状态密集页面,每次改样式后我会列一个最小回归清单:输入正确账号密码,错误提示消失;输入错误手机号,错误提示出现;切换记住我,重开页面回填;切换深色模式,按钮和输入框颜色可读;开启系统大字号,验证码按钮不遮挡。用截图工具拿到三个尺寸设备的对比图,人工扫一眼主色、错误色和圆角三处即可。
之前给模拟项目X做主题换肤时,我改了按钮的 shape 圆角,结果只看真机没看深色图,提交后用户在深色模式下看见按钮边缘多了一条浅色描边。从那以后我的习惯是先改主题资源、再跑五张截图、最后才动业务代码。登录页的表皮和内核同样重要,能让你少挨几次骂。希望这篇能帮到正被登录注册界面折磨的 Android 开发者,省下几晚调试时间,把你自己的漂亮界面安全送到用户眼前。
本文还有配套的精品资源,点击获取