【安卓】开发一个读取文件信息的简易apk

场景介绍:最近博主更新博客频率变少了 因为周末都在沉迷风光摄影,随着照片拍的越来越多 诞生了写一个简易安卓应用的想法。摄影和移动端应用是如何发生碰撞的呢?因为拍的照片多,照片后期+拍摄技巧 感觉到了一定的瓶颈 再往上可能就要换镜头了,所以博主想知道自己使用什么焦段频率最多 根据自己的拍摄习惯再决定要不要换其它焦段的镜头。

所以很明显 本期的实战内容为:读取手机相册内的照片焦段信息,并统计什么焦段用的最多,逻辑很简单 读取后分组count即可,给不懂摄影的同学解释一下,不用在意什么是焦段 你理解成图片的一个信息即可 例如图片的创建时间,总之就是一个统计而已。

熟悉博主的同学可能知道,博主是java开发,并不是安卓开发,所以本篇安卓教程会按照初学者角度来描述。

文章目录

创建一个安卓工程

在Android studio中新建一个工程

因为是个简易的apk 没写什么布局 所以博主这里选择的是no activity

目录结构如下:

按照以下命名创建:(注意是创建kotlin class非java class)

工程源码

核心代码解释:FastFolderImageScannerV2类 ,读取到图片后 调用Exif库获取焦段信息, 代码中有个 val buffer = ByteArray(65536) 是因为exif信息一般就在流前面,不需要往后读取那么多了 节省效率,读取完之后stat方法就类似java的stream流分组统计。

kotlin 复制代码
package com.tanmo.exifhelper.scanner

import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.exif.ExifSubIFDDirectory
import com.tanmo.exifhelper.data.FocalPhoto
import kotlinx.coroutines.*
import java.io.ByteArrayInputStream

class FastFolderImageScannerV2(
    private val context: Context
) {

    companion object {
        private const val CONCURRENT_READERS = 4
        private const val BATCH_SIZE = 50
    }

    suspend fun scan(folderUri: Uri): List<FocalPhoto> = withContext(Dispatchers.IO) {
        val root = DocumentFile.fromTreeUri(context, folderUri) ?: return@withContext emptyList()

        val imageFiles = collectAllImageFiles(root)
        println("找到 ${imageFiles.size} 个图片文件")

        // 使用专用EXIF库并行处理
        return@withContext processWithMetadataExtractor(imageFiles)
    }

    private suspend fun processWithMetadataExtractor(files: List<DocumentFile>): List<FocalPhoto> =
        coroutineScope {
            // 分批处理
            val chunks = files.chunked(BATCH_SIZE)
            val allResults = mutableListOf<FocalPhoto>()

            chunks.forEach { chunk ->
                // 并行处理当前批次
                val deferredResults = chunk.map { file ->
                    async {
                        readFocalWithMetadataExtractor(file)
                    }
                }

                // 等待当前批次完成
                val batchResults = deferredResults.awaitAll().filterNotNull()
                allResults.addAll(batchResults)
            }

            allResults
        }

    private suspend fun readFocalWithMetadataExtractor(file: DocumentFile): FocalPhoto? =
        withContext(Dispatchers.IO) {
            return@withContext try {
                context.contentResolver.openInputStream(file.uri)?.use { input ->
                    // 只读取前64KB
                    val buffer = ByteArray(65536)
                    val bytesRead = input.read(buffer)

                    if (bytesRead > 0) {
                        ByteArrayInputStream(buffer, 0, bytesRead).use { bais ->
                            try {
                                val metadata = ImageMetadataReader.readMetadata(bais)
                                val directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory::class.java)

                                directory?.getDoubleObject(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)?.toDouble()
                                    ?.takeIf { it > 0 }
                                    ?.let { focal ->
                                        FocalPhoto(
                                            focal = focal,
                                            uri = file.uri.toString()
                                        )
                                    }
                            } catch (e: Exception) {
                                null
                            }
                        }
                    } else {
                        null
                    }
                }
            } catch (e: Exception) {
                null
            }
        }

    /**
     * 收集所有图片文件
     */
    private fun collectAllImageFiles(root: DocumentFile): List<DocumentFile> {
        val files = mutableListOf<DocumentFile>()
        traverseForFiles(root, files)
        return files
    }

    private fun traverseForFiles(dir: DocumentFile, fileList: MutableList<DocumentFile>) {
        for (file in dir.listFiles()) {
            when {
                file.isDirectory -> traverseForFiles(file, fileList)
                isImageFile(file) -> fileList.add(file)
            }
        }
    }

    private fun isImageFile(file: DocumentFile): Boolean {
        return file.type?.startsWith("image/") == true ||
                file.name?.lowercase()?.let { name ->
                    name.endsWith(".jpg") || name.endsWith(".jpeg") ||
                            name.endsWith(".png") || name.endsWith(".heic") ||
                            name.endsWith(".webp") || name.endsWith(".bmp") ||
                            name.endsWith(".tiff") || name.endsWith(".gif")
                } == true
    }}
