学习版游戏自制云存档同步的方法探索以及总结

作为一名资深足球游戏玩家,最近重燃对《实况足球2021》的热情,搞了个带大补的版本,确实爽,却在多设备存档同步问题上掉进了坑。通过两天”痛并快乐着”的折腾,最终找到完美解决方案,现整理给喝油们参考。 ■ 硬件环境 ROG Ally掌机(外出便携)+ MSI gp76笔记本(家用主力)构成了我的双屏足球战场。这两个Win11平台都装载了打了大补的PES2021赛季更新版。 我的爱机们 ■ 痛点溯源 游戏存档默认存储在C:\Users\用户名\Documents\KONAMI\eFootball PES 2021 SEASON UPDATE\数字ID\文件夹。由于学习版缺乏云存档功能,导致: 双设备存档各自为战 手动复制存在覆盖风险 游戏进度无法延续 ■ 试错全记录(前5个踩坑方案) 方案0 - 正版授权(沉没成本) 各平台密钥均价$800+,瞬间击穿钱包防御。 太贵了 方案1 - 手工搬运(原始主义) 直接复制save000001。bin等系列文件。耗时费力不说,曾在机场手滑覆盖2小时比赛记录,血泪教训。 方案2 - 黑盒云盘(伪云方案) 宣称自动同步,实测: 仅支持5个文件 与黑盒语音服务耦合 不太好用,放弃 方案3 - 微存档(氪金劝退) 专业存档管理器的PUA套路: 免费版可本地备份 云同步需月付,感觉有点不靠谱,暂不考虑 云存档要给钱 方案4 - GitHub私仓(极客陷阱) .git仓库看似优雅却暗藏杀机: 需手动pull/push操作 无代理时上传龟速(25MB存档需5min) 冲突解决机制缺失 ■ 破局方案:百度网盘+符号链接 最终选择百度网盘超级会员方案,实现原理: 百度网盘带的同步功能 📁 系统架构 本地存档 → 符号链接 → 百度同步目录 → 云端实时同步 🖥关于符号连接 ai是这样说的 ⚙ 实施步骤(Win系统通用) 定位存档路径: C:\Users\用户名\Documents\KONAMI\eFootball PES 2021 SEASON UPDATE 创建云同步目录(建议路径): D:\BaiduSync\PES2021_SAVE 迁移存档文件: 剪切原存档文件夹内容至云目录 管理员模式启动CMD执行: mklink /J “C:…\数字ID” “D:\BaiduSync\PES2021_SAVE” 配置百度网盘: 设置→高级设置→文件夹自动备份→添加云目录 🔧 注意要点 需先卸载原有存档文件再创建软链 关闭客户端”完成同步弹窗提示”(否则游戏过程频繁弹窗) 🔄 同步效果实测 ROG Ally到笔记本端延迟<20秒 修改时间戳完全同步 电脑上的存档进度 ally 同步过来啦 经过测试,累计完成34场跨设备联赛对战,存档同步成功率达 90%,有时候百度云会下载失败,多点两下就好了。此方案不仅适用于实况足球,同样可拓展到FIFA、FM等需多设备存档同步的体育类游戏。 后记:这次折腾最值得的投资不是网盘会员,而是掌握mklink这个命令。当软链接贯通物理路径和云存储的瞬间,颇有种开任意门连接数字世界的奇妙快感。

2025/8/19
articleCard.readMore

鸿蒙next中web组件和navigation的一个bug以及处理方案

问题现象 在这种情况下,把controller从上层传递到最底层的web页面,用它来打开网页 刚开始一切正常,但是需要执行一些js的时候,就会出现controller未绑定的提示 特别是在连续打开好几个页面之后,从最顶部页面返回,在中间的页面就会出现该问题 例如a-b-c-d,此时从d返回c,再在c页面使用controller执行js方法,就会出现controller未绑定到webview的提示 但是此时,controller肯定是绑定过的,出现该问题是不合理的。 现象报错 简要代码 homepage -HomeNavigation homenavigation -Navigation -stack -mywebview(在这里new webviewcontroller()) mywebview -mywebcore(传递controller) mywebcore -web(最终的使用controller) -button1 打开新页面 -button2 返回上一页并执行一段自定义js 现在我不断的点击button1使用navigation打开多个新页面,再不断点击返回,即可出现问题 排查步骤 1.首先排查是否在往navigation中添加和移除页面的过程中,是否发生了对象的销毁,经过对比对象id号,发现id未变化,说明对象不存在销毁的问题 2.排查该报错出现的原因,经过网络搜索,发现未绑定问题只会出现在new了controller但是还未传递给web组件的时候,但是我们已经打开了网页,所以肯定是绑定过的 3.经过1,2的排查,我们可以推断,这是一个系统级的bug,所以我们需要特殊管理controller 尝试解决 新建一个全局的map,当有新页面打开的时候,把对应的controller放入该map,当页面销毁时,把controller从中移除 代码改造,经过如下改造,我们已经对controller进行了单独的管理 解决代码 import web_webview from '@ohos.web.webview'; import { HashMap } from '@kit.ArkTS'; export let controllers:HashMap<string,web_webview.WebviewController> = new HashMap<string,web_webview.WebviewController>() 效果测试 改造之后,再看我们的项目运行情况,发现每次都能顺利的执行了,不再会出现未绑定的情况 结论 目前将webviewcontroller作为页面跳转参数传递,在navigation中存在bug,会导致对象的绑定状态混乱,令其无法成功调用方法。 我们需要单独使用全局的管理策略,这样可以暂时规避controller状态混乱的情况 问题本质 该问题的本质还是鸿蒙next系统在传递controller的过程中,该对象的绑定状态会丢失,判定为一个系统bug,需要等后续修复。 本文正在参加华为鸿蒙有奖征文征文活动

2024/8/30
articleCard.readMore

ChatGPT是怎么帮我写ios代码的

