Android备忘录App高分实战:从gradle.properties到FileProvider全链路解析
2026/9/2 5:14:49 网站建设 项目流程

简介:本资源是一份面向计算机及相关专业本科生的移动开发期末大作业实战项目——高分备忘录App,专为课程设计、实训练习与毕业设计前期实践打造。项目经导师指导并获98分高分评价,源码全部本地编译通过、严格调试可直接运行,配套导入文档详述Android Studio环境配置与常见问题解决方法,并附多张核心界面截图辅助理解。压缩包共67个文件,含10个Java业务逻辑文件、22个XML布局与资源定义文件、10个WebP图标资源、3个Gradle构建脚本及1个可安装APK,整体4.07MB,结构规范、模块清晰,涵盖数据持久化、RecyclerView列表管理、增删改查交互等典型Android开发要点。目前已有148人学习下载,适合移动开发初学者巩固四大组件、UI适配与MVC基础架构,亦可作为课程答辩演示或二次开发原型参考。

1. 这不是“交作业”,而是一次完整的移动开发实战复盘

备忘录App,听起来像教科书里最基础的练手项目——但如果你真把它当成“随便写写就能交差”的期末作业,那大概率会卡在 gradle.properties 配置失败、FileProvider 路径报错、Android 12+ 通知权限崩溃、甚至截图时发现界面元素错位这些细节上。我带过三届移动开发实训课,每年都有学生拿着“功能全对、UI 漂亮、文档齐全”的备忘录 App 却只拿 75 分,原因全出在那些不写进教材、但真实开发中天天打交道的“隐性工程规范”上。这次要拆解的,就是一个能稳拿 95+ 的高分备忘录 App 全流程:它不只是增删改查,而是把 Android Studio 4.2+ 环境下从项目初始化、依赖管理、权限适配、文件存储路径迁移、到最终 APK 打包签名的完整链路,全部摊开讲透。核心关键词Androidbuild.gradlegradle.properties不是配置文件名,而是你和系统对话的语法;content://com.baidu.searchbox.fileprovider/baiddpath/这类 URI 不是乱码,而是 Android 10+ 存储沙盒机制下,你必须亲手写死的契约地址。适合两类人:一是正在赶期末 deadline 的同学,需要可直接复用的结构化方案;二是刚入职的 junior 开发,想补上学校没教但公司每天都在用的工程化细节。下面所有步骤,我都用自己去年带学生做的真实项目截图(已脱敏)和 logcat 报错现场还原,不讲理论,只讲“为什么这行代码必须这么写”。

1.1 高分作业的底层逻辑:它本质是“Android 工程规范考试”

很多同学以为高分 = 功能多 + UI 好看 + 文档厚。错。真正的高分作业,本质是一份Android 工程规范执行报告。它考核的不是你会不会写onCreate(),而是你是否理解:

  • 为什么build.gradleminSdkVersion必须设为 21 而不是 16?—— 因为androidx.appcompat:appcompat1.6.1 版本已弃用 API 16 支持,强行设置会导致MaterialToolbar渲染失败,而你的截图里如果 toolbar 是空白的,老师一眼就扣分;
  • 为什么gradle.properties里必须加android.useAndroidX=trueandroid.enableJetifier=true?—— 这不是可选项,是 Android Studio 4.0+ 的强制开关,漏掉它,你引用的RecyclerView会编译通过但运行时NoClassDefFoundError,而错误日志藏在Logcat里,不抓日志根本找不到;
  • 为什么截图必须包含adb shell dumpsys activity activities | grep mResumedActivity的输出?—— 这行命令证明你的 Activity 生命周期管理正确,不是靠finish()硬退,而是用onPause()保存状态,这是 Android 开发的“呼吸感”体现。

所以这个备忘录 App 的骨架,不是按“添加笔记→编辑→删除”功能模块切分,而是按Android 系统层约束 → 开发工具链规范 → 用户交互契约三层递进。接下来每一部分,我都会先告诉你“系统在背后要求什么”,再告诉你“你该在代码里怎么回应”。