kotlin 复制代码
package com.tanmo.exifhelper.stat


data class FocalCount(
    val focal: Float,
    val count: Int
)
kotlin 复制代码
package com.tanmo.exifhelper.stat

class FocalLengthStatService {

    fun stat(focals: List<Double>): List<FocalCount> {
        return focals
            .groupingBy { it }
            .eachCount()
            .toSortedMap()
            .map { FocalCount(it.key.toFloat(), it.value) }
    }
}
kotlin 复制代码
package com.tanmo.exifhelper.stat

data class FocalRange(
    val min: Double?,
    val max: Double?,
    val label: String
) {
    fun match(value: Double): Boolean {
        return when {
            min == null -> value <= max!!
            max == null -> value > min
            else -> value >= min && value <= max
        }
    }
}

FocalRangesProperties.kt 文件 (注意是.kt结尾 )

这个可以根据自己想统计的焦段范围调整

kotlin 复制代码
package com.tanmo.exifhelper.stat

val FOCAL_RANGES = listOf(
    FocalRange(null, 18.0, "≤18mm"),
    FocalRange(19.0, 23.0, "19--23mm"),
    FocalRange(24.0, 50.0, "24--50mm"),
    FocalRange(51.0, 74.0, "51--74mm"),
    FocalRange(75.0, 120.0, "75--120mm"),
    FocalRange(121.0, 139.0, "121--139mm"),
    FocalRange(140.0, 200.0, "140--200mm"),
    FocalRange(201.0, 250.0, "201--250mm"),
    FocalRange(250.0, null, ">250mm")
)
kotlin 复制代码
package com.tanmo.exifhelper.stat

data class FocalRangeStat(
    val range: FocalRange,
    val count: Int,
    val percent: Float)
kotlin 复制代码
package com.tanmo.exifhelper.stat

class FocalRangeStatService {

    /**
     * @param focals  扫描得到的焦段列表
     * @param uris    和 focals 一一对应的图片 uri(后面点进去用)
     */
    fun stat(
        focals: List<Double>,
    ): List<FocalRangeStat> {

        val total = focals.size
        if (total == 0) return emptyList()

        return FOCAL_RANGES.map { range ->
            val matched = focals.withIndex()
                .filter { range.match(it.value) }

            FocalRangeStat(
                range = range,
                count = matched.size,
                percent = matched.size.toFloat() / total,
            )
        }.filter { it.count > 0 }
    }
}
kotlin 复制代码
package com.tanmo.exifhelper

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.tanmo.exifhelper.data.FocalPhoto
import com.tanmo.exifhelper.scanner.FastFolderImageScannerV2
import com.tanmo.exifhelper.stat.FocalLengthStatService
import com.tanmo.exifhelper.stat.FocalRangeStatService
import kotlinx.coroutines.launch

class MainActivity : AppCompatActivity() {

    private lateinit var btnPickFolder: Button
    private lateinit var btnReselect: Button
    private lateinit var btnRangeStat: Button
    private lateinit var tvStatus: TextView
    private lateinit var tvResult: TextView
    private var cachedFocals: List<Double> = emptyList()



    /** SAF 文件夹选择 */
    private val pickFolderLauncher =
        registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
            if (uri != null) {
                contentResolver.takePersistableUriPermission(
                    uri,
                    Intent.FLAG_GRANT_READ_URI_PERMISSION
                )
                startScan(uri)
            }
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        btnPickFolder = findViewById(R.id.btnPickFolder)
        btnReselect = findViewById(R.id.btnReselect)
        btnRangeStat = findViewById(R.id.btnRangeStat)
        tvStatus = findViewById(R.id.tvStatus)
        tvResult = findViewById(R.id.tvResult)

        btnPickFolder.setOnClickListener {
            pickFolderLauncher.launch(null)
        }

        btnReselect.setOnClickListener {
            resetUI()
        }

