ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android页面架构实战:登录跳转、底部导航与Fragment切换

2026/9/10 16:16:45 拓冰建站 浏览量
Android页面架构实战:登录跳转、底部导航与Fragment切换 简介一套完整的Android小应用源代码工程适合刚入门Android开发或需要参考界面交互实现的初学者。工程覆盖登录注册界面利用TextView搭建底部导航栏并配合4个Fragment完成页面切换还包含走马灯、滚动图片等常见UI功能能帮助理解基础Android项目结构。压缩包为rar格式整体约89.48MB共689个文件其中xml负责界面布局与资源描述java承载核心逻辑png/jpg为图片素材json及gradle等为工程配置与构建脚本也附带apk和dex/class编译产物可直接安装运行或二次修改。目前已有427人学习下载。解压导入后即可梳理从登录跳转到Fragment切换的完整流程也可借鉴走马灯与图片滚动的实现方式适合作为课程设计、毕业设计或日常练手的参考工程。1. 从登录到主界面一个 Android 小项目的页面骨架与跳转设计实际开发中我拿到一个 Android 小项目的源码第一眼做的不是跑起来看效果而是先看它的 Activity 和 Fragment 组织方式。这个 teacher 项目的源码就是很典型的小 app 结构入口是登录页注册页藏在登录页右侧认证通过后进入一个由底部导航承载的主界面主界面里放着 4 个 Fragment每个 Fragment 自己管自己的布局和数据请求。它没有引入 DataBinding也没有用 Navigation 组件完全靠 Activity FragmentTransaction TextView 的 selector 状态来驱动页面切换。这种写法在老项目里很常见也是理解 Android 页面体系最适合的范本。下面这几个模块我会从登录校验到 Fragment 懒加载一个个拆开讲。2. 登录注册界面输入校验与登录态传递的细节2.1 登录页的输入校验为什么不能只写 isNotEmpty登录界面是第一个入口也是我调试最多的页面。最常见的坑是各个输入框只判断了空字符串却没有把 trim、长度、格式这些边界考虑进去。这个 teacher 项目里的登录页用户名和密码来自两个 EditText如果直接拿来比对很容易出现在前后有空格时误判的情况。我一般会先写一个工具方法把输入统一做一次清理再去做校验// AuthValidator.java 常见做法统一处理输入校验 public class AuthValidator { public static boolean isValidPhone(String phone) { if (phone null || phone.length() ! 11) return false; // 简单的手机号格式校验不区分具体运营商 return phone.matches(^1[3-9]\\d{9}$); } public static boolean isValidPassword(String pwd) { // 6-20位必须包含字母和数字 return pwd ! null pwd.matches(^(?.*[A-Za-z])(?.*\\d)[A-Za-z0-9]{6,20}$); } }逻辑说明这里的matches用的是正则表达式密码规则是 6 到 20 位必须同时包含字母和数字避免用户只输一串数字就提交。isValidPhone只约束了 11 位以 1 开头的手机号格式并没有做真正的号段判断因为本地校验通常只用来拦住明显的误输入真正的有效性要等服务端接口返回。参数说明phone.matches返回布尔值^1[3-9]\\d{9}$中[3-9]表示第二位是 3 到 9\\d{9}表示后面还有 9 位数字。正则里的^和$保证整个字符串完全匹配不会只匹配中间一段。然后把校验接到登录按钮上登录成功后把用户信息写入 SharedPreferences再跳转主界面。这里有一个容易踩的坑不要在监听器里直接startActivity要先判断当前是不是正在登录用一个布尔值挡住重复点击。我通常会给按钮加isClickable false等接口回调成功或失败后再恢复这样比单纯在 onClick 里判断时间戳更稳。2.2 注册页字段与登录页的状态传递注册页几乎和登录页共用同一套控件模板用户名、手机号、密码、确认密码再加一个注册按钮。这个项目里的注册页是从登录页右上角通过Intent跳转过去的注册完成后并没有直接把数据返回给登录页而是让用户再回到登录输入。等接口做好了我一般会在setResult(RESULT_OK, intent)里把注册手机号带回登录页省掉重复输入。// LoginActivity 中等待注册页面返回账号 private void openRegisterPage() { Intent intent new Intent(this, RegisterActivity.class); startActivityForResult(intent, REQUEST_REGISTER); } Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode REQUEST_REGISTER resultCode RESULT_OK data ! null) { String registeredAccount data.getStringExtra(phone); // 把注册成功的手机号回填到登录输入框 if (registeredAccount ! null) { etPhone.setText(registeredAccount); etPhone.setSelection(registeredAccount.length()); } } }逻辑说明startActivityForResult是页面间带回传值的传统做法在 Activity 被销毁重建时结果可能丢失但在这个小项目里足够用。setSelection让光标停在输入内容末尾方便用户直接改密码这个小细节能避免「为什么光标在开头」的困惑。参数说明REQUEST_REGISTER是自己定义的 int 常量用来区分多个等待返回的请求RESULT_OK是系统预设值。如果注册页用的是finish()而不是setResult这里拿到的data会是 null所以判断里不能省掉对data ! null的检查。注册页的密码和确认密码是否一致建议放在注册按钮点击时校验而不是用TextWatcher每个字符都监听。原因很简单密码长度没输入完时一致性判断没有意义反而会频繁弹出错误提示。只在提交前做一次校验配合输入框背景色变红提示用户体验更直接。2.3 登录接口失败时的兜底处理本地校验通过后真实登录要交给后端接口。这个项目里没有放服务端代码所以在doLogin里我一般会模拟一段延迟把登录态保存在 SharedPreferences 中这样即使用户杀掉进程下一次启动也能直接进主界面。常见做法是写一个SessionManager单例把 token 和时间戳一起存进去而不是只存一个isLogin true。// SessionManager.java 登录态管理 public class SessionManager { private static final String PREF_NAME teacher_session; private static final String KEY_TOKEN token; private static final String KEY_ACCOUNT account; public static void saveLogin(Context context, String account, String token) { // 使用 apply 是异步落盘不会卡主线程 context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) .edit() .putString(KEY_ACCOUNT, account) .putString(KEY_TOKEN, token) .apply(); } public static boolean isLoggedIn(Context context) { String token context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) .getString(KEY_TOKEN, null); // 只做字符串判断不在这里解析过期时间 return token ! null !token.isEmpty(); } }逻辑说明这里把 token 和 account 分开存因为后续很多接口请求都要带 token。以后如果换成EncryptedSharedPreferences只需要改这里不需要动所有页面。.apply()是异步写入不会卡主线程如果你一定要拿到写入结果才改用.commit()。参数说明Context.MODE_PRIVATE表示只有当前应用可以读这个文件安全性比MODE_WORLD_READABLE好很多。KEY_ACCOUNT用来在个人中心显示当前登录账号避免每次都从 EditText 拿。当登录失败时我们还需要清理掉可能残留的 token否则会出现「账号退出后Fragment 还能拿到上一份登录数据」的问题。退出登录的clearLogin方法如果写了就顺手把已保存的 Key 全部移除。一般我会在登录成功的回调里只做三件事保存 token、跳转主界面、把登录页自身的finish()放在startActivity之后否则返回键会回到登录页这对体验来说是明显 bug。3. 用 TextView 搭底部导航Selector 与 Fragment 容器3.1 为什么不用 BottomNavigationView而用 TextView看到摘要里写「通过 textview 设置底部导航栏」有人会觉得是业余做法。但在维护老项目或者做系统级嵌入式 Android 时TextView 底部导航反而有优势不需要引入 Material 库布局文件里能看到所有文本和颜色点击状态完全由 selector 控制代码量更少。所以这个 teacher 项目选择 TextView 做底部导航并不是水平不行而是刻意控制依赖。新手从这个方案入手比直接套 BottomNavigationView 更能理解 Tab 切换的本质。底部导航条一般放在主界面底部用LinearLayout水平排列四个 TextView。每一个 TextView 需要设置一个背景 selector里面定义选中和未选中两种状态的颜色或图标。这种 selector 叫StateListDrawable匹配顺序是从上到下所以state_selectedtrue要写在前面不然会被默认项吃掉。!-- nav_item_selector.xml -- selector xmlns:androidhttp://schemas.android.com/apk/res/android !-- 选中时使用主题主色 -- item android:state_selectedtrue shape solid android:color#2B6CB0 / /shape /item !-- 未选中时使用浅灰形状圆角一致 -- item shape solid android:color#EDF2F7 / /shape /item /selector逻辑说明这个 selector 是给 TextView 做背景的不是给字体上色。state_selected可以配合setSelected(true)使用。TextView 默认没有选中状态所以要在代码里触发背景切换。如果你用的是android:state_checked那就得把控件变成CheckableTextView两套写法都能跑但state_selected在中文资料里语义更清晰也不容易跟复选框混淆。参数说明solid是形状填充色要圆角的话需要在 shape 里加corners android:radius8dp/。如果有图标需求可以换成item android:state_selectedtrue android:drawabledrawable/ic_tab_selected/但要注意 selector 作为背景时的显示区域。字体颜色也可以用类似 selectorandroid:textColorcolor/tab_text_color。3.2 主界面容器与四个 Fragment 的切换主界面通常是MainActivity里面放一个FrameLayout作为 Fragment 容器下面放底部导航条。点击 TextView 时通过FragmentManager的beginTransaction()做切换。这里要注意replace()会在每次切换时销毁并重建 Fragment如果四个 Fragment 都有网络请求就会频繁触发加载。我一般用show/hide方式维护让 Fragment 实例常驻// MainActivity.java 底部导航切换 Fragment private final FragmentManager fm getSupportFragmentManager(); private Fragment currentFragment null; private final MapInteger, Fragment tabFragments new HashMap(); private void switchTab(int itemId, Fragment fragment) { FragmentTransaction tx fm.beginTransaction(); // 隐藏当前 Fragment再显示目标 Fragment if (currentFragment ! null) { tx.hide(currentFragment); } Fragment target tabFragments.get(itemId); if (target null) { target fragment; tabFragments.put(itemId, target); tx.add(R.id.fragment_container, target); } else { tx.show(target); } currentFragment target; tx.commit(); }逻辑说明FragmentTransaction是一组操作的集合commit()不会立刻执行而是要等主线程空闲后执行。所以调用switchTab后不要马上findFragmentById去拿新 Fragment因为这时候可能还没 attach。tabFragments的 key 用R.id.tab_home这类资源 id这样switchTab可以根据点击的 View id 定位到目标 Fragment避免 switch-case 写一长串。参数说明tx.hide只是把 Fragment 的视图设成GONE并没有销毁它。tx.add要传入容器 idR.id.fragment_container如果同一个 Fragment 之前已经 add 过重复 add 会崩溃所以 map 里要先判断。R.id.fragment_container就是布局里的 FrameLayout。下面是底部导航对应的 Fragment 映射实际项目里只需要按需改类名底部 TabFragment 类容器 itemId业务说明首页HomeFragmentR.id.tab_home走马灯通知 图片轮播课程CourseFragmentR.id.tab_course课程列表展示作业WorkFragmentR.id.tab_work作业待办清单我的MineFragmentR.id.tab_mine登录账号与设置然后给每个 TextView 设置点击监听private void bindBottomNav() { setTabListener(R.id.tab_home, new HomeFragment()); setTabListener(R.id.tab_course, new CourseFragment()); setTabListener(R.id.tab_work, new WorkFragment()); setTabListener(R.id.tab_mine, new MineFragment()); } private void setTabListener(int itemId, Fragment fragment) { findViewById(itemId).setOnClickListener(v - { // 更新所有 TextView 的选中状态再切换 Fragment updateTabSelection(itemId); switchTab(itemId, fragment); }); }这里的updateTabSelection需要遍历底部容器里的所有子 View把不是当前 itemId 的 TextView 都设回未选中当前 itemId 设成选中。这个逻辑写在线性布局里很直观如果用BottomNavigationView反而要把 MenuItem 拆出来。3.3 按返回键时的返回栈处理使用show/hide方式时默认没有返回栈用户按返回键会直接退出 app而不是回到上一个 tab。我一般会在MainActivity里重写onBackPressed连续按两次才退出第一次用 Toast 提示。这样能避免用户在多个 tab 之间切换后按一下返回键整个应用就没了。// MainActivity.java 返回键二次确认 private long lastBackPressedTime 0; Override public void onBackPressed() { if (System.currentTimeMillis() - lastBackPressedTime 2000) { super.onBackPressed(); return; } lastBackPressedTime System.currentTimeMillis(); Toast.makeText(this, 再按一次退出, Toast.LENGTH_SHORT).show(); }逻辑说明onBackPressed不是 fragment 的默认行为所以如果某一个子页面需要拦截返回键要在 Fragment 里手动调用方法或者交给 Activity 分发。这里的lastBackPressedTime用时间戳差值限制两次点击的间隔比用计数更简洁因为计数还要处理时间差的判断。2000 毫秒是一个比较自然的间隔太长会显得反应迟钝太短用户容易误触。如果你希望在切换 tab 时记录页面栈可以换成addToBackStack(null)配合replace但那样就回到了 Fragment 重建的老路。小项目里保持单 Activity 无栈 Fragment后面切深色模式或者改横竖屏时能少一半的坑。4. 走马灯与滚动图片TextView Marquee 和 ViewPager 轮播4.1 TextView 走马灯setSelected 才是触发条件TextView 自带跑马灯效果但很多人设置android:ellipsizemarquee后发现没有滚动。原因是一个 TextView 要有焦点才能跑马灯如果在非焦点容器里需要主动让它获取焦点。我一般不在 XML 里写focusableInTouchMode而是在代码里调用textView.setSelected(true)这样既不会抢走页面其他输入框的焦点也能触发滚动。布局文件里保留这几个属性TextView android:idid/tv_marquee android:layout_widthmatch_parent android:layout_heightwrap_content android:singleLinetrue android:ellipsizemarquee android:focusabletrue android:focusableInTouchModetrue android:marqueeRepeatLimitmarquee_forever /然后在代码里这样启动// HomeFragment 或 MainActivity 中启动走马灯 TextView tvMarquee findViewById(R.id.tv_marquee); tvMarquee.setText(teacher 项目通知本周作业截止日期已更新); tvMarquee.setSelected(true);参数说明singleLine必须设为 true多行文本无法跑马灯。marqueeRepeatLimit设为marquee_forever表示无限循环也可以设成 3 表示只滚三次。setSelected(true)会在对应 TextView 上持续触发 onDraw 的滚动偏移计算。缺陷是页面中如果有多个跑马灯 TextView或者 Fragment 切到后台后没有重新触发 selected滚动会停在最后状态需要重写onWindowVisibilityChanged再次调用setSelected。4.2 图片轮播ViewPager 循环滚动的两种写法滚动图片在首页 Fragment 里最常见的实现是 ViewPager 加一个无限循环 Adapter或者用 LinearLayout HorizontalScrollView 做横向滚动。小项目里用 ViewPager 结构更清晰但要处理循环切换时首尾的衔接。下面是一个精简的 PagerAdapter用取模实现简单的无限轮播// ImagePagerAdapter.java 通常作为 HomeFragment 的内部类 class SlideAdapter extends PagerAdapter { private final ListInteger items; SlideAdapter(ListInteger items) { this.items items; } Override public int getCount() { // 比真实数量多 1 个用于实现往右的伪循环 return items.size() 1; } Override public boolean isViewFromObject(NonNull View view, NonNull Object object) { return view object; } NonNull Override public Object instantiateItem(NonNull ViewGroup container, int position) { int realPosition position % items.size(); ImageView image new ImageView(container.getContext()); image.setImageResource(items.get(realPosition)); image.setScaleType(ImageView.ScaleType.CENTER_CROP); container.addView(image); return image; } Override public void destroyItem(NonNull ViewGroup container, int position, NonNull Object object) { container.removeView((View) object); } }逻辑说明getCount返回items.size() 1让用户在翻到最后一页之后还能继续向右instantiateItem里用取模运算把越界位置映射回真实图片。这个方案只做单向循环自动轮播时没问题手动左滑到第一页再往左会滑不动。真要做双向循环得把getCount设成一个很大的值比如Integer.MAX_VALUE初始currentItem设到中间。参数说明position % items.size()是关键当 position 等于items.size()时会取到 0 号图片。CENTER_CROP会缩放图片并居中裁剪适合固定尺寸的轮播 banner。拿到 List 之后最好在 ViewPager 上设置setOffscreenPageLimit(2)让左右相邻页面预先创建减少滑动卡顿。如果你不想处理循环细节可以回到 HorizontalScrollView让图片在一个横向滚动的容器里依次排列不需要循环停止位置不自动居中实现更简单但滑动体验差一些。具体选哪种取决于首页 banner 是固定几个入口还是真正需要轮播的运营位。4.3 自动轮播的 Handler 与页面生命周期定时轮播一般放在 Fragment 的onResume里启动onPause里停止否则后台时它还在发任务等回到页面时会连续跳好几张。我通常用Handler Runnable而不是 Timer因为 Handler 可以精确贴合 UI 更新时机也方便在销毁时移除。Override public void onResume() { super.onResume(); autoPlayHandler.postDelayed(playRunnable, 3000); } Override public void onPause() { super.onPause(); autoPlayHandler.removeCallbacksAndMessages(null); } private final Runnable playRunnable new Runnable() { Override public void run() { ViewPager pager getView().findViewById(R.id.pager); int current pager.getCurrentItem(); pager.setCurrentItem(current 1, true); autoPlayHandler.postDelayed(this, 3000); } };逻辑说明postDelayed(this, 3000)让 Runnable 执行后再次把自己排到队列末尾形成循环。removeCallbacksAndMessages(null)会把该 handler 下的所有消息都清空防止页面不可见后还更新 View 导致泄漏。setCurrentItem(current 1, true)的第二个参数 true 表示带滑动动画如果你用 false图片会瞬间切换用户看不出滚动效果。参数说明3000是轮播间隔毫秒数实际项目里可以做成常量。如果 Fragment 被隐藏但没有走onPause你可以把停止逻辑放在getUserVisibleHint()回调里配合onHiddenChanged一起处理。ViewPager 会自动把退出的页面缓存不会崩但要注意 handler 持有 Fragment 引用可能造成泄漏。Fragment 销毁前要记得移除回调。5. 进阶登录态与 Fragment 懒加载的边界处理5.1 用 BaseFragment 封装懒加载当首页 Fragment 里有轮播图和接口请求时进入主界面那一刻就立即启动数据加载会拖慢启动速度。常见做法是做一个LazyFragment在 Fragment 第一次对用户可见时才加载数据而不是在onCreateView里无脑请求。这里关键是处理setUserVisibleHint和onResume的组合public abstract class LazyFragment extends Fragment { private boolean isFirstVisible true; private boolean isFragmentVisible() { return getUserVisibleHint() isResumed(); } Override public void onResume() { super.onResume(); if (isFirstVisible isFragmentVisible()) { onLazyLoad(); isFirstVisible false; } } protected abstract void onLazyLoad(); }逻辑说明isResumed()确保 Fragment 已经完成 onResume否则 View 可能还在创建中。getUserVisibleHint()在 ViewPager 里可以判断当前页是否可见但在嵌套 Fragment 或部分场景下会有兼容问题。如果项目用的是 ViewPager2建议改用isMenuVisible或者监听翻页回调老 Handler 方案在这里会变慢。isFirstVisible保证只在第一次可见时加载避免每次回前台都重新请求。5.2 登录态失效时的兜底登录态的校验如果只放在MainActivity的 onCreate 里一旦 Session 过期用户只能在点某个接口时才被弹回登录页。更好的做法是在BaseFragment的 onResume 里检查一次登录态然后通过startActivityForResult跳转登录页并在返回后刷新当前页。这个 teacher 项目的登录态没有做 token 过期时间解析所以当接口返回 401 时需要调用SessionManager.clearLogin(context)并跳转回 LoginActivity同时把退出的 Fragment 从tabFragments中移除避免下次回到旧页面。另外一个实用技巧是用Fragment.setArguments()传递页面初始化参数而不是把 Activity 的引用存进 Fragment。登录页跳转主界面时可以把account放进 arguments个人中心 Fragment 再显示。这样旋转屏幕或者进程重建时数据不会丢。MainActivity 收到登录成功回调后只需执行tabFragments.clear()再恢复第一个 tab就能避免旧 Fragment 携带上一份登录数据。本文还有配套的精品资源点击获取