背景 博主只会安卓开发,最近使用chatGPT3.5把一个安卓项目转换成了ios项目并成功运行实现功能 应用的主要功能 这个安卓应用主要是拿来看比赛回放的,有时候太迟的足球比赛不想熬夜,但是又不想被剧透看全场回放,就写了个这个应用,这次讨论的重点并不是应用本身,如果大家有兴趣可以评论区留言,我把应用发出来 应用的主要页面有下面几个,1.登录爱奇艺账号页面,2.球队列表页面,3.播放页面 其中播放页面最复杂,要实现以下几个功能 根据选择的球队,加载搜索比赛结果页面,并把回放和广告列表隐藏,把比赛列表文字放大 点击了比赛列表,跳转到播放页面 等待播放页面加载完成,自动点击全屏元素按钮 在全屏开始播放之后,加载左右两个快放按钮,长按调用网页上的快速播放元素,松开恢复正常速播放 开始转换 文件结构差异 Android的页面主要由两个部分组成 XML布局文件,决定了页面上有哪些元素,以及如何摆放,实现布局部分 Activity文件,决定了页面上元素的各种行为,实现逻辑部分 但是在ios上,并不是这样,chatgpt告诉我,转换出来的东西是一个继承自UIViewController的类,里面同时包含了上面的两个部分内容 要转换哪些功能 应用主要功能就是操作webview加载页面,然后通过调用js的方法来操作页面上的元素,所以webview的加载和js的加载就是这次转换的重点 问题有两个 怎么用webview加载内容 怎么操作webview中的内容 我和gpt的对话 由于gpt没法自动合并xml和activity的代码,所以我只有让它先转xml,得到布局的controller内容 列表页面 比如这个布局 <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".PlayerActivity"> <FrameLayout android:visibility="gone" android:id="@+id/mFlVideoContainer" android:layout_width="match_parent" android:layout_height="match_parent"> <FrameLayout android:id="@+id/mFlVideoContainer1" android:layout_width="match_parent" android:layout_height="match_parent"/> <LinearLayout android:orientation="horizontal" android:id="@+id/ll_container" android:layout_marginBottom="50dp" android:layout_width="match_parent" android:layout_height="match_parent"> <Button android:layout_weight="1" android:layout_width="0dp" android:background="#00000000" android:id="@+id/back_bt" android:layout_height="match_parent"/> <Button android:layout_weight="3" android:layout_width="0dp" android:visibility="invisible" android:enabled="false" android:background="#00000000" android:id="@+id/pause_bt" android:layout_height="match_parent"/> <Button android:layout_weight="1" android:layout_width="0dp" android:background="#00000000" android:id="@+id/go_bt" android:layout_height="match_parent"/> </LinearLayout> </FrameLayout> <WebView android:layout_width="match_parent" android:layout_height="match_parent" android:id="@+id/webview" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout> 让它给我转成ios版本,转出来是下面这样的 import UIKit import WebKit class PlayerViewController: UIViewController { // UIView elements let videoContainer: UIView = { let view = UIView() view.isHidden = true return view }() let videoContainer1: UIView = { let view = UIView() return view }() let backButton: UIButton = { let button = UIButton() button.backgroundColor = UIColor.clear return button }() let pauseButton: UIButton = { let button = UIButton() button.isHidden = true button.isEnabled = false button.backgroundColor = UIColor.clear return button }() let goButton: UIButton = { let button = UIButton() button.backgroundColor = UIColor.clear return button }() let webView: WKWebView = { let webView = WKWebView() return webView }() override func viewDidLoad() { super.viewDidLoad() // Set up constraints for each element videoContainer.translatesAutoresizingMaskIntoConstraints = false videoContainer1.translatesAutoresizingMaskIntoConstraints = false backButton.translatesAutoresizingMaskIntoConstraints = false pauseButton.translatesAutoresizingMaskIntoConstraints = false goButton.translatesAutoresizingMaskIntoConstraints = false webView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(videoContainer) view.addSubview(webView) NSLayoutConstraint.activate([ videoContainer.topAnchor.constraint(equalTo: view.topAnchor), videoContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor), videoContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor), videoContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor), videoContainer1.topAnchor.constraint(equalTo: videoContainer.topAnchor), videoContainer1.leadingAnchor.constraint(equalTo: videoContainer.leadingAnchor), videoContainer1.trailingAnchor.constraint(equalTo: videoContainer.trailingAnchor), videoContainer1.bottomAnchor.constraint(equalTo: videoContainer.bottomAnchor), backButton.topAnchor.constraint(equalTo: videoContainer.bottomAnchor), backButton.leadingAnchor.constraint(equalTo: view.leadingAnchor), backButton.bottomAnchor.constraint(equalTo: view.bottomAnchor), backButton.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.25), pauseButton.topAnchor.constraint(equalTo: videoContainer.bottomAnchor), pauseButton.leadingAnchor.constraint(equalTo: backButton.trailingAnchor), pauseButton.bottomAnchor.constraint(equalTo: view.bottomAnchor), pauseButton.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.5), goButton.topAnchor.constraint(equalTo: videoContainer.bottomAnchor), goButton.leadingAnchor.constraint(equalTo: pauseButton.trailingAnchor), goButton.bottomAnchor.constraint(equalTo: view.bottomAnchor), goButton.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.25), webView.topAnchor.constraint(equalTo: view.topAnchor), webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), webView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) } } 遇到的坑 gpt生成的代码并不是直接可以用的 gpt生成出来的代码看似正确,但是有时候运行就会发现缺少内容,比如有的变量没有定义就使用了,你得追问它,才会告诉你 虽然告诉你了,但是大概率会重新生成一份代码,这又是一个坑,这个重新生成的代码又有概率和第一次完全不同 这坑需要有一定的编程能力,自己阅读下gpt给的代码,才可以实现想要的功能,不然就会被它坑 对于稍微复杂一点的布局,gpt给出的方案并不总能成功实现 由于给出的ios界面是用代码实现的,给出的安卓布局是xml的,有些时候ios并没有安卓的布局控件,gpt给出的方案运行起来就会出现错位等情况,这时候也需要人工调试 对于项目配置方面,gpt并不能给出精准的解答 这点主要体现在其中一个功能上,从ios应用中拉起爱奇异体育并播放,chatgpt给出的方案不管怎么设置都无法实现,因为它理解成了我需要从其他应用跳转到自己的ios应用 其实这个问题在网上一搜就能处理,这也是一个坑,不能全部相信gpt的话 ios和安卓webview的差异导致的功能实现不同 由于爱奇艺的限制,使用ios的wkwebview无法进行播放,在安卓却可以播放,所以需要调整功能,不能直接在应用内播放,需要走scheme的方式跳转爱奇艺app做播放 对于wkwebview无法播放的问题,我问了gpt很久,每次它也非常配合,给出各种各样的方案,花了很多时间,但是都无效 这个问题也需要自己想一想,用自带的浏览器打开一下页面,把agent换成pc端,发现也无法播放,所以确认是wkwebview的问题,而不是实现方案的问题 总结 在使用gpt的时候,也不能无脑相信它,有时候它明明不会或者不懂,也会给出答案,这种答案看起来没问题,其实是错误的,会把方向带偏,需要特别留意 使用gpt可以让我这样一个完全不懂ios的开发人员在短时间内完成简单的开发工作,放在以前我至少需要学习基础的ios开发知识,学习完了之后,我还需要在开发遇到问题的时候一个点一个点的搜,大概率是没有相同问题的,需要自己从各种各样的结果中提炼 现在gpt帮我完成了这个步骤,让这次开发变得轻松了很多 gpt对开发人员的帮助确实是非常大,粗略估计,这次的小应用开发,为了节省了60%以上的时间,相信随着技术的发展,这个工具会越来越便利,这次使用的是免费的gpt3.5都如此强大,收费的4.0真是不敢想象

2024/2/2
articleCard.readMore

Android项目cicd流程总结(使用jenkins)

没有cicd之前我们都是怎么做的 相信做安卓开发的都做过这些事 手动运行单元测试,根据报错改代码 检查代码风格,根据报错改代码 构建apk包,发给测试,有时候还得打很多个 接收测试的反馈,改bug, 重复之前的步骤 把apk放到ftp或者其他地方去发布 是不是想到这一套流程,头都大了,虽然每一步都不难,但是连起来都手工操作就很繁琐 像这些手动流程固定的事,我们完全就可以交给机器来做,让我们有更多时间做点别的事,没错,这就是今天要说的cicd 什么是cicd,以及在cicd过程中包含了哪些步骤 一般来说,安卓开发的CI/CD流程包括以下几个阶段:代码提交、代码检查、编译构建、单元测试集成测试、部署发布、用户反馈。 在代码提交阶段,开发者将自己的代码推送到远程仓库,例如Git或SVN,并触发CI/CD工具或平台例如Jenkins或Travis CI等 在代码检查阶段,CI/CD工具或平台会对代码进行静态分析和风格检查,例如使用SonarQube或Checkstyle等。 在编译构建阶段,CI/CD工具或平台会使用Gradle或Maven等工具对代码进行编译和打包,生成APK文件 在单元测试阶段,CI/CD工具或平台会使用JUnit或Espresso等框架对代码进行单元测试,并生成测试报告。 在集成测试阶段,CI/CD工具或平台会使用Appium或Selenium等框架对应用进行集成测试,并生成测试报告 在部署发布阶段,CI/CD工具或平台会将APK文件上传到内部服务器或外部平台,例如蒲公英或Google Play等,并通知相关人员。 在用户反馈阶段,开发者可以通过Bugly或Firebase等工具收集用户的反馈和错误信息,并根据需要进行修复和更新 通过以上几个步骤,我们可以把以前的app构建流程从手动变为自动,而且可以通过不断以非常低的成本的重复这个过程,提高我们的项目质量,这就是cicd带给我们的自信 今天我们来通过jenkins来实现上面的几个步骤 安装配置jenkins 本文讨论的主要是在windows环境下安装jenkins 从jenkins官网下载对应的安装包即可 安装过程很简单但是需要提供一个账号,就像下图显示的界面,这个账号需要有权限 打开开始菜单,搜索本地安全策略,选择本地策略、用户权限分配,在右侧的策略中找到作为服务登录,双击打开。点击添加用户或组,在输入框中填入你的账户的名字,单击检查名称,如果加上了下划线,则说明没有问题,如果输入的用户不存在,则会跳出来一个找不到名称的对话框。 这里需要注意一点,windows家庭版默认是没有本地安全策略的,需要用一些技巧把它开启,如下: 1. 在桌面上单击右键,选择“新建”->“文本文档”。 2. 将文本文档重命名为“OpenLocalSecurityPolicy.bat”。 3. 右键单击“OpenLocalSecurityPolicy.bat”,选择“编辑”。 4. 将以下命令复制并粘贴到文本编辑器中: @echo off pushd "%SystemRoot%\system32" findstr /c:"[SR] Cannot repair member file" %windir%\logs\cbs\cbs.log >%userprofile%\Desktop\sfcdetails.txt start ms-settings:windowsdefender start ms-settings:windowsupdate start ms-settings:windowsupdate-history start ms-settings:windowsupdate-options start ms-settings:storagesense start ms-settings:storagesense-diagnostics start ms-settings:storagesense-configurecleanup start ms-settings:storagesense-changehowwesave start ms-settings:storagesense-runstoragecleanupnow start ms-settings:storagesense-storageusage start ms-settings:storagesense-changestoragesavelocations start ms-settings:backup start ms-settings:backup-advancedsettings start ms-settings:backup-addalocaldriveornetworklocation start ms-settings:backup-managebackups start ms-settings:backup-moreoptions start ms-settings:dateandtime start ms-settings:regionlanguage start ms-settings:regionlanguage-languagepacks start ms-settings:regionlanguage-speech start ms-settings:regionlanguage-keyboards start ms-settings:regionlanguage-morespeechservicesonline start ms-settings:speech start ms-settings:speech-microphoneprivacysettings 5. 保存并关闭文本编辑器。 6. 双击“OpenLocalSecurityPolicy.bat”文件,以打开本地安全策略。 修改默认根地址到其他盘符 默认情况下,jenkins的主目录都是在c盘,如果这样,我们使用中产生的数据都是在c盘,用过windows的都知道,数据放在c盘是很危险也是很让人不爽的一件事,我们可以通过修改jenkins的主目录方法来把数据放到其他盘 在成功安装了jenkins并解锁之后,我们可以配置环境变量JENKINS_HOME,地址就是我们想改的目录, 然后修改jenkins.xml <env name="JENKINS_HOME" value="%LocalAppData%\Jenkins.jenkins"/> 改为 <env name="JENKINS_HOME" value="E:\jenkins"/> 配置常用的插件 在第一次启动jenkins的时候,会让你选择安装哪些插件,这时候直接选择推荐的插件就好,包含了一些常用插件,比如git等等,如下图 配置针对于android的环境 android sdk 见下图 gradle – Dashboard -> Manage Jenkins -> Global Tool Configuration中的Gradle配置gradle路径即可 jdk – Dashboard -> Manage Jenkins -> Global Tool Configuration中的JDK配置jdk路径即可 git – Dashboard -> Manage Jenkins -> Global Tool Configuration中的Git installations配置git路径即可 配置Android的具体job信息 新建一个freestyle的item,在里面做以下几步: 配置git仓库地址以及构建分支 设置构建触发器(定时构建) – 找到构建触发器,勾选build periodically,在编辑框里按照规则设置构建时间,在某天的某个时段自动构建,比如45 9-15/2 * * 1-5,虽然可以提交一次就构建一次,但是不建议这么做。构建表达式的规则见下图,可以根据自己的需要写表达式。 添加构建步骤,打包出apk,如下图在build step中触发 配置构建后步骤,自动把apk包上传到ftp或者其他地方 在Jenkins的项目中,选择“构建”->“增加构建步骤”->“执行shell”或“执行Windows批处理命令” 一个上传ftp的例子 ftp -n <<EOF open ftp.example.com user username password cd /remote/directory put /local/file bye EOF 配置邮件通知 在构建完成之后,特别是失败的时候,我们希望收到一封邮件告诉我们构建失败了,快去处理,我们可以通过以下步骤来实现 在Jenkins中安装Email Extension Plugin插件,可以在插件管理中搜索并安装。 在Jenkins的系统管理中,配置邮件服务器的地址,用户名,密码和端口。如果使用的是QQ邮箱或者163邮箱,还需要获取邮箱授权码并使用授权码作为密码。 在Jenkins的项目中,选择“构建后操作”->“增加构建后操作”->“Editable Email Notification”。 在邮件通知的配置中,填写收件人,抄送人,邮件主题,邮件内容等信息。可以使用一些变量来自定义邮件内容,例如BUILDSTATUS表示构建状态,BUILDS​TATUS表示构建状态, {BUILD_URL}表示构建链接等。 这里特别要注意的是,上面的配置地址和授权码需要在job的设置里面进行,在全局配置有可能发不出邮件 配置单元测试和代码检查 我们还需要在运行前执行代码lint检查和单元测试,也需要配插件,插件名字是JUnit和Warnings Next Generation 参考上面 配置Android的具体job信息 中的配置,添加lint和单元测试的任务 配置单元测试插件和lint插件,主要指定报告文件的位置,见下图 把单元测试的结果放到邮件的附件中去,配置见下图,也可以放些别的东西 一劳永逸,使用docker把上面的配置做到随时随地使用 上面的步骤完成之后,我们就能自动构建,上传apk什么的了,但是每次换台机器我们都得再配一次,想下就很累,这时候我们就可以用docker,创建一个容器,把上面这些操作放在容器里面,在新环境里面拉个镜像,创建容器跑起来,就ok啦,关于怎么用docker,就需要大家自己去搜索学习了 最后放张图吧,jenkins真好用啊

2023/4/21
articleCard.readMore

Android使用Logger开发App本地日志记录功能

前言 记录日志是一个App常用的功能,很多软件都会有,它能帮助开发者快速确定问题位置,可以说是App维护的基础。 项目现状 之前我们的项目没有记录日志到本地的功能,每次排查问题都非常的费劲,需要本地能复现才能解决问题,但是有些问题并不是我们本地能复现的,比如客户环境在内网,无法访问,或者只有特定手机出现问题,这时候我们就束手无策了。 开发目的 为了更好的手机问题信息,并且让用户简单的去获取日志,发给开发者,我们急需记录日志到本地的功能,并且提供开关给客户,让客户方便的进行操作。 探索过程 经过搜索对比,最终选取了Logger日志库作为开发的基础,这个库比较知名,出了问题也有很多资料,而且使用简单,功能全面,甚至提供了基础的记录日志到本地的能力,应该能满足我们的需求。 这个库的地址是https://github.com/orhanobut/logger 但是这个库有一些小问题需要我们通过开发去补全。 记录的目录需要改变,默认的目录都在同一个,不然有其他的app使用了Logger记录日志功能,就会混在一起 默认的情况是这样的 需要变成这样 记录的文件名字需要改变,标记日期,否则用户不知道哪个日志是最新的,不好发过来 莫默认情况是这样的,我们想加上日志标识,让用户看得懂 变成这个样子 需要有一个开关来控制是否开启记录,并且在一定时间之后自动停止记录,并删除超期的日志文件,这一步主要是为了app的安全性,否则通过日志会泄露一些重要的信息 比如这样 为了单独分析网络请求情况,我们还需要把网络请求信息单独放到一个文件里面,也就是一个logger同时往两个文件输出日志 具体步骤 集成logger,并按照官方文档实现记录日志到本地功能 这一步很简单,照着官方文档做就行了 修改logger的TextDiskLogStrategy和TextFormatStrategy文件,实现日志的分文件、路径存放 这一步需要把logger的源码进行一些改造,具体来说分为两个部分 1.TextDiskLogStrategy – 继承FormatStrategy,在内部可以通过log方法给日志信息加一些附加信息,比如时间戳或者换行,并且定义一个handlerthread并获取handler,方便子线程使用它来往文件里写日志 2.TextFormatStrategy–继承自LogStrategy,使用handler进行日志的写入工作,通过what类型的不同,写入到不同的目录 通过tag不同,发送不同的message 内部还有个处理的handler,用来接收并真正执行日志文件的写入,根据不同类型写入不同文件,并且对日志使用加密算法加密,保证客户不能直接读取文件内容,确保安全性。至于如何加密,就可以根据实际情况使用不同的算法进行操作。 写一个工具类,通过开关来控制是否进行日志的输出操作,并定时关闭开关 每次app启动的时候,扫描日志目录,判断文件创建时间,如果超过三天,就删除 使用一个shareprefrence的值来保存是否开启调试模式,并记录开启时间,每次应用启动的时候检测这两个值,并比较,如果超过时间就把debug变量还原,这样就不会继续输出日志了 关于安全性的考量 由于日志里面记录的信息有可能涉及一些敏感的东西,我们必须做好防护,上面说到的三天之后删除,24小时自动关闭记录,加密日志都是为了防止用户敏感信息泄露出去。 这个功能只能在用户知情的情况下,主动开启,并且只能使用有限的时间。 回顾总结 这个功能开发起来并不是非常困难,但是结合产品的过程可以引发一些思考 为了获取信息的便捷性,这个功能不可或缺,否则很多问题光靠开发复现费时费力 为了记录的清晰,要把网络请求和logcat日志分开记录 为了用户信息的安全,需要设置开关和自动关闭功能,并且给日志加密 以前做一个功能的时候,考虑的可能就是实现这个功能,记录日志这个功能其实非常简单 但是要考虑到安全性和易用性,做起来还是有很多的工作,写代码的过程往往就是简单功能,复杂实现 为了堵住所有可能的口子,往往需要对一个功能有更全面的思考,开始思考这些问题,也是编程经验提升的一种象征。

2022/10/12
articleCard.readMore

一张启动图引发的思考--探索.9图原理和应用场景

引子 小u啊,我们应用启动的时候有一段白屏,不雅观,你给整个启动图上去,给,这里是资源图片 换好了 嗯,不错不错,咦,这个小米fold怎么显示了两个logo? 啊?这。。。我来看看 是这样的,activity启动图和启动背景图标一起显示了,但是启动图片又不适配fold这种狭长的屏幕,而且下半部分由于没有背景,是透明的,所以就显示了两个图标,一个启动图标,一个启动图图片图标,请看示意图 那怎么解决呢? 有几个办法,1.让启动图拉伸,覆盖下部分区域,但是会有形变,不够优雅,2.给imageview设置一个白色背景,让它不透明,3.使用android studio的。9图片制作工具,让空白部分拉伸,内容不拉伸,非常优雅。 好,那你先试试.9图片吧 .9简介 我知道看这篇文章的朋友肯定对.9图或多或少有点了解,就简单介绍下就行 .9图可以通过设置来让图片某一部分拉伸 其他部分不拉伸 还可以控制图片里面显示内容的区域 这就是.9图的简介 够简了吧 如果想看更详细的说明可以移步谷歌官方的说明 大体上也是这意思 这个是官方的文档https://developer.android.com/studio/write/draw9patch?hl=zh-cn 这是.9图在三种拉伸情况下的例子,纵向,横向,横纵双向。 .9图制作 网上很多文章说最简单是用as内置工具来做 但是网上文章好多都是很早的 都是老版本工具 让我们来看看这工具现在啥样 好像也不是很简单啊 这些东西做什么用的第一次看确实会很蒙,这里介绍下这个工具 Zoom:调整图形在绘制区域中的缩放级别。 Patch scale:调整图像在预览区域中的比例。 Show lock:当鼠标悬停在图形的不可绘制区域上时以直观方式呈现。 Show patches:预览绘制区域中的可拉伸图块(粉色为可拉伸图块),如上面的图 2 所示。 Show content:突出显示预览图像中的内容区域(紫色为允许绘制内容的区域),如图 2 所示。 Show bad patches:在拉伸时可能会在图形中产生伪影的图块区域周围添加红色边框,如图 2 所示。如果您消除所有不良图块,已拉伸图像的视觉连贯性将得以保持。 怎么生成.9图不是重点,网上文章不要太多,最主要就是绘制左边和上面的黑线,左边黑线控制的是哪里可以被上下拉伸,上面黑线控制哪里可以被水平拉伸,可以有多条,右边和下面的黑线控制了哪里可以放内容,不在这个区域的内容不会被显示。 当然 还有很多办法生成.9图 单独工具或者在线工具都行 看个人喜好了。 .9图原理 上面的介绍都很大众化 那么为啥.9图这么神奇呢?它是什么原理呢,这个好像没什么人说过,这里也简单阐述下。 主要是四条黑线 分为两组 左边和上面的黑线 负责判定图片哪个部分可以被拉伸 右边和下面的黑线 负责确定图片内部展示内容的区域 比如这个图是个聊天气泡 内容是一堆文字 大概就是下面这个图的样子 分成了九个区域 我们把他们编号成1-9,这几个区域对应情况如下 1 3 7 9 号区域,不在两条黑线区域内,不会被拉伸 4 6 号区域,只在左侧黑线范围内,可以被上下拉伸 2 8 号区域,只在上面黑线区域内,会被左右拉伸 5号区域,同时在上和左区域内,会被上下左右拉伸 回到刚才的问题 为啥处理之后就能控制拉伸和内容了? 首先我们发现处理之后的图片后缀还是原来的 证明没有变成其他格式 但是名字里加上了.9 那猜测是不是在文件里加上了一些额外信息 用.9作为标识 图片系统处理拉伸的时候就去读这些信息 那么 到底加了什么信息呢 又是怎么判断和使用的呢 下面一一道来 首先 我们给图片加了什么信息 我们看看官方怎么描述.9图的 NinePatchDrawable 图形是一种可拉伸的位图,可用作视图的背景。Android 会自动调整图形的大小以适应视图的内容。NinePatch 图形是标准 PNG 图片,包含一个额外的 1 像素边框。必须使用 9.png 扩展名将其保存在项目的 res/drawable/ 目录下。 可以看到,.9图本质上还是png图片,但是加了1像素边框,且名字里加了个.9。 让我们来看看什么是png图片,以及它的数据构成 The PNG format provides a portable, legally unencumbered, well-compressed, well-specified standard for lossless bitmapped image files. A PNG file consists of a PNG signature followed by a series of chunks. This chapter defines the signature and the basic properties of chunks. png的签名块后面跟了两个数据块critical chunk和ancillary chunks,其中critical chunk包含关键数据,也是每个图片必须有的,ancillary chunks包含一些辅助信息,png如果不识别这些辅助块,可以忽略它打到向下兼容的目的。 签名块就是一个八个字节的十六进制码,用来标识图片。 重点来看看数据块的布局 名称 | 字节数 | 说明 | | ———————– | —- | ——————————— | | 长度(Length) | 4字节 | 指定数据块中数据区域的长度,长度不可超过(231-1)个字节 | | 数据块类型码(Chunk Type Code) | 4字节 | 数据块类型码由ASCII字母(A-Z和a-z)组成的”数据块符号” | | 数据块数据(Chunk Data) | 可变长度 | 存储数据块类型码指定的数据 | | 循环冗余检测(CRC) | 4字节 | 存储用来检测是否文件传输有误的循环冗余码 看到这个可以猜测,我们添加的黑色边框就是往辅助块里面加了内容,在展示的时候识别添加的信息,达到控制哪些地方伸缩的目的。 让我们看看一张图片被弄成.9之后加了些什么内容。 原图数据如下 .9数据如下 可以看到,变成.9图片之后,多了5个IDAT块,而且参数里面的长宽都增加了2像素,而且图片大小也增加了不少从80kb增加到了180kb,我们可以猜测到,这几个块里面记录的数据就是我们生成.9图时画的那几条线生成的了。 其次 这些信息在图片发生拉伸时怎么被识别和使用的 这就涉及到android怎么加载一张图片的问题了,当然这些操作都是native层进行的,经过代码跟踪,我们发现有这样一个类 ** NinePatchPeeker.cpp** 我们来看看这个类里面的readChunk方法干了些什么 bool NinePatchPeeker::readChunk(const char tag[], const void* data, size_t length) { if (!strcmp("npTc", tag) && length >= sizeof(Res_png_9patch)) { Res_png_9patch* patch = (Res_png_9patch*) data; size_t patchSize = patch->serializedSize(); if (length != patchSize) { return false; } // You have to copy the data because it is owned by the png reader Res_png_9patch* patchNew = (Res_png_9patch*) malloc(patchSize); memcpy(patchNew, patch, patchSize); Res_png_9patch::deserialize(patchNew); patchNew->fileToDevice(); free(mPatch); mPatch = patchNew; mPatchSize = patchSize; } else if (!strcmp("npLb", tag) && length == sizeof(int32_t) * 4) { mHasInsets = true; memcpy(&mOpticalInsets, data, sizeof(int32_t) * 4); } else if (!strcmp("npOl", tag) && length == 24) { // 4 int32_ts, 1 float, 1 int32_t sized byte mHasInsets = true; memcpy(&mOutlineInsets, data, sizeof(int32_t) * 4); mOutlineRadius = ((const float*)data)[4]; mOutlineAlpha = ((const int32_t*)data)[5] & 0xff; } return true; } 我们看到这里处理了npTc、npLb、npOl三个数据块,当判断有npTc这个数据块的时候,系统就认为这是.9图片,就会进行下一步处理。 npTc这个数据又是从哪来的呢?" class="reference-link" href="#">那npTc这个数据又是从哪来的呢? 从上面内容我们知道已经添加了一些额外信息,我们发现官方的说明里有一句,要把.9图放在src/drawable目录下,这是因为在编译的时候,aapt会在发现图片名字符合.9图规则的时候,把四周的黑色边框提取出来,放在npTc数据块里面。 接下来我们看看结构体Res_png_9patch里面有什么。 /** * This chunk specifies how to split an image into segments for * scaling. * * There are J horizontal and K vertical segments. These segments divide * the image into J*K regions as follows (where J=4 and K=3): * * F0 S0 F1 S1 * +-----+----+------+-------+ * S2| 0 | 1 | 2 | 3 | * +-----+----+------+-------+ * | | | | | * | | | | | * F2| 4 | 5 | 6 | 7 | * | | | | | * | | | | | * +-----+----+------+-------+ * S3| 8 | 9 | 10 | 11 | * +-----+----+------+-------+ * * Each horizontal and vertical segment is considered to by either * stretchable (marked by the Sx labels) or fixed (marked by the Fy * labels), in the horizontal or vertical axis, respectively. In the * above example, the first is horizontal segment (F0) is fixed, the * next is stretchable and then they continue to alternate. Note that * the segment list for each axis can begin or end with a stretchable * or fixed segment. * * ... * * The colors array contains hints for each of the regions. They are * ordered according left-to-right and top-to-bottom as indicated above. * For each segment that is a solid color the array entry will contain * that color value; otherwise it will contain NO_COLOR. Segments that * are completely transparent will always have the value TRANSPARENT_COLOR. * * The PNG chunk type is "npTc". */ struct alignas(uintptr_t) Res_png_9patch { int8_t wasDeserialized; uint8_t numXDivs, numYDivs, numColors; uint32_t xDivsOffset, yDivsOffset, colorsOffset; int32_t paddingLeft, paddingRight, paddingTop, paddingBottom; enum { // The 9 patch segment is not a solid color. NO_COLOR = 0x00000001, // The 9 patch segment is completely transparent. TRANSPARENT_COLOR = 0x00000000 }; ... inline int32_t* getXDivs() const { return reinterpret_cast<int32_t*>(reinterpret_cast<uintptr_t>(this) + xDivsOffset); } inline int32_t* getYDivs() const { return reinterpret_cast<int32_t*>(reinterpret_cast<uintptr_t>(this) + yDivsOffset); } inline uint32_t* getColors() const { return reinterpret_cast<uint32_t*>(reinterpret_cast<uintptr_t>(this) + colorsOffset); } } 从注释我们看出,一张图片被分成了很多个部分,s开头的代表可以拉伸的,f开头的代表不能拉伸,还有一些颜色的数据,他们共同构成了这个结构体,x,y轴上这些可以拉伸和不可拉伸的部分分别放在xdiv和ydivs数组里,同时内容的显示区域也由几个padding来存放,到这里,就把.9图的额外信息都给读出来了。 既然已经获得了这些额外的信息,那么绘制的时候,系统就可以根据这些信息判断怎么拉伸一张.9图了,绘制调用了NinePatchDrawable,最终也会走到native层去处理这些数据。 到此为止,制作.9和解析.9的过程就分析完毕了,一张.9图背后居然这么复杂,还是挺让人意外的,果然是学无止境啊。 .9图应用以及一个小坑 看完了怎么生成和它的原理 该我们去使用它了 回到开头 用我们新做的酷炫.9图解决问题吧! 代码张这样 效果张这样 为什么我们的.9图没有按照预期的拉伸,而是把内容也给拉伸了?因为.9图的特性问题,它只支持拉伸,如果一个图片本身就比imageview大了,那就不会去拉伸,所以我们的.9图本身太大了,也就不存在拉伸的说法了,那肯定是无效的。 那我们再来尝试把图片尺寸改小,这下可以拉伸了吧,来看看效果。 可以看到,下面图标被遮挡住了,但是内容也太小了吧,所以用.9图来做splash screen背景不是个好主意。 那么,到底哪些场景可以用它呢?总结一下就是,一张图片,角落不能拉伸,其他部分可以随着内容的增多随便拉伸的情况,像这样。 简单来说,就是聊天气泡一类的,如果一张图片的主体是内容要放缩的话,是不适合的,因为.9图只会拉伸特定部分,主体部分会维持原大小。 比如我们上面做背景图这种情况。 总结 .9图并不适合作为全屏展示页面,要不太大了没有拉伸效果,要不内容太小了不美观 .9图适合做一些纯色拉伸不形变的背景,比如聊天气泡和按钮背景这种 在android中,.9图得放到sr/drawable目录才行,系统才会去生成对应数据块,才可以被解析,否则就要自己去生成 普通图片由于没有额外的数据,是不能达到.9图这样的效果的

2022/10/8
articleCard.readMore

跟我一起玩Paging3

什么是Paging3 Paging 库可帮助您加载和显示来自本地存储或网络中更大的数据集中的数据页面。此方法可让您的应用更高效地利用网络带宽和系统资源。Paging 库的组件旨在契合推荐的 Android 应用架构,流畅集成其他 Jetpack 组件,并提供一流的 Kotlin 支持。 以上来自安卓官网,简单点来说,就是帮你处理列表分页加载工作的一个官方框架,从名字也能看出来。而且已经到达了第三个版本,paging3。 如何使用Paging3 使用paging3总共有以下几个步骤 定义PagingSource – 需要在这里处理如何获取数据和如何处理页面的号码 设置 PagingData 流 – 需要把pagingsource的实例和一些其他参数传递给构造器,比如每次加载的条目数量等等 定义 RecyclerView 适配器 – 需要继承PagingDataAdapter并提供DiffUtil.ItemCallback的实例 在界面中显示分页数据 – 在页面中绑定adapter 通过以上几个步骤我们就可以使用paging3了,如果想看具体的步骤,可以移步官网,我们今天不用沉溺于具体的细节当中 Paging3有哪些方便的地方 毫无疑问,使用paging3会带给我们一些开发效率上的收益,回想一下我们以前是如何处理分页列表的,检测列表是否到达底部,使用自己持有的页数去做网络请求,把数据添加到原有的集合,更新列表。 是不是简单的回想一下就感觉到不少坑了呢,没错,我们自己做列表分页加载不仅繁琐无趣,还容易出问题。 这些工作交给paging3之后,我们只需要关注逻辑部分就可以了,如何获取数据,想以怎样的形式展示数据,这才是我们需要关注的焦点。 总结一下paging3的优点,替代了大量的琐碎分页加载代码,让逻辑和页面的耦合度降低,由官方的工程师保证了加载的正确性和效率。 Paging3有哪些不方便的地方 相信看了上面的优点之后大家都迫不及待的想去把以前的老代码干掉,换成这种看起来方便又省事的工具了。 但是在换之前,我这里先提几条使用paging3容易遇到的坑,帮助大家综合考量是不是真的要去使用这个工具框架。 对数据源有一定要求,有时候会提示indexoutofbounds,这种情况需要检查你提供的数据源,而不是adapter 使用paging3官方教程上需要继承PagingDataAdapter,这个问题可以通过装饰模式来解决,具体可以参考这篇文章 paging3的pagingdataadapter虽然提供了refresh方法,但是调用之后数据源并没有获得通知,需要手动调用一次notifyDataSetChanged再去调用refresh才能让列表刷新之后继续触发分页加载,这是一个已知的bug,希望后期得到解决 paging3的列表中由于写法的原因,对于多种不同item的展示存在一定困难性,如果现在已经有多item逻辑的话,修改起来比较麻烦 Paging3的原理分析 关于paging3的原理,网上的文章已经很多了,相信大家也不想看到我再重复长篇大论,这里放一张官方架构图给大家 简单的总结一下原理就是,数据源把数据放入pager,pager使用flow(kotlin协程提供的对标rxjava的工具)将数据传递到adapter并处理和维护页数,adapter里面再使用DiffUtil.ItemCallback结合上自身的一些api对新来的数据做比对,再进行数据的处理,最后反应到页面上。

2022/10/8
articleCard.readMore

Android中使用ASPECTJ进行用户操作路径跟踪与日志搜集

编写初衷 在Android App开发中,出现了bug和崩溃测试们就会提着手机上门,然后开发一顿操作,bug消失了,测试们又只有进行大量的操作来复现。 这样的情况想必大家都遇到过,更极端的是线上出现了bug,虽然可以设置崩溃日志上传来收集崩溃日志,但是用户是怎么操作的,我们也只能靠猜 为什么不能有一个工具,记录下最近打开了什么界面,点击了哪些按钮,并且记录到本地,方便开发们查看呢?于是笔者有了写这个工具的想法。 我们来缕一缕:我们的工具要实现的几个功能 记录activity的打开和关闭情况 记录fragment的打开和关闭情况 记录控件的点击情况 把这些日志存放到本地,方便上传和查阅 用什么技术实现 有了目标,我们就可以考虑怎样实现了,大概有以下几种方式实现操作收集 这种技术有个名称,叫做埋点,因为我们就像埋地雷一样在指定位置设置代码,当触发的时候打日志并收集 人工手动埋点 使用特殊控件,原理与1类似,不过工作转移给了写控件的人 使用aop框架进行自动化埋点,比如aspectj或者asm AccessibilityService配合ContentDescription进行埋点 选择哪项技术呢,我们逐项分析 虽然最简单,但是工作量大,容错错埋漏埋,放弃 工作量巨大,使用者学习成本高,放弃 使用简单,通用,考虑使用 需要开发时添加ContentDescription并且某些机型无法开启AccessibilityService相关设置,有局限性 综合考虑之后,我们选择了使用aop的方式埋点,我们现在有两个选择,aspectj或者asm,但是有于asm过于灵活学习和使用均有一定门槛,所以我们选择简单易用的aspectj 基本思路 通过对aspectj的学习,我们发现可以对onclick方法以及生命周期方法进行切点设置,至于什么是切点,将会在后续部分解释 日志方面,我们采用了开源项目ZLog进行记录,该框架实现了日志写到文件,以及对文件的数量和大小管理功能,比较方便 对于最后一项,由于使用itembinding等框架的时候会遇到捕获不到具体类的情况,所以按需要进行捕捉 关于Aspectj 这个工具最关键的还是怎样使用aspectj去做埋点,关于aspectj的使用网上有很多例子,这里就不赘述了,下面贴出一个简单的使用文件,大家配合注解看一下,如果还是不太明白建议先去搜一些基本使用的帖子 @Aspect//标注这个类是一个aspectj需要处理的类 class AspectJTest { @Pointcut("execution(void _internalCallbackOnClick(..))")//切点,检测返回值为void的_internalCallbackOnClick方法 fun onBindingClick() { } @Around("onBindingClick()")//在合适的时机对切点进行处理 @Throws(Throwable::class) fun onClickMethodBinding(joinPoint: ProceedingJoinPoint) { val args = joinPoint.args//获取方法参数 if (args.size >= 1 && args[1] is View) { val view = args[1] as View//获取view val id = view.id //处理该view或者打印日志 } joinPoint.proceed() //执行原来的代码 } } 需要解决的问题 虽然看起来aspectj用起来很简单,但还是有一些问题需要我们处理 怎样获取点击事件的控件id和它所在的类名 怎么获取在list中的点击事件,并获取它的位置信息 关于第一点,我们普通使用setOnClick设置是可以拿到的,但是当使用databinding等技术的时候情况就比较复杂了,我们如果使用以下代码,就会获取到生成类里的一个onclick文件,根本不知道哪个页面的控件被点击了 @Around("onClick()") @Throws(Throwable::class) fun onClickMethodAround(joinPoint: ProceedingJoinPoint) { //获取点击事件view对象及名称,可以对不同按钮的点击事件进行统计 val target = joinPoint.target var className = "" if (target != null) { className = target.javaClass.name if (className.contains("$")) { className = className.split("\\$").toTypedArray()[0] } if (className.contains("_ViewBinding")) { className = className.split("_ViewBinding").toTypedArray()[0] } }//看似可以获取,但是实际使用itembinding的时候只能拿到生成的onclick文件名,没有其他信息 joinPoint.proceed() //执行原来的代码 } 要处理这个问题,我们需要监听生成类中的点击事件,在笔者这里,这个事件方法名叫做_internalCallbackOnClick,我们添加一个对它的切点就可以了 对于问题2,我们可以通过获取view的父view,并判断它的类型,再通过强转来解决,代码如下 val view = args[0] as View var index = -1 if (view.parent is RecyclerView) { index = (view.parent as RecyclerView).getChildPosition(view) } 输出 当完成了埋点之后,我们就可以把日志输出到文件里了,这里我使用了一个叫ZLog的库,不过由于有一些年头了,我直接复制了文件到项目中。如果大家有兴趣可以去看看ZLog代码仓库并点个star 总结与完全代码 解决了上面的问题,我们就可以检测到想要的信息并保存了,以后测试来找我们的时候排查bug又多了一点点线索,线上用户报bug的时候也不用胡乱猜测了,是不是感觉很有用呢?(可能并没有 如果想看完全的代码,可以到github上的这里来看看如果能顺便点个star就再好不过了,如果有任何问题,也可以留言讨论

2022/10/8
articleCard.readMore

彻底摆脱数据线——远程ADB调试小工具开发过程记录

前排提示 本文中所描述工具只在ROOT过的设备上有效,如果不感兴趣的朋友可以点赞后退出了,也可以去github给我点个星星,源码地址在这里 写在开始前 每次重启测试机都要连接usb才能开始远程adb调试,真麻烦,能不能弄一个软件点一下就能开始远程调试呢? 如果对什么是adb远程调试不熟悉的朋友,可以搜索下adb tcpip 5555这条命令 为了解决每次重启都要连接一次电脑的问题,经过了大量的网络搜索,终于解决了这个问题。 本文记录了从提出问题到解决问题的全过程。 提问环节 1.为什么要做这个工具? adb提供的远程调试工具不香吗,为什么还要没事找事折腾半天来做这个工具 2.怎么实现不连接Usb启动adb的远程连接模式? 通过往常的经验来看,各种实现远程调试的插件都需要连接一次usb,看起来不用usb好像不太现实,但是经过调研,确实是可以的 3.找到方法了之后,执行哪些命令才能达到我们的目的? 看似简单的几行命令,到了Android手机上怎么就老跑不通呢 ** 带着上面这几个问题,我们开始今天的探索吧** 为什么 博主有一台很棒的测试机,它叫小6,小6有很多优点,速度快,体积小,操作简单。 既然小6如此优秀,为什么还会有这篇博客呢,其实小6有一个致命问题,电池不耐用,毕竟已经退役成测试机了嘛。 每次博主要远程调试的时候,小6都需要重新连一次电脑,对于一个电脑只有两个usb口的博主来说,这个过程非常的痛苦,特别是小6的电池又不耐用,每天晚上都会自动关机睡一觉。 所以博主我想,能不能把连电脑这一个步骤转移到手机上执行呢,看着命令也挺简单的,只有一行 感觉我上我也行嘛,说做就做,马上来试试 adb tcpip 5555 adb tcpip 5555 做了些什么 这是一条神奇的命令,手机开启调试模式,连上电脑之后,运行它,我们就可以用ip连接手机,不用数据线调试程序了 经过一番查找,我们发现,这条命令发送了几条命令给手机 1.设置service.adb.tcp.port为5555 2.重启手机上的adb 搜到这里,博主激动了起来,看起来好像很简单啊,我在手机上也这么操作不就行了,马上去找找怎么在手机上执行命令行 在手机上运行它们! Android上有很多命令行工具,博主随便找了一个,叫做Termux,运行了上面两条命令 报错了,not found 是什么鬼?经过一番搜索,原来是没有root权限 ** 那我们只有去root手机了** 我们来看看root之后再运行怎么样 在获取root权限之后,报错消失了,太好了! 我们继续运行接下来两个命令 运行成功了,我们现在就可以在电脑上用下面的命令来连接手机了 adb connect 手机的ip:5555 在自己的代码里运行命令 目前为止,一切都很顺利,我们不想用第三方的app,自己写一个程序,点一下,玩一年,不用数据线一整年 怎么在代码里运行shell命令呢? 经过又一番搜索,博主找到了几种方法,最终选定了下面这种,su可以替换成其他命令 Runtime.getRuntime().exec("su") 让我们行动起来,运行三行命令吧 好像没有作用,尴尬 对比三方应用和自己的命令 通过对比,我们发现第三方的客户端的用户好像不太一样啊,经过搜索,我们发现是它调用了一下/system/bin/sh 我们也得给自己的代码加上才行 我们看看改完之后的命令 运行检测一下,成功啦! 优化一下使用体验 现在我们实现了基本的功能,但是不太友好,对于没有root的用户我们要提醒他,你不能用这个玩意,对于已经root的用户,操作成功了我们要提示他现在的ip是什么,用什么命令连接 这个很简单,弄完就像下面这样,其中的ip我们要去获取手机现在的ip地址 放出代码 前面比比了那么多,代码拿出来看看啊,别急,下面就是完整的代码 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager val ip: String = Formatter.formatIpAddress(wm.connectionInfo.ipAddress) AlertDialog.Builder(this).setMessage("该程序仅工作在拥有root权限设备上,如果没有root,请连接usb并使用下列命令进行远程调试\nadb tcpip 5555\nadb connect $ip:5555").setPositiveButton( "确认" ) { _, _ -> val commands = arrayListOf( "/system/bin/sh", "setprop service.adb.tcp.port 5555", "stop adbd", "start adbd" ) try { RunAsRoot(commands) } catch (e: IOException) { e.printStackTrace() } }.setCancelable(false).show() } private fun RunAsRoot(cmds: ArrayList<String>) { val p = Runtime.getRuntime().exec("su") val os = DataOutputStream(p.outputStream) for (tmpCmd in cmds) { os.writeBytes( """ $tmpCmd """.trimIndent() ) } os.writeBytes("exit\n") os.flush() val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager val ip: String = Formatter.formatIpAddress(wm.connectionInfo.ipAddress) AlertDialog.Builder(this).setMessage("设置完成,请在同一局域网下使用以下命令连接设备\nadb connect $ip:5555").setPositiveButton( "确认" ) { _, _ -> finish() }.setCancelable(false).show() } } 相信看完上面的介绍,代码应该不难看懂,如果不想自己写,可以去这里找到源码,直接复制粘贴一把梭 问题回顾 让我们来看看最开始的几个问题,总结一下 1.为什么要做这个工具? 我们的测试机可以重启后不连接usb启动远程调试模式啦! 2.怎么实现不连接Usb启动adb的远程连接模式? 在代码里运行命令就可以了,用Runtime.getRuntime().exec(你的命令) 3.找到方法了之后,执行哪些命令才能达到我们的目的? 下面这些 su /system/bin/sh setprop service.adb.tcp.port 5555 stop adbd start adbd 写在最后 终极问题,能不能不用root实现这套操作?不能,即使设置端口成功了,重启手机上adb的操作也需要su权限,所以不root是不行的,遗憾 那么大家下次再见