        btnRangeStat.setOnClickListener {
            showRangeStatDialog()
        }
    }

    private fun startScan(folderUri: Uri) {
        tvStatus.text = "正在扫描,请稍候..."
        tvResult.text = ""
        btnPickFolder.isEnabled = false
        btnRangeStat.visibility = View.GONE

        lifecycleScope.launch {
            try {
                val scanner = FastFolderImageScannerV2(this@MainActivity)

                // 扫描焦段
                // 1️⃣ 扫描
                val focalPhotoList = scanner.scan(folderUri)

                val statService = FocalLengthStatService()
                val focalList = focalPhotoList.map(FocalPhoto::focal)
                val result = statService.stat(focalList)
                cachedFocals = focalList;

                val total = focalList.size

                val resultText = buildString {
                    append("总照片数:$total 张\n\n")
                    append("【具体焦段统计】\n")
                    result.forEach { item ->
                        val percent =
                            if (total > 0) String.format("%.2f", item.count * 100.0 / total)
                            else "0.00"
                        append("${item.focal} mm :${item.count} 张($percent%)\n")
                    }
                }

                showResult(resultText)

            } catch (e: Exception) {
                Toast.makeText(this@MainActivity, e.message, Toast.LENGTH_LONG).show()
                resetUI()
            }
        }
    }

    private fun showResult(text: String) {
        tvStatus.text = "扫描完成"
        tvResult.text = text
        btnPickFolder.visibility = View.GONE
        btnReselect.visibility = View.VISIBLE
        btnRangeStat.visibility = View.VISIBLE
    }

    private fun resetUI() {
        tvStatus.text = ""
        tvResult.text = ""
        cachedFocals = emptyList()

        btnPickFolder.visibility = View.VISIBLE
        btnPickFolder.isEnabled = true
        btnReselect.visibility = View.GONE
        btnRangeStat.visibility = View.GONE
    }

    /**
     * 弹窗展示【焦段范围统计】
     * 不跳页面、不影响当前统计
     */
    private fun showRangeStatDialog() {
        if (cachedFocals.isEmpty()) {
            Toast.makeText(this, "请先扫描文件夹", Toast.LENGTH_SHORT).show()
            return
        }

        val rangeService = FocalRangeStatService()
        val rangeStats = rangeService.stat(cachedFocals)

        val total = cachedFocals.size

        val message = buildString {
            rangeStats.forEach {
                val percent =
                    if (total > 0) String.format("%.2f", it.count * 100.0 / total)
                    else "0.00"
                append("${it.range.label} :${it.count} 张($percent%)\n")
            }
        }

        AlertDialog.Builder(this)
            .setTitle("焦段范围统计")
            .setMessage(message)
            .setPositiveButton("关闭", null)
            .show()
    }
}

/res/layout xml文件:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:padding="16dp"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <Button
        android:id="@+id/btnPickFolder"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="选择文件夹并扫描焦段"/>

    <Button
        android:id="@+id/btnReselect"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="重新选择文件夹"
        android:visibility="gone"
        android:layout_marginTop="8dp"/>

    <TextView
        android:id="@+id/tvStatus"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text=""
        android:textSize="16sp"
        android:layout_marginTop="12dp"/>

    <ScrollView
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:layout_marginTop="12dp">

        <TextView
            android:id="@+id/tvResult"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textSize="15sp"/>
    </ScrollView>
	
	<Button
    android:id="@+id/btnRangeStat"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="查看焦段范围统计"
    android:layout_marginTop="8dp"
    android:visibility="gone"/>


</LinearLayout>

最后是data文件夹 一些和sqlite有关的数据库操作,因为项目并不是博主想要的完全体效果 所以看起来会有一些"脏代码" (没用上的) 这里都贴上 以防项目跑不起来:

kotlin 复制代码
package com.tanmo.exifhelper.data

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(entities = [ImageMetaEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {


    abstract fun imageMetaDao(): ImageMetaDao


    companion object {
        @Volatile private var INSTANCE: AppDatabase? = null


        fun get(context: Context): AppDatabase =
            INSTANCE ?: synchronized(this) {
                INSTANCE ?: Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "image_meta.db"
                ).build().also { INSTANCE = it }
            }
    }
}
kotlin 复制代码
package com.tanmo.exifhelper.data

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query

@Dao
interface FocalCacheDao {

    @Query("SELECT * FROM focal_cache WHERE uri = :uri AND lastModified = :lastModified LIMIT 1")
    suspend fun find(uri: String, lastModified: Long): FocalCacheEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(entity: FocalCacheEntity)
}
kotlin 复制代码
package com.tanmo.exifhelper.data

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(
    entities = [FocalCacheEntity::class],
    version = 1,
    exportSchema = false
)
abstract class FocalCacheDatabase : RoomDatabase() {

    abstract fun dao(): FocalCacheDao

    companion object {
        @Volatile
        private var INSTANCE: FocalCacheDatabase? = null

        fun get(context: Context): FocalCacheDatabase =
            INSTANCE ?: synchronized(this) {
                INSTANCE ?: Room.databaseBuilder(
                    context.applicationContext,
                    FocalCacheDatabase::class.java,
                    "focal_cache.db"
                ).build().also { INSTANCE = it }
            }
    }
}
kotlin 复制代码
package com.tanmo.exifhelper.data


import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "focal_cache")
data class FocalCacheEntity(
    @PrimaryKey
    val uri: String,

    val lastModified: Long,

    val focalLength: Double
)
kotlin 复制代码
package com.tanmo.exifhelper.data

