ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android保存QQ密码实战:SharedPreferences存储与AES加密详解

Android保存QQ密码实战:SharedPreferences存储与AES加密详解

简介:面向初学者的Android Studio实战工程,聚焦于保存与读取QQ密码这类登录凭证,主要讲解SharedPreferences轻量级数据存储机制在真实场景中的用法,适合刚接触Android数据持久化与界面事件处理的开发者。压缩包共941个文件,大小6.31MB,包含438张png图片、414个xml布局与配置、9个java源文件,以及class、jar、apk等编译产物,目录结构完整,可导入Android Studio后直接查看运行。已有2263人学习下载。通过这份工程,可以掌握SharedPreferences的读写调用、EditText与Button的界面绑定方式、简单的密码加密与解密思路,并借助附带的app-debug.apk快速验证效果,为后续实现自动登录等功能打下基础。

1. 保存QQ密码的Android实战:这个小工程为什么值得动手

保存QQ密码,乍看是个极小的工具需求,但把它当做一个完整的Android Studio工程来做,你会立刻发现自己被逼着处理三件事:账号数据存在哪、应用被杀之后怎么读回来、以及密码会不会被别人轻易翻走。这三个问题对应着数据持久化、生命周期和加密设计,也是之后做任何登录类App都绕不开的底层能力。这篇笔记面向那些能写Activity但还没独立做过存储层的开发者。我会从建工程开始,把保存、读取、加密和常见坑全部铺开,最后给出可以照着敲的Java源码与验证步骤。这套东西做完,你对本地存储的边界会有一个很具体的认识。

2. 工程落地与存储选型:SharedPreferences为什么是保存QQ密码的首选

2.1 存储方案对比:SharedPreferences、SQLite与文件方案的取舍

在Android上做本地保存,绕不开三种路径:SharedPreferences、SQLite、普通文件读写。绝大多数人第一次写保存功能时都分不清它们,后面才发现选错方案的成本远高于多写几行代码的成本。先给结论:保存QQ密码这种单条键值数据,SharedPreferences最合适。

SharedPreferences是系统提供的键值对存储,数据以XML格式落在应用私有目录。它最适合存几十KB以内的轻量数据,比如记住账号、应用设置、开关状态。它读写API足够简单,两行代码就能写入,不需要建表和写SQL;而且系统自动处理了文件目录和加载时机,你不需要关心文件系统层面的并发和缓冲。

什么时候不该用它?数据量到几十上百条后,或者后续需要“查询所有账号中密码快过期的人”这种条件检索时,SharedPreferences会非常吃力。SQLite才是正路。SQLite支持建表、索引、事务,多账号密码本用它可以管理得很规矩,代价是需要自己写SQL、写升级迁移,新手容易在数据库版本升级上翻车。普通文件则适合图片、日志、导出文件这类非结构化内容,用来存密码需要自己搞定序列化和并发写入,属于没事找事。

存储方案适合场景写入性能实现复杂度
SharedPreferences少量键值对、记住账号、应用配置快(apply异步)低
SQLite多条记录、条件查询、事务中高
文件读写大文件、自定义格式、图片缓存取决于格式中

2.2 新建Android Studio工程:Gradle配置与项目结构

用Android Studio新建一个空Activity项目,IDE已经把Gradle配置和目录骨架生成好了。你只需要关注三个核心配置项。minSdk决定最低支持到哪个Android版本,我习惯设21(Android 5.0),覆盖存量设备的同时不需要处理古董版本兼容。targetSdk代表针对哪个系统版本做了适配,新版系统对明文流量、分区存储等有更严的约束,targetSdk越高,你的代码越早接触这些约束,这是好事。