1.2 为什么选备忘录而不是天气或计算器?—— 它是最小完备的 Android 生态缩影

备忘录 App 看似简单,实则是 Android 开发的“麻雀虽小五脏俱全”典型:

  • 存储层:既要处理轻量级的 SharedPreferences(存用户偏好),又要处理结构化数据的 Room 数据库(存笔记内容),还得兼容外部存储(导出 PDF 时写入android/data/com.yourpackage/files/);
  • UI 层:涉及CoordinatorLayout+AppBarLayout实现折叠标题栏(对应热词“android中协调布局+banner”),RecyclerViewItemTouchHelper实现滑动删除,MaterialButtonToggleGroup控制笔记分类标签;
  • 系统交互层FileProvider生成content://URI 供第三方 App(如微信、钉钉)读取导出的文件(这就是content://com.baidu.searchbox.fileprovider/...的真实来源);NotificationCompat.Builder在 Android 12+ 上必须指定setSmallIcon()否则崩溃;ActivityResultLauncher替代startActivityForResult()处理拍照附件;
  • 构建层build.gradlesigningConfigs配置 debug 和 release 签名,gradle.properties控制org.gradle.jvmargs避免编译内存溢出。

它不追求炫技,但每一步都踩在 Android 官方文档的“最佳实践”红线上。你做完这个,等于把 Android 开发的“地基”夯得结结实实。下面进入正题——不是从MainActivity.java开始,而是从gradle.properties这个被 90% 同学忽略的配置文件开始。

2. 核心细节解析与实操要点:从 gradle.properties 到 FileProvider 的生死线

2.1 gradle.properties:不是“可有可无的配置文件”,而是构建系统的呼吸阀

gradle.properties位于项目根目录,它不参与编译,却决定整个构建过程的成败。很多同学复制网上的模板,直接粘贴一堆#注释掉的配置,结果编译卡在 99%。真实情况是:它只有 4 行关键配置,缺一不可。

第一行:org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
这是给 Gradle Daemon 分配的 JVM 内存。Android Studio 默认分配 1024m,但当你引入androidx.compose.ui:uikotlinx-coroutines-android后,编译时会触发大量 Kotlin 字节码生成,内存不足直接 OOM。我实测过:-Xmx2048m是底线,低于此值,./gradlew build会在:app:compileDebugKotlin阶段静默失败,logcat 里只有一行Daemon disappeared。这不是 bug,是 Gradle 的优雅降级——它宁愿退出也不给你一个错误提示。

第二行:android.useAndroidX=true
这是 AndroidX 迁移的总开关。2023 年后所有新项目默认开启,但如果你从旧项目升级,漏掉这一行,import androidx.appcompat.app.AppCompatActivity;会编译通过,但运行时AppCompatActivityonCreate()里调用getDelegate()会抛NoSuchMethodError。因为android.support.v7.app.AppCompatActivity的字节码已被移除,而androidx的实现类名变了,Gradle 不知道该桥接到哪个版本。

第三行:android.enableJetifier=true
这是 Jetifier 工具的开关。它的作用是:把你项目里引用的第三方库(比如com.github.bumptech.glide:glide:4.14.2)中的android.support.*包名,自动重写成androidx.*。没有它,Glide 的RequestOptions就无法注入AppCompatActivitysupportFragmentManager。注意:Jetifier 只在构建时生效,不增加运行时开销,但它必须和android.useAndroidX=true成对出现,否则 Jetifier 不启动。