2022/10/8
articleCard.readMore

自己实现一个Android网络图片加载器

在Android开发中,我们经常会用到各种各样的图片加载框架来帮助我们加载网络图片,那有没有想过自己实现一个呢 本文记录了实现一个图片框架的整个流程,以及对代码的优化整理过程,文章比较长,如果只对其中的一部分感兴趣直接跳转到相应部分即可 提问环节 把大象装进冰箱分几步?是不是感觉在这初秋时节更加凉爽了呢 好吧,下面开始提问 大多数图片都是网络获取的,如何加载网络图片到本地imageview 图片有可能很大,怎么在加载前进行压缩 每次加载同一个url的图片都要请求网络,可以做一个缓存来防止过度请求吗 头像想显示圆角,怎么通过加载器显示圆角图片 在图片没加载出来之前,会显示placeholder图,加载完成之后切换会闪烁一下,很不美观,怎么处理 写好了代码,但是用起来很麻烦,想用设计模式优化一下,让它用起来像glide一样,减少学习成本,该怎么做 如果只想看其中某一部分,可以直接跳转到相应段落,完整的代码放到了我github上,点击这里可以直达 那么,女士们先生们,下面就开始我们的旅程吧 如何加载网络图片到本地imageview 这个功能很简单,怎么实现我不管,明天上线 这个问题确实很简单,开启一个线程,从网络请求中获取图片流,再组装成bitmap,加载到imageview里面去就行了,看代码 private fun getNetImg(imageView: ImageView): Bitmap? { var bitmap: Bitmap? = null val url = URL(params.imageURL) val connection = url.openConnection() as HttpURLConnection connection.requestMethod = "GET" connection.connectTimeout = 10000 val code = connection.responseCode if (code == 200) { val inputStream = connection.inputStream bitmap = bitmapCompressor.getCompressBitmap(inputStream, imageView)//获取到亚索(压缩,谐音梗扣钱)图片 inputStream.close() } else { Log.e("NetImageView", "server error") } return bitmap//获取到bitmap了,返回,直接扔给imageview用就行 } 怎么在加载前进行压缩 网络图片有时候会很大,我们的ImageView就那么小一点,图片很大浪费了我们宝贵的内存资源,怎么办呢?我们勤劳的劳动人民有很多办法,没错,就是图片压缩,在加载前我们先获取到图片的宽高以及ImageView的宽高,根据比例来压缩图片,再加载,就没问题啦。 关键利用了 val options = BitmapFactory.Options()这个Options里有个方法,叫inSampleSize,设置比例 还有一个参数,叫inJustDecodeBounds,只返回图片尺寸 这俩配合,就能让我们压缩图片了 关键代码: val options = BitmapFactory.Options() options.inJustDecodeBounds = true BitmapFactory.decodeStream(sizeInputStream, null, options) options.inSampleSize = getInSampleSize(options, imageView)//getInSampleSize就是算比理的函数 options.inJustDecodeBounds = false -----getInSampleSize的代码在下面----- private fun getInSampleSize(options: BitmapFactory.Options, imageView: ImageView): Int { var inSampleSize = 1 val (viewWidth, viewHeight) = getImageViewSize(imageView)//这个函数是什么?别急,它的代码就在下面,算ImageView尺寸的 val outWidth = options.outWidth val outHeight = options.outHeight if (outWidth > viewWidth || outHeight > viewHeight) { val widthRadio = (outWidth / viewWidth).toDouble().roundToInt() val heightRadio = (outHeight / viewHeight).toDouble().roundToInt() inSampleSize = if (widthRadio > heightRadio) widthRadio else heightRadio } return inSampleSize } //计算ImageView尺寸的函数 private fun getImageViewSize(imageView: ImageView): Pair<Int, Int> { val displayMetrics = context.resources?.displayMetrics val layoutParams = imageView.layoutParams var viewWidth = imageView.width var viewHeight = imageView.height if (viewWidth <= 0) { viewWidth = layoutParams.width } if (viewWidth <= 0) { viewWidth = imageView.maxWidth } if (viewWidth <= 0) { viewWidth = displayMetrics?.widthPixels!! } if (viewHeight <= 0) { viewHeight = layoutParams.height } if (viewHeight <= 0) { viewHeight = imageView.maxHeight } if (viewHeight <= 0) { viewHeight = displayMetrics?.heightPixels!! } return Pair(viewWidth, viewHeight)//返回一个pair,可以同时返回算好的宽和高 } 看完了关键代码,是不是很简单呢,我们来看看完整的代码 //图片压缩类 class BitmapCompressor(private val context:Context) { //计算imageview尺寸 private fun getImageViewSize(imageView: ImageView): Pair<Int, Int> { val displayMetrics = context.resources?.displayMetrics val layoutParams = imageView.layoutParams var viewWidth = imageView.width var viewHeight = imageView.height if (viewWidth <= 0) { viewWidth = layoutParams.width } if (viewWidth <= 0) { viewWidth = imageView.maxWidth } if (viewWidth <= 0) { viewWidth = displayMetrics?.widthPixels!! } if (viewHeight <= 0) { viewHeight = layoutParams.height } if (viewHeight <= 0) { viewHeight = imageView.maxHeight } if (viewHeight <= 0) { viewHeight = displayMetrics?.heightPixels!! } return Pair(viewWidth, viewHeight) } //计算压缩比例 private fun getInSampleSize(options: BitmapFactory.Options, imageView: ImageView): Int { var inSampleSize = 1 val (viewWidth, viewHeight) = getImageViewSize(imageView) val outWidth = options.outWidth val outHeight = options.outHeight if (outWidth > viewWidth || outHeight > viewHeight) { val widthRadio = (outWidth / viewWidth).toDouble().roundToInt() val heightRadio = (outHeight / viewHeight).toDouble().roundToInt() inSampleSize = if (widthRadio > heightRadio) widthRadio else heightRadio } return inSampleSize } //获取压缩后的bitmap fun getCompressBitmap(input: InputStream, imageView: ImageView): Bitmap? { val stream = ByteArrayOutputStream() val bufferSize = 1024 try { val buffer = ByteArray(bufferSize) var len: Int while (input.read(buffer).also { len = it } > -1) { stream.write(buffer, 0, len) } stream.flush() } catch (e: IOException) { e.printStackTrace() } val sizeInputStream: InputStream = ByteArrayInputStream(stream.toByteArray()) val bitmapInputStream: InputStream = ByteArrayInputStream(stream.toByteArray()) val options = BitmapFactory.Options() options.inJustDecodeBounds = true BitmapFactory.decodeStream(sizeInputStream, null, options) options.inSampleSize = getInSampleSize(options, imageView) options.inJustDecodeBounds = false return BitmapFactory.decodeStream(bitmapInputStream, null, options) } } 这个功能稍微复杂一点点,不过也挺简单,相信大家都表示问题不大 可以做一个缓存来防止过度请求吗 当然可以,这个功能也非常简单,我们用文件来进行缓存,怎么确保缓存唯一呢——使用图片url来当文件名即可 这个功能太简单了,直接看代码吧 //先来一个接口,为了以后可能的更多缓存形式做准备 interface ImageCache{ fun cacheImg(bitmap: Bitmap?,name:String); fun getCacheImage(name:String): Bitmap? } //文件缓存类 class FileImageCache(private val context: Context): ImageCache { //储存缓存文件 override fun cacheImg(bitmap: Bitmap?,name:String) { if (bitmap != null) try { val file = File(context.cacheDir, name) val out = FileOutputStream(file) bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) out.flush() out.close() } catch (e: IOException) { e.printStackTrace() } } //根据名字获取文件 override fun getCacheImage(name:String): Bitmap? { val file = File(context.cacheDir, name) var bitmap: Bitmap? = null if (file.length() > 0) { val inputStream: InputStream = FileInputStream(file) bitmap = BitmapFactory.decodeStream(inputStream) } return bitmap } } 这个就不用多解释了吧,只是一个文件存取而已 怎么通过加载器显示圆角图片 现在我们的图片加载器以及初具雏形了,但是好多头像都有圆角,我们怎么给这个加载器添加这个功能呢? 这个稍微复杂一点,需要用到canvas的绘制功能,大体思路是这样的 绘制一个圆角的rect 设置xfermode为PorterDuffXfermode(PorterDuff.Mode.SRC_IN) 绘制bitmap 上代码 object BitmapRounder{ fun getRoundedCornerBitmap(bitmap: Bitmap, round: Float): Bitmap? { return try { val output = Bitmap.createBitmap( bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888 ) val canvas = Canvas(output) val paint = Paint() val rect = Rect( 0, 0, bitmap.width, bitmap.height ) val rectF = RectF( Rect( 0, 0, bitmap.width, bitmap.height ) ) paint.isAntiAlias = true canvas.drawARGB(0, 0, 0, 0) paint.color = Color.BLACK canvas.drawRoundRect(rectF, round, round, paint)//重点,绘制一个圆角的矩形 paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)//也是重点,设置xfermode为src_in val src = Rect( 0, 0, bitmap.width, bitmap.height ) canvas.drawBitmap(bitmap, src, rect, paint)//绘制bitmap output } catch (e: Exception) { bitmap } } } 圆角功能也就完成了 在图片没加载出来之前,会显示placeholder图,加载完成之后切换会闪烁一下,很不美观,怎么处理 一句话,用动画 怎么用动画,这里就要引入一个类,drawable,想必大家都不陌生,我们直接自定义一个drawable,在里面执行显示内容的切换动画即可 看代码 class MyAnimationDrawable(private val bitmap: Bitmap, private val placeholder: Bitmap) : Drawable(),Animatable { private var mValueAnimator = ValueAnimator() var placeholderAlpha = 250 var paint:Paint var isFinish = false init { mValueAnimator = ObjectAnimator.ofInt(this,"placeholderAlpha",0) mValueAnimator.duration = 1200 mValueAnimator.startDelay = 1000 paint = Paint() mValueAnimator.addUpdateListener { // 监听属性动画并进行重绘 invalidateSelf() } } override fun draw(canvas: Canvas){ if (!isFinish) { val rectF = RectF( 0f, 0f, bounds.width().toFloat(), bounds.height().toFloat() ) //w和h分别是屏幕的宽和高,也就是你想让图片显示的宽和高 paint.reset() canvas.drawBitmap(bitmap, null, rectF, paint) paint.alpha = placeholderAlpha canvas.drawBitmap(placeholder, null, rectF, paint) paint.reset() } if (placeholderAlpha == 0)isFinish = true } override fun setAlpha(p0: Int) { } @SuppressLint("WrongConstant") override fun getOpacity(): Int { return 1 } override fun setColorFilter(p0: ColorFilter?) { } override fun isRunning(): Boolean { return mValueAnimator.isRunning } override fun start() { mValueAnimator.start() } override fun stop() { } } 用这个类替代bitmap,即可实现从一个图片到另一个图片的动画效果切换,是不是很简单呢 写好了代码,但是用起来很麻烦,想用设计模式优化一下,让它用起来像glide一样,减少学习成本,该怎么做 核心,使用builder模式 关于这个问题,我们就要去看看glide是怎么做的了 经过对glide的分析,我们发现它是用了builder模式,我们也整一个,我们还发现它的builder不直接设置加载器,而是设置了一个param类,最后加载的时候再传入参数,所以我们也这么玩 下面是我们的builder代码 class RequestBuilder { data class ImageParams( var roundPx: Float = 0f, val emptyPlaceHolderId: Int = -1, var placeHolder: Int = emptyPlaceHolderId, var imageURL: String = "", var imageMaxSideSize: Float = -1f, var useCache: Boolean = true, var context: Context? = null) private var params = ImageParams() fun withContext(context: Context): RequestBuilder { params.context = context return this } fun useCache(useCache: Boolean): RequestBuilder { params.useCache = useCache return this } fun placeholder(placeholder: Int): RequestBuilder { params.placeHolder = placeholder return this } fun load(url: String): RequestBuilder { params.imageURL = url return this } fun round(round:Float): RequestBuilder { params.roundPx = round return this } fun into(imageView: ImageView) { val realLoader = RealImageLoader(params) realLoader.loadImage(imageView) } fun adjustImageScale(imageMaxSideSize:Float): RequestBuilder { params.imageMaxSideSize = imageMaxSideSize return this } } 是不是so easy呢 我们来看看具体使用的时候长什么样 ImageLoader.withContext(this) .placeholder(R.drawable.holder) .load("https://www.baidu.com/img/flexible/logo/pc/result.png") .useCache(true) .into(image) 是不是感觉和glide一模一样呢 最后 前面说了那么多,可能有人想说,你的加载图片代码呢,不急,这里就放出全部的代码 class RealImageLoader(private val params: RequestBuilder.ImageParams) { private var fileCache = FileImageCache(params.context!!)//缓存类 private var bitmapCompressor = BitmapCompressor(params.context!!)//压缩类 //加载图片的方法 fun loadImage(imageView: ImageView) { if (params.context == null) { Log.e("ImageLoader", "Empty context") return }//参数为空,直接返回 var bitmap: Bitmap? = null loadPlaceHolderImg(imageView)//加载placeholder GlobalScope.launch(Dispatchers.IO) {//开启一个协程,用来处理图片 try { if (params.useCache) { bitmap = fileCache.getCacheImage(getCacheFileName())//从缓存查找 } if (bitmap == null) { bitmap = getNetImg(imageView)//缓存没有,从网络获取 } if (bitmap != null) bitmap = roundBitmap(bitmap)//根据设置,进行圆角处理 } catch (e: IOException) { e.printStackTrace() Log.e("NetImageView", "Load image error") } withContext(Dispatchers.Main) {//切换到主线程,进行图片加载 if (bitmap != null) { imageView.setImageBitmap(bitmap) /* val placeholder = BitmapFactory.decodeResource( params.context?.resources, params.placeHolder ) val drawable = MyAnimationDrawable(bitmap!!, placeholder!!) imageView.setImageDrawable(drawable) drawable.start()*/ } } } } private fun loadPlaceHolderImg(imageView: ImageView) { if (params.placeHolder != params.emptyPlaceHolderId) { var bitmap = BitmapFactory.decodeResource(params.context?.resources, params.placeHolder) bitmap = roundBitmap(bitmap) imageView.setImageBitmap(bitmap) } } private fun roundBitmap(bitmap: Bitmap?): Bitmap? { var roundBitmap = bitmap if (params.roundPx != 0f) { if (roundBitmap != null) roundBitmap = BitmapRounder.getRoundedCornerBitmap(roundBitmap, params.roundPx) } return roundBitmap } private fun getNetImg(imageView: ImageView): Bitmap? { var bitmap: Bitmap? = null val url = URL(params.imageURL) val connection = url.openConnection() as HttpURLConnection connection.requestMethod = "GET" connection.connectTimeout = 10000 val code = connection.responseCode if (code == 200) { val inputStream = connection.inputStream bitmap = bitmapCompressor.getCompressBitmap(inputStream, imageView) if (bitmap != null) bitmap = changeScale(bitmap) if (params.useCache) fileCache.cacheImg(bitmap, getCacheFileName()) inputStream.close() } else { Log.e("NetImageView", "server error") } return bitmap } private fun getCacheFileName(): String { var name = "" val strings = params.imageURL.split("/") for (s in strings) { name += s } return name } private fun changeScale(bitmap: Bitmap): Bitmap { var mBitmap = bitmap if (params.imageMaxSideSize > 0) { var height = bitmap.height var width = bitmap.width if (width >= height) { width = params.imageMaxSideSize.toInt() height = (params.imageMaxSideSize * (bitmap.height.toFloat() / bitmap.width)).toInt() } else { height = params.imageMaxSideSize.toInt() width = (params.imageMaxSideSize * (bitmap.width.toFloat() / bitmap.height)).toInt() } mBitmap = zoomImg(bitmap, width, height) } return mBitmap } private fun zoomImg(bm: Bitmap, newWidth: Int, newHeight: Int): Bitmap { val width = bm.width val height = bm.height val scaleWidth = newWidth.toFloat() / width val scaleHeight = newHeight.toFloat() / height val matrix = Matrix() matrix.postScale(scaleWidth, scaleHeight) return Bitmap.createBitmap(bm, 0, 0, width, height, matrix, true) } } 至此,我们的图片加载工具已经做好啦,大家可以根据自己的需求愉快的使用了,希望这篇文章能对你有帮助,我们下次再见

2022/10/8
articleCard.readMore