示例工程【免费下载链接】52-technologies-in-2016Lets learn a new technology every week. A new technology blog every Sunday in 2016.项目地址https://gitcode.com/gh_mirrors/52/52-technologies-in-2016点击查看免费下载本文是 52-technologies-in-2016 仓库中 23-android-part1 的完整技术解读。该周教程从零开始用 Android Studio 逐步搭建一个名为Missing Kid Tracker的 Android 应用的第一版用户可以通过菜单项启动系统相机拍照把照片保存到应用专属相册并在 Activity 中预览拍摄结果。读完本文你将掌握 Android 项目骨架结构、菜单资源、Intent 启动相机、Activity 状态保存/恢复、Bitmap 缩放与 EXIF 方向修复等一整套实战技能为后续上传照片到后端、匹配失踪儿童报告的功能打下基础。注本文所有截图均来自 23-android-part1/images 目录源码说明以该目录下的 README.md 为准。项目背景与本期目标作者在新闻中了解到印度每年有超过 10 万名儿童失踪其中一些被勒索赎金、被性侵后杀害、被胁迫成为红绿灯乞讨者或被卷入人口贩卖。于是产生了构建一个应用的想法当你在地铁口、红绿灯等不寻常的场所看到可疑儿童时用 App 拍下照片应用自动记录照片与地理定位并上传后端后端将这些数据与家长提交的失踪儿童报告进行匹配一旦发现可能匹配就通知家长。由于无法在一个周末完成整个应用作者计划在 6 月至 7 月间分多篇博客逐步开发。本篇Part 1聚焦三个能力捕获照片capture photo将照片存储到相册store photo in an album展示捕获照片的预览show the captured photo for preview。环境准备开始 Android 开发前需要准备机器上安装JDK 6 或以上版本下载并安装Android Studio对应操作系统版本。作者强调自己也是 Android 开发新手因此整个教程采取一步步来的节奏读者无需担心前置经验。第一步创建 Android Studio 项目1.1 新建项目与关键字段启动 Android Studio点击Start a new Android Studio Project开始新建应用。随后需要填写以下项目配置字段字段含义Application name应用名称应用将以该名称安装到用户设备Company domain反向域名限定符在 Play Store 发布前必须确保使用的是自己拥有的合法域名Package nameAndroid 项目的完全限定名默认值等于Application Name与Company Domain拼接Project location存放应用源代码的本地路径填写完成后点击Next。1.2 选择运行设备与 Minimum SDK接下来选择应用可运行的设备形态。由于本应用只面向Phone and Tablet手机与平板只勾选该项即可。Minimum SDK字段用于指定应用可运行的最低 Android SDK 版本。教程选择API 10: Android 2.3.3 (Gingerbread)作为最低版本这意味着后续编写代码时必须自行处理 API 兼容性问题——部分功能可能只存在于最新的 Android SDK 中需要做版本判断。1.3 创建 Activity点击Next后进入 Activity 创建环节。Activity 是用户能做的一件单一、聚焦的事情一个应用可以包含一个或多个 Activity。选择Empty Activity并点击Next最后在Customize the Activity页面保持默认选项点击Finish完成向导。Activity 是 Android 应用最重要的组件之一代表一个带用户界面的独立屏幕。后续教程中MainActivity负责展示失踪儿童列表另一个 Activity 负责向后端发送数据还有一个 Activity 用于提交失踪儿童报告——每个用例对应一个 Activity。1.4 运行应用到真机/模拟器教程建议直接使用真实设备作者认为模拟器慢且笨重用 USB 数据线连接手机后点击Run按钮在弹出的设备选择对话框中选中真实设备并点击OK可勾选Use same selection for future launches以便后续自动使用同一设备。1.5 背后的构建与部署机制Gradle adb点击 Run 按钮时Android Studio 实际上协调了两个关键工具GradleAndroid 应用构建工具。它会执行:app:generateDebugSources、:app:prepareDebugUnitTestDependencies、:app:mockableAndroidJar、:app:generateDebugAndroidTestSources等任务最终生成应用安装包app-debug.apk存放在app/build/outputs/apk目录。APKAndroid Package是以.apk结尾的归档文件包含应用源码与资源文件。adbAndroid Debug Bridge命令行工具负责把 APK 推送到 Android 设备并启动应用。真实运行时的控制台日志如下可以看到完整的 adb 操作链路06/11 19:25:16: Launching app $ adb push ~/MissingKidTracker/app/build/outputs/apk/app-debug.apk /data/local/tmp/com.shekhargulati.missingkidtracker $ adb shell pm install -r /data/local/tmp/com.shekhargulati.missingkidtracker pkg: /data/local/tmp/com.shekhargulati.missingkidtracker Success $ adb shell am start -n com.shekhargulati.missingkidtracker/com.shekhargulati.missingkidtracker.MainActivity -a android.intent.action.MAIN -c android.intent.category.LAUNCHER Connected to process 4001 on device samsung-gt_i9500-xxxx日志依次展示了adb push推送 APK 到设备临时目录 →pm install -r安装-r表示允许覆盖安装→am start通过MAINaction 与LAUNCHERcategory 启动MainActivity。第二步理解自动生成的代码Android Studio 生成了骨架应用理解其中几个关键文件是后续开发的基础。2.1 AndroidManifest.xml应用与系统交互的契约AndroidManifest.xml是 Android 系统了解应用的入口文件用于声明应用启动时要启动的 Activity声明应用所需的特性feature与权限permission所有 Activity 都必须在这里声明。骨架生成的清单文件如下?xml version1.0 encodingutf-8? manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.shekhargulati.missingkidtracker application android:allowBackuptrue android:iconmipmap/ic_launcher android:labelstring/app_name android:themestyle/AppTheme activity android:name.MainActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /application /manifest其中application标签声明应用级配置如图标、名称等activity标签在application元素内部声明每个 Activityandroid:name属性定义 Activity 名称intent-filter声明其他应用组件如何激活它。官方文档对骨架 Activity 有明确说明使用 Android SDK 工具创建新应用时自动生成的 stub Activity 会自带一个 intent-filter声明该 Activity 响应 main action 且应被放入 launcher category——这正是它能作为应用入口的原因。2.2 MainActivity.java应用入口MainActivity是应用的入口 ActivityMain 只是命名惯例可以任意命名。Android 系统启动应用时会读取AndroidManifest.xml中MAINaction LAUNCHERcategory 的声明来定位主 Activity。所有 Activity 都是 Android SDK 中Activity类的子类。MainActivity继承的是特殊类型AppCompatActivity从而获得 Action Bar操作栏能力——如果把父类换成Activity应用将没有操作栏。所有 Activity 必须重写onCreate方法并在其中调用setContentView设置内容视图系统在 Activity 创建时会回调该方法。骨架代码中MainActivity通过setContentView(R.layout.activity_main)加载activity_main.xml布局Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); }2.3 activity_main.xml布局文件该 XML 表示 Activity 的布局。使用 XML 布局文件被认为是一种更好的设计实践因为它实现了关注点分离Separation of Concerns——用户界面与业务逻辑分离从而获得更清晰的设计。骨架布局包含 Material Design 库的默认界面元素App Bar 与悬浮操作按钮 FAB并引用独立的布局文件承载主要内容。2.4 build.gradle构建配置Android Studio 使用 Gradle 编译构建应用项目的每个模块都有一份build.gradle整个项目也有一份build.gradle。第三步添加拍照菜单Android 内置了菜单支持。本节添加一个选项菜单Options Menu展示应用的核心动作Take Photo。3.1 创建菜单资源文件菜单定义在菜单资源文件中。在res下新建menu目录并在其中创建main_menu.xml?xml version1.0 encodingutf-8? menu xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res-auto android:layout_widthwrap_content android:layout_heightwrap_content /menumenuXML 标签用于定义菜单项的容器。菜单由**项item或组group**组成item 表示菜单中的单个动作group 允许把多个 item 归类使其共享活动状态、可见性等属性。3.2 添加 Take Photo 菜单项?xml version1.0 encodingutf-8? menu xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res-auto android:layout_widthwrap_content android:layout_heightwrap_content item android:idid/menu_take_photo android:titlestring/menu_take_photo app:showAsActionifRoom /item /menu三个核心属性android:iditem 的唯一资源 idandroid:title描述 item 用途的字符串这里使用字符串资源定义app:showAsAction指定 item 以何种方式出现在 App Bar 中。合法取值如下表取值含义always始终作为操作项显示在 App BarcollapseActionView折叠为可展开的操作视图ifRoom若有空间则放入 App Bar本文使用的值never永远不放入 App Bar只在溢出菜单中显示withText同时显示文本与图标3.3 定义字符串资源更新strings.xml为menu_take_photo定义值为Take Photoresources string nameapp_nameMissingKidTracker/string string namemenu_take_photoTake Photo/string /resources也可以不手动编辑strings.xml将光标定位到android:title属性处在 Mac 上按optionenter、Windows 上按altenter会弹出快捷菜单选择创建新的字符串资源在弹窗中为menu_take_photo输入Take Photo并点击OK即可。3.4 把菜单挂到 MainActivity重写onCreateOptionsMenu方法用MenuInflater将菜单资源填充进回调提供的Menu对象Override public boolean onCreateOptionsMenu(Menu menu) { MenuInflater menuInflater getMenuInflater(); menuInflater.inflate(R.menu.main_menu, menu); return true; }重新运行应用即可看到菜单界面右侧的溢出按钮overflow button会展示菜单点击溢出按钮即可看到Take Photo菜单项。第四步点击Take Photo启动相机菜单集成完毕后接下来为应用添加拍照能力用户点击Take Photo时启动相机应用。4.1 处理菜单点击重写onOptionsItemSelected方法处理菜单项点击Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.menu_take_photo: takePhoto(); return true; default: return super.onOptionsItemSelected(item); } } private void takePhoto() { }当用户选中menu_take_photo菜单项时调用takePhoto()方法该方法负责调用相机 App 拍照。4.2 在 Manifest 中声明相机特性与权限要使用相机 App必须先告诉 Android。在AndroidManifest.xml中添加uses-feature标签并声明写外部存储的权限?xml version1.0 encodingutf-8? manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.shekhargulati.missingkidtracker uses-feature android:nameandroid.hardware.camera android:requiredtrue/ uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE / !-- Rest is removed for brevity. Please add uses-feature to full xml.-- /manifest声明android.hardware.camera并标记为required的作用是让 Google Play 等应用商店禁止在不支持该特性的设备上安装本应用——例如标记相机为必需特性后商店只会把应用展示给拥有相机功能的用户。4.3 takePhoto 实现用 Intent 启动相机Android 的Intent机制允许从一个组件启动另一个组件——可以是同应用的组件也可以是不同应用的组件就像这里从我们的应用启动相机 App。static final int REQUEST_IMAGE_CAPTURE 1; public static final String IMAGE_EXTENSION .jpg; private String capturedPhotoPath; private void takePhoto() { final String tag getString(R.string.app_name); final Intent takePictureIntent new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (takePictureIntent.resolveActivity(getPackageManager()) ! null) { try { final String albumName getString(R.string.app_name); final String galleryPath Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).getPath(); final String albumPath galleryPath File.separator albumName; File albumDir new File(albumPath); if (!albumDir.isDirectory() !albumDir.mkdirs()) { Log.e(tag, String.format(Unable to create album directory at [%s], albumPath)); return; } File image File.createTempFile(getImageName(), IMAGE_EXTENSION, albumDir); capturedPhotoPath image.getAbsolutePath(); takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(image)); startActivityForResult(takePictureIntent, REQUEST_IMAGE_CAPTURE); } catch (IOException e) { Log.e(tag, Exception encountered while creating file for storing image, e); } } } private String getImageName() { return IMG- new SimpleDateFormat(yyyyMMdd-HHmmss).format(new Date()) -; //import class from java.util }关键点拆解MediaStore.ACTION_IMAGE_CAPTURE标准相机拍照 Intent 动作。启动前用resolveActivity(getPackageManager())判空确保设备上确实存在能处理该 Intent 的应用避免崩溃。相册目录通过Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)得到公共图片目录再拼接应用名MissingKidTracker作为专属相册子目录若目录不存在则用mkdirs()创建失败则记录错误日志并返回。临时文件命名getImageName()用SimpleDateFormat(yyyyMMdd-HHmmss)生成时间戳前缀如IMG-20160612-103050-配合File.createTempFile在相册目录中创建.jpg临时文件并把绝对路径存入capturedPhotoPath。MediaStore.EXTRA_OUTPUT通过Uri.fromFile(image)指定照片的写入位置这样相机 App 会把照片写到我们指定的文件而非返回缩略图。startActivityForResult(takePictureIntent, REQUEST_IMAGE_CAPTURE)异步启动相机拍照完成后结果会通过onActivityResult回调返回。此时重新运行应用点击Take Photo会拉起相机并可拍照。但由于还没有编写保存照片的代码按下Save后照片会丢失回到应用主界面。第五步保存与恢复 Activity 状态拍照时Android 会启动另一个 Activity相机此时MainActivity会被停止。拍照完成后你面对的是新的 Activity 实例——如果只把状态存到实例变量里这些状态会丢失。解决方法是重写两个生命周期回调onSaveInstanceState与onRestoreInstanceState。教程使用SharedPreferencesMODE_PRIVATE私有模式持久化capturedPhotoPathOverride protected void onSaveInstanceState(Bundle outState) { SharedPreferences.Editor editor getPreferences(MODE_PRIVATE).edit(); editor.putString(capturedPhotoPath, capturedPhotoPath); editor.commit(); super.onSaveInstanceState(outState); } Override protected void onRestoreInstanceState(Bundle savedInstanceState) { SharedPreferences settings getPreferences(MODE_PRIVATE); capturedPhotoPath settings.getString(capturedPhotoPath, ); super.onRestoreInstanceState(savedInstanceState); }onSaveInstanceState在 Activity 即将被销毁/重建前被系统调用把照片路径写入 SharedPreferencesonRestoreInstanceState在重建后恢复该路径。这样即使 Activity 实例被替换拍照文件的路径也不会丢失。第六步将照片保存到相册目前可以拍照但照片尚未持久化。拍照完成后系统会回调onActivityResult其中包含操作结果。在这里把照片保存到新相册Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { final String tag getString(R.string.app_name); if (resultCode RESULT_OK) { Log.d(tag, String.format(Photo is successfully saved to [%s], capturedPhotoPath)); addPicToGallery(); setPic(); } } private void addPicToGallery() { Intent mediaScanIntent new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); File f new File(capturedPhotoPath); Uri contentUri Uri.fromFile(f); mediaScanIntent.setData(contentUri); this.sendBroadcast(mediaScanIntent); } private void setPic() { }resultCode RESULT_OK表示拍照成功随后把照片加入系统相册addPicToGallery()并设置预览setPic()。addPicToGallery的核心是发送一个Intent.ACTION_MEDIA_SCANNER_SCAN_FILE广播把照片文件的Uri放进 Intent 并sendBroadcast通知媒体扫描器把这张新照片收录进系统图库。第七步预览拍摄的照片最后一步是把捕获的照片内容设置到ImageView中实现预览。7.1 更新布局加入 ImageView把activity_main.xml更新为包含ImageView的布局?xml version1.0 encodingutf-8? RelativeLayout xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:toolshttp://schemas.android.com/tools android:layout_widthmatch_parent android:layout_heightmatch_parent android:paddingBottomdimen/activity_vertical_margin android:paddingLeftdimen/activity_horizontal_margin android:paddingRightdimen/activity_horizontal_margin android:paddingTopdimen/activity_vertical_margin tools:contextcom.shekhargulati.missingkidtracker.MainActivity ImageView android:idid/image_preview android:layout_widthmatch_parent android:layout_heightmatch_parent android:adjustViewBoundstrue android:scaleTypecenter android:visibilityvisible / /RelativeLayout7.2 用 BitmapFactory 解码并显示private void setPic() { final String tag getString(R.string.app_name); try { ImageView imageView (ImageView) this.findViewById(R.id.image_preview); imageView.setVisibility(View.VISIBLE); final BitmapFactory.Options opt new BitmapFactory.Options(); opt.inSampleSize 2; opt.inJustDecodeBounds false; Bitmap bitmap BitmapFactory.decodeFile(capturedPhotoPath, opt); imageView.setImageBitmap(bitmap); } catch (Exception e) { Log.e(tag, Error encountered while doing image preview, e); } }这里通过BitmapFactory.Options设置了inSampleSize 2——表示按 1/2 比例降采样解码这是避免把高分辨率照片整图加载进内存导致 OOM 的常用手段。解码出的Bitmap通过setImageBitmap设置到ImageView。第八步处理三星等设备上的配置变更部分设备如作者使用的三星手机在拍照或旋转屏幕时会触发配置变更configuration change导致 Activity 被重建。在AndroidManifest.xml中为MainActivity声明android:configChangesorientation|screenSize让 Activity 自行处理方向与屏幕尺寸变化而不是销毁重建?xml version1.0 encodingutf-8? manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.shekhargulati.missingkidtracker uses-feature android:nameandroid.hardware.camera android:requiredtrue/ uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE / application android:allowBackuptrue android:iconmipmap/ic_launcher android:labelstring/app_name android:supportsRtltrue android:themestyle/AppTheme activity android:name.MainActivity android:configChangesorientation|screenSize intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /application /manifest第九步修复照片旋转 90 度问题做出上述修改后拍照会发现照片被旋转了 90 度——这是 Android 上一个历史悠久的Image Rotated by 90 degreesbug设备方向信息通常以 EXIF 元数据形式写入 JPEG但解码显示时并未按该信息旋转。作者在三星手机上拍到的示例就是这种情况。9.1 用 ExifInterface 读取方向修复方案是使用ExifInterface读取 EXIF 中的方向属性再用Matrix旋转 Bitmap 后显示private void setPic() { final String tag getString(R.string.app_name); try { ImageView imageView (ImageView) this.findViewById(R.id.image_preview); imageView.setVisibility(View.VISIBLE); Bitmap bitmap createScaledBitmap(capturedPhotoPath); imageView.setImageBitmap(bitmap); } catch (Exception e) { Log.e(tag, Error encountered while doing image preview, e); } } public Bitmap createScaledBitmap(String pathName) throws IOException { final BitmapFactory.Options opt new BitmapFactory.Options(); opt.inSampleSize 2; opt.inJustDecodeBounds false; Bitmap bitmap BitmapFactory.decodeFile(pathName, opt); File file new File(capturedPhotoPath); Bitmap rotatedBitmap; ExifInterface exif new ExifInterface(file.getPath()); int rotation exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); int rotationInDegrees exifToDegrees(rotation); Matrix matrix new Matrix(); //import this class from android.graphics if (rotation ! 0f) { matrix.preRotate(rotationInDegrees); } rotatedBitmap Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true); return rotatedBitmap; } private static int exifToDegrees(int exifOrientation) { if (exifOrientation ExifInterface.ORIENTATION_ROTATE_90) { return 90; } else if (exifOrientation ExifInterface.ORIENTATION_ROTATE_180) { return 180; } else if (exifOrientation ExifInterface.ORIENTATION_ROTATE_270) { return 270; } return 0; }逻辑拆解依然用inSampleSize 2降采样解码 Bitmap用ExifInterface从照片文件读取TAG_ORIENTATION默认ORIENTATION_NORMALexifToDegrees把 EXIF 方向常量映射为旋转角度ORIENTATION_ROTATE_90 → 90、ORIENTATION_ROTATE_180 → 180、ORIENTATION_ROTATE_270 → 270否则为 0若方向非 0用Matrix.preRotate预旋转对应角度再通过Bitmap.createBitmap生成旋转后的 Bitmap。修复后再运行应用拍照照片即可正常方向显示。常见坑注意类导入教程代码中部分类存在多个同名候选包作者特意在代码后以注释标出了正确包名例如SimpleDateFormat从java.util导入、Matrix从android.graphics导入。Android Studio 通常能自动导入大部分类但存在多选时会选错包请务必留意代码注释中的包名提示避免导入错误的类导致编译失败。小结与后续本周完成了 Missing Kid Tracker 的拍照闭环创建项目 → 理解骨架 → 添加菜单 → 启动相机拍照 → 保存/恢复状态 → 存入相册 → 预览 → 修复 90 度旋转 bug。截至目前应用可以拍照、把照片存入应用专属相册并在同一 Activity 中预览。该系列的第二部分见 26-android-part2/README.md它在此基础上新增独立的PreviewActivity负责预览、引入 Glide 图片加载库并为PreviewActivity添加分享菜单把拍摄的图片分享出去。整个系列都属于 52-technologies-in-2016 博客计划的一部分后续将继续完善上传后端、匹配失踪儿童报告等能力。赞分享示例工程【免费下载链接】52-technologies-in-2016Lets learn a new technology every week. A new technology blog every Sunday in 2016.项目地址https://gitcode.com/gh_mirrors/52/52-technologies-in-2016点击查看免费下载相关推荐Pixelle-Video三步实现AI短视频创作革命告别繁琐剪辑Pixelle Video三步实现AI短视频创作革命告别繁琐剪辑 你是否曾经想过制作专业短视频却被复杂的剪辑软件劝退你是否羡慕那些精美的AI生成内容却人工智能AI 应用音视频媒体生成如何使用lemonbar打造极简主义桌面状态栏5分钟快速上手指南如何使用lemonbar打造极简主义桌面状态栏5分钟快速上手指南 lemonbar是一款轻量级、基于xcb的桌面状态栏工具以其极简设计和高效性能著称。本指南Elwing Item Tracker 插件实战用 React 与 Cloudscape 构建 Aurora Serverless 工作项追踪应用Elwing Item Tracker 插件实战用 React 与 Cloudscape 构建 Aurora Serverless 工作项追踪应用 本指南以示例工程教程后端创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考