第四行:kotlin.code.style=official
这是 Kotlin 编码风格开关。虽然不影响功能,但高分作业要求代码格式统一。开启后,Android Studio 的Code → Reformat Code会按 Kotlin 官方规范缩进、空格、换行。比如if (condition) {必须换行写{,而不是if (condition){。老师用git diff查看提交记录时,格式错误会直接扣分。

提示:gradle.properties文件必须用 UTF-8 编码,且不能有 BOM 头。Windows 记事本保存时默认加 BOM,会导致 Gradle 解析失败,报错Could not compile settings file 'settings.gradle'。解决方案:用 VS Code 或 Notepad++ 保存为 “UTF-8 无 BOM”。

2.2 build.gradle:不是“写完就能跑”,而是 Android 构建生命周期的契约书

app/build.gradle是整个项目的“宪法”,它定义了从源码编译、资源处理、打包签名的全流程。高分作业的build.gradle必须包含 5 个不可省略的区块,少一个,截图里就会暴露问题。

第一区块:android闭包里的compileSdktargetSdk

android { compileSdk 34 defaultConfig { applicationId "com.example.memo" minSdk 21 targetSdk 34 versionCode 1 versionName "1.0" } }

compileSdk 34对应 Android 14,它决定了你能使用哪些 API。比如NotificationChannelsetSound(null, null)方法在 API 33 才支持,如果你设compileSdk 33,即使targetSdk 34,也无法调用。targetSdk 34是硬性要求——Android 14 强制要求PendingIntent必须显式声明FLAG_IMMUTABLEFLAG_MUTABLE,否则应用启动时崩溃。minSdk 21是底线,因为androidx.core:core-ktx1.12.0 已弃用 API 16,而MaterialTheme在 API 21 才完整支持。

第二区块:dependencies里的implementation版本锁定

dependencies { implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.10.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' // Room 数据库 implementation 'androidx.room:room-runtime:2.6.1' implementation 'androidx.room:room-ktx:2.6.1' kapt 'androidx.room:room-compiler:2.6.1' }

所有版本号必须精确到小数点后一位。网上教程常写1.12.+,这是大忌。+会拉取最新版,而core-ktx:1.12.1可能引入requireNotNull()的非空断言变更,导致你写的val text = editText.text.toString()在空输入时崩溃。高分作业要求“可重现”,所以必须锁死版本。Room 的kapt插件必须和room-runtime版本一致,否则@Entity注解不生成 DAO 类,编译通过但运行时报ClassNotFoundException

第三区块:buildTypes里的debug签名配置

android { buildTypes { debug { signingConfig signingConfigs.debug } release { signingConfig signingConfigs.release minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } signingConfigs { debug { storeFile file("debug.keystore") storePassword "android" keyAlias "androiddebugkey" keyPassword "android" } release { storeFile file("release.keystore") storePassword System.getenv("KEYSTORE_PASSWORD") ?: "changeit" keyAlias System.getenv("KEY_ALIAS") ?: "key0" keyPassword System.getenv("KEY_PASSWORD") ?: "changeit" } } }

debug签名必须用 Android Studio 自动生成的debug.keystore,路径在~/.android/debug.keystore。如果你手动创建,SHA1 指纹不匹配,FileProvidercontent://URI 会被系统拒绝。release签名用环境变量读取密码,是为了防止 keystore 密码硬编码在代码里——这是安全红线,老师会用grep -r "android" app/src/main/检查。

第四区块:packagingOptions防止重复类冲突

android { packagingOptions { exclude 'META-INF/DEPENDENCIES' exclude 'META-INF/LICENSE' exclude 'META-INF/license.txt' exclude 'META-INF/license' exclude 'META-INF/license.rtf' exclude 'META-INF/license.txt' exclude 'META-INF/LGPL2.1' exclude 'META-INF/NOTICE' exclude 'META-INF/notice.txt' exclude 'META-INF/NOTICE.txt' exclude 'META-INF/notice' exclude 'META-INF/ASL2.0' exclude 'META-INF/MANIFEST.MF' } }

这是解决Duplicate class android.support.v4.app.Fragment错误的唯一方案。当你同时引用androidx.appcompat和某个老版本第三方 SDK(比如百度地图 SDK 3.0)时,它们都打包了support-v4的 class,Gradle 会报More than one file was found with OS independent path 'META-INF/...'exclude不是删掉,而是告诉打包器“这些文件我不要”,避免冲突。

第五区块:android.applicationVariants.all注入构建时间戳

android.applicationVariants.all { variant -> variant.outputs.all { def date = new Date().format("yyyyMMdd_HHmmss") outputFileName = "memo_${variant.name}_${date}.apk" } }

这行代码让每次构建的 APK 文件名带时间戳,避免老师下载多个同名app-debug.apk时覆盖。更重要的是,它证明你理解了 Gradle 的 Variant API——这是高级构建脚本的基础。

2.3 FileProvider:不是“复制粘贴就能用”,而是 Android 存储沙盒的通关文牒

content://com.baidu.searchbox.fileprovider/...这类 URI 的出现,意味着你正在和 Android 的存储沙盒机制打交道。从 Android 7.0(API 24)开始,file://URI 被禁止跨应用传递,必须用FileProvider生成content://URI。高分作业的截图里,如果导出 PDF 后用微信发送失败,90% 是FileProvider配置错误。

第一步:在AndroidManifest.xml中声明 Provider

<provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider>

android:authorities必须是${applicationId}.fileprovider,不能写死成com.example.memo.fileprovider。因为applicationIdbuild.gradledefaultConfig里可能被 flavor 修改,硬编码会导致不同 flavor 下 URI 不匹配。

第二步:创建res/xml/file_paths.xml

<?xml version="1.0" encoding="utf-8"?> <paths xmlns:android="http://schemas.android.com/apk/res/android"> <external-files-path name="external_files_path" path="." /> <cache-path name="cache_path" path="." /> </paths>

这里有两个致命陷阱:

  • external-files-path对应getExternalFilesDir(),路径是/storage/emulated/0/Android/data/com.example.memo/files/,这是你的 App 私有目录,无需申请WRITE_EXTERNAL_STORAGE权限;
  • cache-path对应getCacheDir(),用于临时文件;
  • 绝对不能写<external-path name="external_path" path="." />external-path指向 SD 卡根目录,Android 10+ 会因Scoped Storage限制直接拒绝访问,报错java.lang.SecurityException: Permission Denial

第三步:在代码中生成 URI

val file = File(context.getExternalFilesDir(null), "memo_export.pdf") val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", file ) // 发送给微信 val intent = Intent(Intent.ACTION_SEND).apply { type = "application/pdf" putExtra(Intent.EXTRA_STREAM, uri) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION } context.startActivity(intent)

关键点:Intent.FLAG_GRANT_READ_URI_PERMISSION必须设置,它临时授予微信读取该 URI 的权限。没有它,微信打开文件时会报java.io.FileNotFoundException: open failed: EACCES (Permission denied)。这个 flag 只对本次 Intent 有效,不用revokeUriPermission()

注意:FileProvidername属性(如external_files_path)必须和file_paths.xml中的name完全一致,包括大小写。Android Studio 不校验这个,但运行时getUriForFile()会抛IllegalArgumentException: Failed to find configured root that contains /data/user/0/com.example.memo/files/memo_export.pdf

3. 实操过程与核心环节实现:从零搭建一个可交付的备忘录 App

3.1 项目初始化:避开 Android Studio 的“默认陷阱”

新建项目时,Android Studio 的向导页面有 3 个隐藏雷区:

雷区一:“Phone and Tablet” 模板选错
必须选Empty Activity,而不是 “Basic Activity”。因为 “Basic Activity” 自动生成activity_main.xmlCoordinatorLayout+AppBarLayout+FloatingActionButton,看似省事,实则埋坑:FloatingActionButtonapp:layout_anchor属性依赖CoordinatorLayout的 Behavior,而你的备忘录不需要悬浮按钮,删掉它会导致CoordinatorLayoutBehavior类找不到,编译报错Cannot resolve symbol 'ScrollingViewBehavior'Empty Activity从最简 XML 开始,你按需添加。

雷区二:“Language” 选 Kotlin 还是 Java?
必须选Kotlin。理由:androidx.lifecycle:lifecycle-viewmodel-ktxby viewModels()委托属性,比 Java 的ViewModelProvider(this).get(MemoViewModel::class.java)少写 8 行代码;kotlinx-coroutines-androidlifecycleScope.launch自动绑定生命周期,避免内存泄漏。高分作业要求代码简洁,Kotlin 是事实标准。

雷区三:“Minimum SDK” 设错
向导里默认API 21: Android 5.0 (Lollipop),这是正确选择。如果选API 16: Android 4.1,后续引入MaterialButton会报错Class referenced in the manifest, com.google.android.material.button.MaterialButton, was not found in the dependency graph.,因为 Material Components 库最低支持 API 21。

初始化后,立刻做三件事:

  1. 删除app/src/main/res/values/themes.xml里的Theme.AppCompat.Light.DarkActionBar,改为Theme.Material3.DayNight—— Material 3 是 Android 12+ 的设计语言,老师截图时会看主题色是否符合 Material 规范;
  2. app/src/main/res/values/colors.xml中定义colorPrimary="#6750A4"(紫罗兰主色),这是 Material 3 的推荐色;
  3. 修改app/src/main/AndroidManifest.xmlandroid:theme@style/Theme.Memo,指向你新建的主题。

3.2 数据层:Room 数据库不是“ORM框架”,而是 SQLite 的类型安全封装

备忘录的数据模型只有Memo一个 Entity,但 Room 的配置决定性能和稳定性。

Entity 定义:

@Entity(tableName = "memo_table") data class Memo( @PrimaryKey(autoGenerate = true) val id: Long = 0, @ColumnInfo(name = "title") val title: String = "", @ColumnInfo(name = "content") val content: String = "", @ColumnInfo(name = "created_time") val createdTime: Long = System.currentTimeMillis(), @ColumnInfo(name = "updated_time") val updatedTime: Long = System.currentTimeMillis(), @ColumnInfo(name = "is_pinned") val isPinned: Boolean = false, @ColumnInfo(name = "category") val category: String = "default" )

关键点:@PrimaryKey(autoGenerate = true)必须用Long,不能用Int。SQLite 的AUTOINCREMENT机制要求主键是 64 位整数,Int在插入第 2147483647 条记录时会溢出。createdTimeupdatedTimeLong存毫秒时间戳,比String格式(如"2023-10-01 12:00:00")节省 30% 存储空间,且排序更快。

DAO 接口:

@Dao interface MemoDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(memo: Memo): Long @Update suspend fun update(memo: Memo) @Delete suspend fun delete(memo: Memo) @Query("SELECT * FROM memo_table ORDER BY is_pinned DESC, updated_time DESC") fun getAllMemos(): Flow<List<Memo>> @Query("SELECT * FROM memo_table WHERE category = :category ORDER BY is_pinned DESC, updated_time DESC") fun getMemosByCategory(category: String): Flow<List<Memo>> }

@InsertonConflict = OnConflictStrategy.REPLACE是关键。当用户快速点击两次“保存”按钮,可能触发两次insert(),如果用ABORT,第二次会抛异常;用REPLACE,则自动删除旧记录再插入新记录,保证数据一致性。Flow<List<Memo>>返回Flow而不是LiveData,因为Flow支持协程取消,避免内存泄漏。

Database 类:

@Database( entities = [Memo::class], version = 1, exportSchema = true ) abstract class MemoDatabase : RoomDatabase() { abstract fun memoDao(): MemoDao companion object { @Volatile private var INSTANCE: MemoDatabase? = null fun getDatabase(context: Context): MemoDatabase { return INSTANCE ?: synchronized(this) { INSTANCE ?: buildDatabase(context).also { INSTANCE = it } } } private fun buildDatabase(context: Context): MemoDatabase { return Room.databaseBuilder( context.applicationContext, MemoDatabase::class.java, "memo_database" ).addCallback(object : RoomDatabase.Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) // 首次创建数据库时插入默认分类 db.execSQL("INSERT INTO memo_table (title, content, category) VALUES ('欢迎使用', '这是您的第一个备忘录', 'default')") } }).build() } } }

exportSchema = true生成schemas/目录下的 JSON 文件,这是高分作业的加分项——它证明你理解数据库版本迁移机制。addCallback()onCreate()里插入欢迎笔记,避免用户首次打开 App 时看到空列表,提升体验。

3.3 UI 层:CoordinatorLayout 不是“为了好看”,而是处理嵌套滚动的物理引擎

备忘录的主界面用CoordinatorLayout,不是为了炫酷,而是解决RecyclerViewAppBarLayout的嵌套滚动冲突。没有它,下拉刷新时 toolbar 不会隐藏,用户体验降级。

activity_main.xml 结构:

<androidx.coordinatorlayout.widget.CoordinatorLayout 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"> <com.google.android.material.appbar.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:theme="@style/ThemeOverlay.Material3.Dark.ActionBar"> <com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorSurface" app:title="备忘录" app:navigationIcon="@drawable/ic_menu" app:menu="@menu/menu_main" /> </com.google.android.material.appbar.AppBarLayout> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" /> <com.google.android.material.floatingactionbutton.FloatingActionButton android:id="@+id/fab" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="bottom|end" android:layout_margin="16dp" android:src="@drawable/ic_add" app:tint="@android:color/white" /> </androidx.coordinatorlayout.widget.CoordinatorLayout>

核心是app:layout_behavior="@string/appbar_scrolling_view_behavior"。这个字符串值是androidx.coordinatorlayout.widget.CoordinatorLayout$Behavior的内部实现,它监听RecyclerView的滚动事件,当RecyclerView向上滑动时,自动收缩AppBarLayout;向下滚动时,展开AppBarLayout。如果你用LinearLayout替代CoordinatorLayout,这个行为就失效。

RecyclerView Adapter:

class MemoAdapter : ListAdapter<Memo, MemoAdapter.ViewHolder>(MemoDiffCallback()) { class ViewHolder(private val binding: ItemMemoBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(memo: Memo) { binding.apply { tvTitle.text = memo.title tvContent.text = memo.content tvTime.text = formatTime(memo.updatedTime) ivPin.visibility = if (memo.isPinned) View.VISIBLE else View.GONE } } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val binding = ItemMemoBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ViewHolder(binding) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.bind(getItem(position)) } } class MemoDiffCallback : DiffUtil.Callback() { override fun getOldListSize(): Int = 0 override fun getNewListSize(): Int = 0 override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = TODO("Not yet implemented") override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = TODO("Not yet implemented") }

ListAdapter是关键。它用DiffUtil计算新旧列表差异,只刷新变化的 item,避免notifyDataSetChanged()全量刷新导致的卡顿。MemoDiffCallback必须实现areItemsTheSame()areContentsTheSame(),否则ListAdapter不工作。正确实现:

override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { return getOldList()[oldItemPosition].id == getNewList()[newItemPosition].id } override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { return getOldList()[oldItemPosition] == getNewList()[newItemPosition] }

3.4 系统集成:FileProvider 导出 PDF 不是“调用 API”,而是跨进程文件共享的契约履行

导出 PDF 功能是高分作业的“画龙点睛”之笔,它检验你对 Android 文件系统和 Intent 机制的理解。

PDF 生成逻辑:

private fun exportToPdf(memos: List<Memo>) { val file = File(context.getExternalFilesDir(null), "memo_export_${System.currentTimeMillis()}.pdf") try { val document = Document(PageSize.A4) val writer = PdfWriter.getInstance(document, FileOutputStream(file)) document.open() val font = Font(Font.FontFamily.HELVETICA, 12f, Font.NORMAL) for (memo in memos) { document.add(Paragraph("标题:${memo.title}", font)) document.add(Paragraph("内容:${memo.content}", font)) document.add(Paragraph("时间:${formatTime(memo.updatedTime)}", font)) document.add(Paragraph("---", font)) } document.close() // 触发 FileProvider val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", file ) sharePdf(uri) } catch (e: Exception) { Toast.makeText(context, "导出失败:${e.message}", Toast.LENGTH_SHORT).show() } }

iText库生成 PDF,compileSdk 34下必须用itext7-corekernellayout模块,而非老版itextpdfFileProvider.getUriForFile()是核心,前面已详述。

分享 Intent:

private fun sharePdf(uri: Uri) { val intent = Intent(Intent.ACTION_SEND).apply { type = "application/pdf" putExtra(Intent.EXTRA_STREAM, uri) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION } startActivity(Intent.createChooser(intent, "分享备忘录")) }

Intent.createChooser()是必须的。它弹出系统分享面板,让用户选择微信、QQ、邮件等 App。如果直接startActivity(intent),在某些定制 ROM(如华为 EMUI)上会因权限问题崩溃。

4. 常见问题与排查技巧实录:那些让老师皱眉的“低级错误”

4.1 Build 失败类问题:Gradle 同步失败的 5 个真实现场

问题 1:Could not find method android() for arguments [...]
现象:新建项目后,build.gradle第一行plugins { id 'com.android.application' }下划红线,提示Could not find method android()
原因:build.gradle是 Groovy 脚本,但 Android Studio 误判为 Kotlin 脚本。
解决:右键build.gradleOverride file type→ 选Groovy。这是 Android Studio 的 UI bug,重启无效。

问题 2:Failed to resolve androidx.core:core-ktx:1.12.0
现象:dependencies里写implementation 'androidx.core:core-ktx:1.12.0',同步时提示Failed to resolve
原因:build.gradle顶部的plugins块缺失google()仓库。
解决:在settings.gradledependencyResolutionManagement里确保:

repositories { google() mavenCentral() }

google()仓库托管所有androidx.*库,mavenCentral()托管第三方库。顺序不能颠倒,否则google()的库可能被mavenCentral()的旧版覆盖。

问题 3:Execution failed for task ':app:processDebugResources'
现象:编译卡在processDebugResources,logcat 显示AAPT: error: resource android:attr/lStar not found.
原因:compileSdkbuildToolsVersion版本不匹配。compileSdk 34必须用buildTools 34.0.0
解决:在app/build.gradleandroid闭包里加:

android { compileSdk 34 buildToolsVersion "34.0.0" // 显式指定 }

Android Studio 4.2+ 默认用最新 build-tools,但有时缓存旧版,显式指定可强制更新。

问题 4:Duplicate class androidx.lifecycle.ViewModelProvider
现象:dependencies里同时有androidx.lifecycle:lifecycle-viewmodelandroidx.lifecycle:lifecycle-viewmodel-ktx,编译报Duplicate class
原因:lifecycle-viewmodel-ktx已包含lifecycle-viewmodel,重复引入导致类冲突。
解决:只保留implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2',删掉lifecycle-viewmodel

问题 5:Unable to merge dex
现象:minSdkVersion设为 16,但引入androidx.core:core-ktx:1.12.0,编译报Unable to merge dex
原因:core-ktx:1.12.0minSdk是 21,与项目minSdk 16冲突。
解决:将minSdkVersion改为21,并检查所有依赖库的minSdk要求(用./gradlew app:dependencies查看)。

4.2 运行时崩溃类问题:截图里“闪退”的 3 个高频场景

问题 1:java.lang.SecurityException: Permission Denial
现象:点击“导出 PDF”后,App 闪退,logcat 显示SecurityException: Permission Denial for content://com.example.memo.fileprovider/...
原因:FileProviderandroid:authorities和代码中getUriForFile()的第二个参数不一致。
排查:在AndroidManifest.xml中查android:authorities,在代码中查getUriForFile(..., "${context.packageName}.fileprovider", ...),两者必须完全相同。

问题 2:java.lang.IllegalStateException: FragmentManager is already closed
现象:快速连续点击“添加笔记”按钮,然后返回,App 崩溃。
原因:FragmentFragmentManagerActivity销毁后仍尝试提交事务。
解决:在FragmentonCreateView()里,用lifecycleScope.launch替代viewLifecycleOwner.lifecycleScope.launch,并加if (isAdded) { ... }判断。

问题 3:android.view.InflateException: Binary XML file line #xx: Error inflating class com.google.android.material.appbar.MaterialToolbar
现象:App 启动即崩溃,logcat 指向MaterialToolbar
原因:themes.xmlTheme.Material3.DayNight未正确继承,或AndroidManifest.xmlandroid:theme指向

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询