plugins { id 'com.android.application' } android { namespace 'com.example.qqpasswordstore' // R类与BuildConfig的包名 compileSdk 34 defaultConfig { applicationId "com.example.qqpasswordstore" // 安装到设备上的唯一标识 minSdk 21 targetSdk 34 versionCode 1 versionName "1.0" } } dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' }

这里重点解释三个容易混淆的点。第一,namespace和applicationId可以不一样,但为了少踩坑,最好保持一致,否则R类路径用的是namespace,第三方SDK拿applicationId标识应用,两边不一致有时会出妖蛾子。第二,compileSdk 34表示用较新的API来编译,你的测试设备版本低于34也能编译,compileSdk跟运行版本没有直接关系。第三,appcompat必须保留,因为MainActivity继承的AppCompatActivity在androidx.appcompat包里,去掉它Activity会报ClassNotFoundException。

目录上只需要记住几个位置:Java源码在app/src/main/java/包名路径,布局在app/src/main/res/layout/,应用图标在mipmap系列,文案在values/strings.xml。刚入门的人经常在Android视图下找不到文件在磁盘哪,切到Project视图就能看到完整物理路径。

2.3 登录界面布局:控件id命名约定与密码输入属性

界面做成最常用的两行输入加复选框加按钮。账号输入框在上面,密码输入框在下面,中间一个“记住密码”的CheckBox,底部一个“保存”按钮。整个布局用LinearLayout垂直排列,嵌套少,渲染快。

<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="24dp"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="QQ账号" /> <EditText android:id="@+id/et_qq_account" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="请输入QQ账号" android:inputType="text" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="密码" /> <EditText android:id="@+id/et_qq_password" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="请输入密码" android:inputType="textPassword" /> <CheckBox android:id="@+id/cb_remember_password" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="记住密码" /> <Button android:id="@+id/btn_save" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="保存" /> </LinearLayout>

id命名我坚持一套约定:控件缩写加下划线加业务名。et_开头的EditText,cb_开头的CheckBox,btn_开头的Button。没有这套约定,Activity里同时有账号、密码、邮箱三个输入框时,findViewById那一段代码会混乱到无法阅读。再提两个属性:inputType="textPassword"会把输入内容显示成圆点,这是密码框的标配;android:hint是输入框未输入时显示的灰色提示文字,既能让用户明白该填什么,又不会占用正文空间。

工程骨架搭好了,存储方案定了,下面进入实际的保存逻辑。

3. 核心实现:把QQ密码写进SharedPreferences的完整代码

3.1 封装PasswordStore工具类:读写集中在一处

很多教程代码把SharedPreferences直接写在Activity里,一个方法里读,一个方法里写,看着能跑,后面加个加密换存储,所有调用点都要跟着改。我习惯的做法是先把数据访问封装成一个PasswordStore类,Activity只负责调save和get,至于数据写到哪个文件、是明文还是密文,Activity完全不关心。

public class PasswordStore { private static final String PREF_NAME = "qq_password_store"; private static final String KEY_ACCOUNT = "account"; private static final String KEY_PASSWORD = "password"; private final SharedPreferences prefs; public PasswordStore(Context context) { // MODE_PRIVATE:仅当前应用可读写该文件 prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); } public void save(String account, String password) { prefs.edit() .putString(KEY_ACCOUNT, account) .putString(KEY_PASSWORD, password) .apply(); } public String getAccount() { return prefs.getString(KEY_ACCOUNT, ""); } public String getPassword() { return prefs.getString(KEY_PASSWORD, ""); } public void clear() { prefs.edit().clear().apply(); } }

构造函数里的Context是所有系统服务访问的入口,getSharedPreferences的第一个参数是文件名,第二个参数是文件权限位。MODE_PRIVATE表示只有本应用能读写,这是Android默认也最常用的文件模式。save方法里prefs.edit()返回一个Editor对象,两个putString分别写入账号与密码,最后由apply()一次提交。这里两个putString合在一次提交里,比分两次edit().putString再apply()少一次磁盘回写。

getString的第二个参数是默认值,文件里没有对应键时返回空字符串而不是null。这个细节一开始不值钱,一旦你在Activity里写if (password != null)这种判断,就会陷入一堆空判断里。我的习惯是所有get方法都返回合理的默认值,调用方只需要判断isEmpty,代码会干净很多。

3.2 Activity里把按钮与CheckBox串起来

工具类写好了,把它接进MainActivity。MainActivity干三件事:findViewById拿控件引用、给保存按钮设置点击监听、在监听里判断CheckBox是否勾选再决定是否保存。

public class MainActivity extends AppCompatActivity { private EditText etAccount; private EditText etPassword; private CheckBox cbRemember; private PasswordStore store; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_login); etAccount = findViewById(R.id.et_qq_account); etPassword = findViewById(R.id.et_qq_password); cbRemember = findViewById(R.id.cb_remember_password); store = new PasswordStore(this); findViewById(R.id.btn_save).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { String account = etAccount.getText().toString().trim(); String password = etPassword.getText().toString(); if (account.isEmpty() || password.isEmpty()) { Toast.makeText(MainActivity.this, "账号和密码不能为空", Toast.LENGTH_SHORT).show(); return; } if (cbRemember.isChecked()) { store.save(account, password); Toast.makeText(MainActivity.this, "已记住密码", Toast.LENGTH_SHORT).show(); } else { store.save("", ""); Toast.makeText(MainActivity.this, "未勾选记住,不保存", Toast.LENGTH_SHORT).show(); } } }); } }

两个细节值得单独讲。第一个是account用trim()去掉首尾空格,防止复制账号时带进奇怪的空格导致登录失败;password不能trim,因为密码本身可以以空格开头或结尾,去掉就破坏了密码原文。第二个是没勾选“记住密码”时,我调用store.save("", "")而不是什么都不做。为什么?用户上一轮可能勾选过并保存了密码,这次取消勾选意味着他想把历史记录清掉。主动写空串等于覆盖旧数据,如果什么都不做,旧密码会一直躺在文件里。

3.3 apply与commit:保存密码该选哪个

SharedPreferences提交有apply和commit两个方法,很多人随手用一个没想过区别。commit是同步写盘,调用后当前线程阻塞直到数据写完,返回boolean告诉你是否成功;apply是异步写盘,先立刻更新内存里的数据,再排队往磁盘写,方法本身没有返回值。

按这个区别去选:如果保存密码后,你的App立刻要执行“杀掉进程”或“退出登录并依赖已保存结果”这样的操作,commit更稳妥。但日常点保存按钮后就继续操作界面,apply完全够用,而且不会卡住UI。我在线上见过一个日志应用,主线程里每次写配置都调commit,数据量一大,保存时界面明显一顿一顿的。PasswordStore的save里我用的是apply,想换成commit只需要替换提交那一行,API签名一致。

4. 读取回填与加密:让保存的密码在下次打开时直接可用

4.1 启动自动填充:onCreate里读回账号密码并回填

能存进去,还得能读回来。在MainActivity的onCreate里,setContentView与findViewById之后追加上回填逻辑,让用户下次打开App时直接看到上次保存的账号密码。

// 在 onCreate 中 findViewById 和 PasswordStore 初始化之后添加 String savedAccount = store.getAccount(); if (!savedAccount.isEmpty()) { etAccount.setText(savedAccount); etPassword.setText(store.getPassword()); cbRemember.setChecked(true); }

这段逻辑用savedAccount的isEmpty做判断,因为账号和密码是成对保存的,账号没保存则密码一定也没有。setText将保存的字符串填充进EditText,setChecked(true)把复选框的状态也恢复成勾选,让界面与本地数据一致。注意回填只应写在onCreate里,不要写进onResume。背景切换时onResume也会执行,如果用户在编辑界面时切到后台再回来,onResume里的setText会把用户刚输入但还没保存的内容覆盖回旧值,这就是典型的生命周期使用不当。

4.2 给密码加一层AES加密:代码与参数边界

SharedPreferences本身不加密,生成的XML文件在/data/data/包名/shared_prefs/目录下。普通App访问不了别的应用目录,但root过的设备、调试时的adb run-as命令都可以直接读文件内容。只教存不教加密,等于给用户埋雷。我一般只对密码字段加密,账号保持明文,便于日后的账号检索。