data class FocalPhoto(
    val focal: Double,
    val uri: String
)
kotlin 复制代码
package com.tanmo.exifhelper.data

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.tanmo.exifhelper.stat.FocalCount

@Dao
interface ImageMetaDao {


    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(entity: ImageMetaEntity)


    @Query("DELETE FROM image_meta WHERE path = :path")
    suspend fun deleteByPath(path: String)


    @Query("SELECT COUNT(*) FROM image_meta WHERE focalLength = :value")
    suspend fun countByValue(value: Float): Int


    @Query("SELECT COUNT(*) FROM image_meta WHERE focalLength BETWEEN :min AND :max")
    suspend fun countByRange(min: Float, max: Float): Int

    @Query("""
    SELECT focalLength AS focal, COUNT(*) AS count
    FROM image_meta
    GROUP BY focalLength
    ORDER BY focalLength ASC
""")
    suspend fun countGroupByFocal(): List<FocalCount>
}
kotlin 复制代码
package com.tanmo.exifhelper.data

import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey

@Entity(
    tableName = "image_meta",
    indices = [Index(value = ["path"], unique = true)]
)
data class ImageMetaEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,


    val path: String,


    /** mm */
    val focalLength: Float,


    val lastModified: Long
)

项目根目录下的gradle文件(注意 是项目根目录 不是app里面)

build.gradle.kts

复制代码
plugins {
    id("com.android.application") version "8.2.1" apply false
    id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}

gradle.properties

一般用默认的就可以了,博主这里在最后一行手动指定了gradle java home

org.gradle.java.home=D:\dev-software\AndroidStudio\jbr (务必替换成自己的)

复制代码
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
# 如果手动指定 务必替换成自己的
org.gradle.java.home=D\:\\dev-software\\AndroidStudio\\jbr

app目录下的build.gradle.kts,这个文件里面配置的是依赖信息 类似maven的pom.xml

复制代码
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("kotlin-kapt") // 在这里添加 kapt 插件
}

android {
    namespace = "com.tanmo.exifhelper"
    compileSdk = 34

    defaultConfig {
        applicationId = "com.tanmo.exifhelper"
        minSdk = 26
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    kotlinOptions {
        jvmTarget = "17"
    }
    // keytool -genkeypair  -alias exifhelper  -keyalg RSA -keysize 2048  -validity 10000 -keystore exifhelper.jks
    // keyAlias 必须和上面的 -alias一致 (exifhelper.jks签名是执行上面命令得到的) 
    signingConfigs {
        create("release") {
            storeFile = file("exifhelper.jks")
            storePassword = "你输入的密码"
            keyAlias = "exifhelper"
            keyPassword = "你输入的密码"
        }
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            signingConfig = signingConfigs.getByName("release")
        }
        debug {
            // debug 
        }
    }

    kotlinOptions {
        jvmTarget = "17"
    }
}

dependencies {

    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")

    implementation ("androidx.core:core-ktx:1.12.0")
    implementation ("androidx.appcompat:appcompat:1.6.1")
    implementation ("com.google.android.material:material:1.11.0")


	// EXIF
    implementation ("androidx.exifinterface:exifinterface:1.3.7")
    // EXIF v2 scanner  当前用的依赖
    implementation ("com.drewnoakes:metadata-extractor:2.18.0")



// 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")


// 协程(后台扫描)
    implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}

项目环境

gradle用的是8.2 ,jdk版本是17 ( 通过配置文件也可以看出)

安卓sdk用的是 android 14 (api :34)

(这个可能需要根据自己手机系统来调整 一般都能向下兼容,比如安卓16的手机 用Android 14对应的api level 没问题;但是反过来就不行了;如果是比较早的安卓系统,那jdk版本可能也要降 版本对应关系自行网上查找)

打包

  1. 首先需要在控制台输入:
shell 复制代码
   keytool -genkeypair  -alias exifhelper  -keyalg RSA -keysize 2048  -validity 10000 -keystore exifhelper.jks

这个是创建证书的,如果apk没有证书,很多手机是无法安装/运行软件的 根据提示自己设置密码、城市信息即可

拿到证书文件后 将证书复制到/app 的根目录下

最后 回到app目录下的build.gradle.kts 修改成设置的密码

  1. 打包命令
shell 复制代码
 ./gradlew assembleRelease 

路径:

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen4 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone4 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui