前言
最近从 iPhone 换到安卓,发现原生系统里居然没自带。去应用商店翻了几页,不是满屏广告就是非要联网登录,用着根本不放心。考虑到备忘录都是隐私,干脆别找了,自己写一个吧。
过程
说实话,配置android studio 环境确实折腾,Ai提供的方法都是老版本,官网下载最新版本很多功能菜单对不上,主要是英文太烂了。但写代码的过程就比较快了前后就半小时。我、把需求丢给 AI,它直接生成了完整的逻辑和界面代码。从环境装好到最终编译出 APK 安装到手机,体验非常顺畅。AI 的效率确实没得说。
软件功能
没搞什么花哨的功能,风格类似iphone的,简单明了,主打一个省心:
- 分类管理:文件夹可以随时新建、重命名、删除,分类逻辑很直接。
- 本地存储:数据全存在手机本地,没有任何联网权限,不用担心隐私上传到服务器。
- 增删改查:笔记的写、改、删逻辑都做好了,满足日常随手记的需求。
- 交互简洁:界面就是简单的 Mac 风格,没乱七八糟的干扰。
预览
![[开源] 用了安卓才发现,我只能自己写个备忘录了](https://www.ximi.me/img/?=c889f6a3b27669dd.jpg)
![[开源] 用了安卓才发现,我只能自己写个备忘录了](https://www.ximi.me/img/?=9109ff9bcb05114b.jpg)
![[开源] 用了安卓才发现,我只能自己写个备忘录了](https://www.ximi.me/img/?=6cf8b2fee48f763e.jpg)
结语
以前觉得写 App 是程序员的专属领域,离我这种小白挺远的。现在发现有了 AI 帮忙,只要心里有想法,几分钟就能搞定一个。以后再遇到什么工具不好用,别再去外面找来找去了,自己动手改个顺手的,这感觉真的比什么都强。
下载与源码
如果你也想试试这款简洁的备忘录,或者想在此基础新增功能,也可以直接下载源码修改编译:
APK:直接安装 APK (约 9MB,下载后解压安装即可)
源代码:项目源代码(约 182KB,直接用 Android Studio 打开)
注:下载链接禁用了迅雷等第三方工具下载,浏览器本地下载不受影响!
温馨提示: 这软件就是个本地笔记,没写联网和定位代码,纯粹为了自用。安装时遇到获取应用列表的权限,直接点“拒绝”就行,没任何影响。
关于
- 作者:希米
- 原文: https://www.ximi.me/post-6041.html
- 最后更新:2026-06-10
核心代码:
package com.example.mynotepad
import android.content.Context
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
// --- 数据模型 ---
data class Note(val id: String = UUID.randomUUID().toString(), var title: String, var content: String)
// 这里的 val name 改成了 var name,以支持重命名
data class Folder(val id: String = UUID.randomUUID().toString(), var name: String, val notes: MutableList<Note> = mutableListOf())
// --- 导航屏幕状态 ---
sealed class Screen {
object FolderList : Screen()
data class NoteList(val folderId: String) : Screen()
data class NoteEditor(val folderId: String, val noteId: String?) : Screen()
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme(
colorScheme = lightColorScheme(
primary = Color(0xFFEAA83A), // 经典 Mac 备忘录琥珀黄
background = Color(0xFFF6F5F0), // 仿纸质温暖灰白底色
surface = Color(0xFFFFFFFF)
)
) {
MacNotesApp()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MacNotesApp() {
val context = LocalContext.current
var folders by remember { mutableStateOf(listOf<Folder>()) }
var currentScreen by remember { mutableStateOf<Screen>(Screen.FolderList) }
// 弹窗与状态控制
var showFolderDialog by remember { mutableStateOf(false) }
var newFolderName by remember { mutableStateOf("") }
// 目录管理的全新状态
var expandedMenuFolderId by remember { mutableStateOf<String?>(null) } // 控制哪个文件夹的下拉菜单展开
var folderToEdit by remember { mutableStateOf<Folder?>(null) } // 当前选中的文件夹
var showRenameDialog by remember { mutableStateOf(false) }
var editFolderName by remember { mutableStateOf("") }
var showDeleteDialog by remember { mutableStateOf(false) }
// 初始化读取
LaunchedEffect(Unit) {
folders = loadDataFromLocal(context)
}
// 保存方法
val saveToLocal = {
saveDataToLocal(context, folders)
}
// 物理返回键接管
BackHandler(enabled = currentScreen !is Screen.FolderList) {
currentScreen = when (val screen = currentScreen) {
is Screen.NoteList -> Screen.FolderList
is Screen.NoteEditor -> Screen.NoteList(screen.folderId)
else -> Screen.FolderList
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = when (val screen = currentScreen) {
is Screen.FolderList -> "文件夹"
is Screen.NoteList -> folders.find { it.id == screen.folderId }?.name ?: "备忘录"
is Screen.NoteEditor -> "编辑备忘录"
},
fontWeight = FontWeight.Bold,
color = Color(0xFF333333)
)
},
navigationIcon = {
if (currentScreen !is Screen.FolderList) {
IconButton(onClick = {
currentScreen = when (val screen = currentScreen) {
is Screen.NoteList -> Screen.FolderList
is Screen.NoteEditor -> Screen.NoteList(screen.folderId)
else -> Screen.FolderList
}
}) {
Text("◀", color = Color(0xFFEAA83A), fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
}
},
actions = {
// 如果处于已有备忘录的编辑页面,右上角显示“垃圾桶”按钮
if (currentScreen is Screen.NoteEditor) {
val editorScreen = currentScreen as Screen.NoteEditor
if (editorScreen.noteId != null) {
IconButton(onClick = {
val currentFolder = folders.find { it.id == editorScreen.folderId }
currentFolder?.notes?.removeIf { it.id == editorScreen.noteId }
saveToLocal()
Toast.makeText(context, "已删除备忘录", Toast.LENGTH_SHORT).show()
currentScreen = Screen.NoteList(editorScreen.folderId)
}) {
Text("🗑️", fontSize = 18.sp)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFFF6F5F0))
)
}
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues).fillMaxSize().background(Color(0xFFF6F5F0))) {
when (val screen = currentScreen) {
// 1. 文件夹列表界面
is Screen.FolderList -> {
Column(modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.weight(1f).padding(horizontal = 16.dp)) {
items(folders) { folder ->
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp).clickable {
currentScreen = Screen.NoteList(folder.id)
},
colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(10.dp)
) {
Row(
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("📁", fontSize = 20.sp)
Spacer(modifier = Modifier.width(12.dp))
Text(text = folder.name, modifier = Modifier.weight(1f), fontSize = 16.sp, fontWeight = FontWeight.Medium)
Text(text = "${folder.notes.size}", color = Color.Gray, fontSize = 14.sp)
// 【新增功能】:目录的更多操作按钮(三个点)
Box {
IconButton(onClick = { expandedMenuFolderId = folder.id }) {
Text("⋮", color = Color.Gray, fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
DropdownMenu(
expanded = expandedMenuFolderId == folder.id,
onDismissRequest = { expandedMenuFolderId = null },
modifier = Modifier.background(Color.White)
) {
DropdownMenuItem(
text = { Text("📝 重命名") },
onClick = {
expandedMenuFolderId = null
folderToEdit = folder
editFolderName = folder.name
showRenameDialog = true
}
)
DropdownMenuItem(
text = { Text("🗑️ 删除", color = Color.Red) },
onClick = {
expandedMenuFolderId = null
folderToEdit = folder
showDeleteDialog = true
}
)
}
}
}
}
}
}
// 底部新建文件夹按钮
Button(
onClick = { showFolderDialog = true },
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFEAA83A))
) {
Text("新建文件夹", color = Color.White)
}
}
}
// 2. 指定文件夹内的记事列表界面
is Screen.NoteList -> {
val currentFolder = folders.find { it.id == screen.folderId }
val notes = currentFolder?.notes ?: listOf()
Column(modifier = Modifier.fillMaxSize()) {
if (notes.isEmpty()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("该文件夹下没有备忘录", color = Color.Gray)
}
} else {
LazyColumn(modifier = Modifier.weight(1f).padding(horizontal = 16.dp)) {
items(notes) { note ->
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp).clickable {
currentScreen = Screen.NoteEditor(screen.folderId, note.id)
},
colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(10.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = note.title.ifEmpty { "无标题" }, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Spacer(modifier = Modifier.height(4.dp))
Text(text = note.content.ifEmpty { "没有其他文本" }, color = Color.Gray, maxLines = 1, fontSize = 14.sp)
}
}
}
}
}
// 新建笔记按钮
Button(
onClick = { currentScreen = Screen.NoteEditor(screen.folderId, null) },
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFEAA83A))
) {
Text("✍️ 撰写新备忘录", color = Color.White)
}
}
}
// 3. 记事编辑器界面
is Screen.NoteEditor -> {
val currentFolder = folders.find { it.id == screen.folderId }
val existingNote = currentFolder?.notes?.find { it.id == screen.noteId }
var title by remember { mutableStateOf(existingNote?.title ?: "") }
var content by remember { mutableStateOf(existingNote?.content ?: "") }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
placeholder = { Text("标题", fontSize = 20.sp, fontWeight = FontWeight.Bold) },
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent
),
textStyle = LocalTextStyle.current.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold)
)
HorizontalDivider(color = Color(0xFFE5E5E5), thickness = 1.dp, modifier = Modifier.padding(vertical = 8.dp))
OutlinedTextField(
value = content,
onValueChange = { content = it },
placeholder = { Text("开始输入内容...") },
modifier = Modifier.fillMaxWidth().weight(1f),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent
)
)
Button(
onClick = {
if (existingNote != null) {
existingNote.title = title
existingNote.content = content
} else {
// 仅当用户输入了内容或标题时才保存,避免产生空白笔记
if (title.isNotBlank() || content.isNotBlank()) {
currentFolder?.notes?.add(0, Note(title = title, content = content))
}
}
saveToLocal()
Toast.makeText(context, "已存储到本地", Toast.LENGTH_SHORT).show()
currentScreen = Screen.NoteList(screen.folderId)
},
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFEAA83A))
) {
Text("完成并保存", color = Color.White)
}
}
}
}
}
}
// --- 所有弹窗组件 ---
// 1. 新建文件夹弹窗
if (showFolderDialog) {
AlertDialog(
onDismissRequest = { showFolderDialog = false },
title = { Text("新建文件夹") },
text = {
OutlinedTextField(
value = newFolderName,
onValueChange = { newFolderName = it },
label = { Text("文件夹名称") }
)
},
confirmButton = {
TextButton(onClick = {
if (newFolderName.isNotBlank()) {
folders = folders + Folder(name = newFolderName)
saveToLocal()
newFolderName = ""
showFolderDialog = false
}
}) { Text("创建", color = Color(0xFFEAA83A)) }
},
dismissButton = {
TextButton(onClick = { showFolderDialog = false }) { Text("取消", color = Color.Gray) }
}
)
}
// 2. 重命名文件夹弹窗
if (showRenameDialog && folderToEdit != null) {
AlertDialog(
onDismissRequest = { showRenameDialog = false },
title = { Text("重命名") },
text = {
OutlinedTextField(
value = editFolderName,
onValueChange = { editFolderName = it },
label = { Text("新名称") }
)
},
confirmButton = {
TextButton(onClick = {
if (editFolderName.isNotBlank()) {
// 更新内存数据
folderToEdit?.name = editFolderName
// 强制触发重组以更新UI
folders = folders.toList()
saveToLocal()
showRenameDialog = false
}
}) { Text("保存", color = Color(0xFFEAA83A)) }
},
dismissButton = {
TextButton(onClick = { showRenameDialog = false }) { Text("取消", color = Color.Gray) }
}
)
}
// 3. 删除文件夹防误触确认弹窗
if (showDeleteDialog && folderToEdit != null) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("删除文件夹?") },
text = { Text("确定要删除「${folderToEdit?.name}」吗?\n警告:这将会一并删除该文件夹下的所有备忘录,且无法恢复。") },
confirmButton = {
TextButton(onClick = {
folders = folders.filter { it.id != folderToEdit?.id }
saveToLocal()
showDeleteDialog = false
}) { Text("确认删除", color = Color.Red) }
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) { Text("取消", color = Color.Gray) }
}
)
}
}
// --- JSON 文件读取与存储逻辑 ---
private fun saveDataToLocal(context: Context, folders: List<Folder>) {
try {
val rootArray = JSONArray()
for (f in folders) {
val fObj = JSONObject().apply {
put("id", f.id)
put("name", f.name)
val nArray = JSONArray()
for (n in f.notes) {
val nObj = JSONObject().apply {
put("id", n.id)
put("title", n.title)
put("content", n.content)
}
nArray.put(nObj)
}
put("notes", nArray)
}
rootArray.put(fObj)
}
context.openFileOutput("notes_storage.json", Context.MODE_PRIVATE).use {
it.write(rootArray.toString().toByteArray())
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun loadDataFromLocal(context: Context): List<Folder> {
val list = mutableListOf<Folder>()
try {
val text = context.openFileInput("notes_storage.json").use { stream ->
stream.bufferedReader().use { it.readText() }
}
val rootArray = JSONArray(text)
for (i in 0 until rootArray.length()) {
val fObj = rootArray.getJSONObject(i)
val folder = Folder(
id = fObj.getString("id"),
name = fObj.getString("name"),
notes = mutableListOf()
)
val nArray = fObj.getJSONArray("notes")
for (j in 0 until nArray.length()) {
val nObj = nArray.getJSONObject(j)
folder.notes.add(
Note(
id = nObj.getString("id"),
title = nObj.getString("title"),
content = nObj.getString("content")
)
)
}
list.add(folder)
}
} catch (e: Exception) {
list.add(Folder(name = "我的备忘"))
}
return list
}