public class CryptoUtil { // 硬编码密钥仅用于教学演示,真实工程要用 Android Keystore 管理密钥 private static final String SECRET_KEY = "0123456789abcdef"; private static final String ALGORITHM = "AES"; public static String encrypt(String content) { try { SecretKeySpec keySpec = new SecretKeySpec( SECRET_KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM); Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); cipher.init(Cipher.ENCRYPT_MODE, keySpec); byte[] bytes = cipher.doFinal(content.getBytes(StandardCharsets.UTF_8)); return Base64.encodeToString(bytes, Base64.NO_WRAP); } catch (Exception e) { return content; } } public static String decrypt(String content) { try { SecretKeySpec keySpec = new SecretKeySpec( SECRET_KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM); Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); cipher.init(Cipher.DECRYPT_MODE, keySpec); byte[] bytes = cipher.doFinal(Base64.decode(content, Base64.NO_WRAP)); return new String(bytes, StandardCharsets.UTF_8); } catch (Exception e) { return content; } } }

几个参数说明一下。SECRET_KEY是16字节的AES-128密钥,长度必须是16字节,多一个少一个都初始化失败。ECB模式是最基础的AES分组模式,同样的明文产生同样的密文,安全性不如CBC/GCM,但代码最简单,适合教学。PKCS5Padding是填充方案,让任意长度的输入都能对齐16字节分组。Base64.NO_WRAP的细心点在于:默认的Base64输出会带换行符,换行符一旦进到XML里,读取时密文串里就多了不可见字符,AES解密必然失败。

接入PasswordStore很简单:save里把password字段改成CryptoUtil.encrypt(password),getPassword里先取出密文再包一层decrypt。注意一定要在保存和读取两侧同时改,只改保存不改读取,下次打开就看到一堆解密失败的乱码。

4.3 多账号场景:一个键放JSON还是换SQLite

只存一个账号的demo可以练手,但很多人的真实需求是管理多个QQ号。SharedPreferences一个键对应一个值,天然不适合多账号。我的做法是把多套账号序列化成JSON数组,整体存进一个键。Android自带org.json包,不需要引第三方库。

public void saveAll(List<QQAccount> accounts) { JSONArray array = new JSONArray(); try { for (QQAccount acct : accounts) { JSONObject obj = new JSONObject(); obj.put("account", acct.account); obj.put("password", CryptoUtil.encrypt(acct.password)); array.put(obj); } prefs.edit().putString("accounts", array.toString()).apply(); } catch (JSONException e) { // 序列化失败时保留原数据,避免覆盖已有记录 Log.e("PasswordStore", "saveAll failed", e); } }

JSONArray.toString()会把整个列表压缩成一行JSON字符串,好处是读写仍然走同一个键,不需要改表结构,新增一个账号也只是重建JSON数组。坏处也很明显:每改一个账号就要整份重写,数据量到几百条后性能会退化。如果做的是严肃多账号密码管理器,建议直接迁到SQLite,建一张account表,字段至少包含id、account、password、updated_at。JSON方案适合工具类App和教学工程,SQLite适合产品化,这个边界要记清楚。

5. 密码保存避坑手册:最容易翻车的5个现场

5.1 保存成功,重启App却读不到数据

现象:点保存时Toast提示“已记住密码”,杀掉App进程重新打开,输入框还是空的。很多人第一反应是apply没写完,改成commit后问题依旧。

原因:数据没丢,丢的是读写文件的名称不一致。最常见的组合是读取时用getPreferences(MODE_PRIVATE)而写入时用getSharedPreferences("qq_password_store", MODE_PRIVATE)。getPreferences不传文件名,Android会自动拿当前Activity的类名当文件名,也就是MainActivity.xml;getSharedPreferences则使用你指定的qq_password_store.xml。保存写进A文件,读取从B文件读,自然取不到。

解决:整个App统一使用PREF_NAME,并且保证所有读写都经过PasswordStore类,不让别的类直接碰这个文件。验证方法:保存后执行adb run-as 包名 cat shared_prefs/qq_password_store.xml,看看文件路径和文件名是否与读取时一致;也可以加日志把SharedPreferences底层对应的File路径打出来,直接对比两个文件路径是否相同。

5.2 中文密码乱码,或解密直接抛异常

现象:密码里有中文或emoji时,保存后再读出来不是原文,加密场景下一直解密失败。

原因:getBytes()没带charset参数时使用系统默认编码。多数Android设备默认UTF-8,但部分定制ROM或旧版本默认编码不同。AES加密与解密依赖同样的字节序列,两边编码不一致,出来的结果就对不上。

解决:全链路用StandardCharsets.UTF_8,getBytes和new String两个方向都写死。同时检查Base64.NO_WRAP是否用了,若密文里混入换行或空格,解密时doFinal会因数据长度不符而直接抛异常。

5.3 明文密码藏在xml里被调试工具翻出来

现象:Android Studio的Device Explorer或adb run-as命令,直接打开shared_prefs目录下的xml文件,明文账号密码一览无余。

原因:MODE_PRIVATE只是Linux文件权限的读写限制,挡住的是其他普通应用,挡不住调试环境、root设备和备份工具。数据一旦以明文落盘,私密性就掌握在终端设备的使用者手上。

解决:第一层用AES加密密码字段再putString;第二层在AndroidManifest.xml的application节点上设android:allowBackup="false",避免密码被系统云备份迁移到其他设备;第三层发布release包时保持debuggable为false,别把测试期开着debug的配置带上线。

5.4 新旧版本数据格式冲突,加密上线后旧数据全读不出

现象:某版本上线后,老用户反馈所有已保存的账号都读不出密码。检查发现旧版本存的是明文,新版本读取时直接调decrypt方法,AES解密旧明文必然失败。

原因:同一个KEY_PASSWORD在旧版存明文、新版存密文,代码却只用新逻辑去读。版本迭代时存储格式升级,这是最容易被忽略的兼容性问题。

解决:读取时做版本兼容判断。常见做法是写入密文时加统一前缀,比如"ENC:",读取时判断该前缀是否存在,存在才走decrypt,否则按旧逻辑返回原串;另一个做法是换一个全新的键名存新格式数据,让新旧数据彻底隔离。不管选哪种,升级存储格式时都要保留旧格式的读取路径,这是存储层开发的基本原则。

5.5 release包被反编译,硬编码的加密密钥直接暴露

现象:打好的release包被别人反编译,DEX文件里明晃晃躺着CryptoUtil里那个SECRET_KEY字符串,加密形同虚设。

原因:Java字符串常量编译后会直接出现在DEX字节码里,反编译工具能把它读出来。密钥一旦被人拿到,所有本地密文都能解回明文。

解决:教学项目可以硬编码密钥,但发布产品不要这么做。Android Keystore是系统提供的安全隔离区域,密钥生成后私钥不可导出,应用只能调用它的加密解密接口,密钥本身不会出现在DEX里。如果暂时不上Keystore,至少要把密钥拆成多段字符串在运行时拼接,提高从反编译里直接定位的难度,但这只是权宜之计。

6. 从Demo到能用的密码小工具:验证清单与两个进阶习惯

6.1 验证清单:模拟真实场景的测试步骤

功能做完不要急着收工,按下面这张清单过一遍,很多隐藏问题都是在测试阶段暴露的。

测试场景操作步骤预期结果
保存勾选记住密码,点击保存日志中能看到SharedPreferences写入,xml里是密文而非明文
回填杀掉进程重新打开App账号密码自动填充,复选框保持勾选
不保存取消勾选,点保存本地旧数据被清空,重启后输入框为空
中文密码保存含中文和emoji的密码重启后能正确解密还原
空输入账号为空直接保存Toast提示“账号和密码不能为空”,不执行写入
数据备份检查manifest里的allowBackup密码类应用建议设置为false

这六项里,中文密码和杀掉进程重开几乎每次改完存储层都能抓出问题,建议形成肌肉记忆,每次提交前都手动跑一遍。

6.2 两个进阶习惯:Keystore升级路径与密码不入库

进阶一是把CryptoUtil换成Android Keystore方案。做法是在Keystore里生成非对称密钥对,用公钥加密AES密钥,再把AES密钥存到SharedPreferences,使用时从Keystore取私钥解密出AES密钥,再用AES密钥解密码字段。这样真正敏感的私钥永远不出安全硬件,应用被杀重启后依然可以解密。这个升级只需要改PasswordStore和CryptoUtil内部实现,Activity不需要动,封装的价值在这里体现得最明显。

进阶二是改变思考方式:真正严肃的密码管理App不会把用户密码作为可逆数据直接保存,而是存密码的哈希或口令派生值,配合生物识别解锁。这个阶段不做不代表不该懂,至少要知道SharedPreferences加密只是起点,产品化路上的密码存储比Demo里复杂得多。

我自己的习惯是把存储层彻底隔离出来,独立成类,禁止Activity直接访问SharedPreferences。曾在图上省事直接把保存逻辑写在Activity里,后来加加密和多账号时改到怀疑人生。所以无论你的工程现在有多小,都值得把存储独立成类,这个习惯会在项目的下一个版本里救你。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进