引言


现在的一个Android设备出货,比如手机,平板和车机,都肯定是经过了很多次的测试。

软件的品质起码是有一个基本的保障。

但是有个实际情况是,当手机在市场上面发售以后,测试是没有办法模拟出来用户的所有操作的。

市场上的消费者包括小白用户,当手机出现各种异常时,用户只能通过设备商售后处理。

而现在售后一般对ROOT,和自己烧一些不是官方发布的软件版本是不保修的。

Android考虑到了这一点,所以增加了救援模式的功能。

可以在严重时,提供给用户恢复出厂设置的选项。

这也就是本文分析的内容。


救援级别

针对不同问题的严重级别,系统定制了不同的救援等级,说明如下:

    @VisibleForTestingstatic final int LEVEL_NONE = 0;@VisibleForTestingstatic final int LEVEL_RESET_SETTINGS_UNTRUSTED_DEFAULTS = 1;@VisibleForTestingstatic final int LEVEL_RESET_SETTINGS_UNTRUSTED_CHANGES = 2;@VisibleForTestingstatic final int LEVEL_RESET_SETTINGS_TRUSTED_DEFAULTS = 3;@VisibleForTestingstatic final int LEVEL_FACTORY_RESET = 4;

我们可以看到,从0 -> 4其实就是随着严重的等级不断的提升,到了4,其实就是factory的操作。


APP级别救援实现

流程图如下:

我们来看下具体的实现过程:
PWD:frameworks/base/core/java/com/android/internal/os/RuntimeInit.java

    /*** Handle application death from an uncaught exception.  The framework* catches these for the main threads, so this should only matter for* threads created by applications. Before this method runs, the given* instance of {@link LoggingHandler} should already have logged details* (and if not it is run first).*/private static class KillApplicationHandler implements Thread.UncaughtExceptionHandler {private final LoggingHandler mLoggingHandler;@Overridepublic void uncaughtException(Thread t, Throwable e) {try {ensureLogging(t, e);// Don't re-enter -- avoid infinite loops if crash-reporting crashes.if (mCrashing) return;mCrashing = true;// Try to end profiling. If a profiler is running at this point, and we kill the// process (below), the in-memory buffer will be lost. So try to stop, which will// flush the buffer. (This makes method trace profiling useful to debug crashes.)if (ActivityThread.currentActivityThread() != null) {ActivityThread.currentActivityThread().stopProfiling();}// Bring up crash dialog, wait for it to be dismissedActivityManager.getService().handleApplicationCrash(mApplicationObject, new ApplicationErrorReport.ParcelableCrashInfo(e));} catch (Throwable t2) {if (t2 instanceof DeadObjectException) {// System process is dead; ignore} else {try {Clog_e(TAG, "Error reporting crash", t2);} catch (Throwable t3) {// Even Clog_e() fails!  Oh well.}}} finally {// Try everything to make sure this process goes away.Process.killProcess(Process.myPid());System.exit(10);}}

KillApplicationHandler是一个内部类,我们这边只截取了一个方法KillApplicationHandler
当APP出现异常,被Kill掉后,会进入到该方法中去进行处理。
这里会调用ActivityManager.getService().handleApplicationCrash来进行后续的处理。
PWD:frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java

    /*** Used by {@link com.android.internal.os.RuntimeInit} to report when an application crashes.* The application process will exit immediately after this call returns.* @param app object of the crashing app, null for the system server* @param crashInfo describing the exception*/public void handleApplicationCrash(IBinder app,ApplicationErrorReport.ParcelableCrashInfo crashInfo) {ProcessRecord r = findAppProcess(app, "Crash");final String processName = app == null ? "system_server": (r == null ? "unknown" : r.processName);handleApplicationCrashInner("crash", r, processName, crashInfo);}

这个注释也很有意思:

Used by {@link com.android.internal.os.RuntimeInit} to report when an application crashes.

然后就去将Crash的ProcessName,和CrashInfo去通过handleApplicationCrashInner进行处理。

    /* Native crash reporting uses this inner version because it needs to be somewhat* decoupled from the AM-managed cleanup lifecycle*/void handleApplicationCrashInner(String eventType, ProcessRecord r, String processName,ApplicationErrorReport.CrashInfo crashInfo) {EventLogTags.writeAmCrash(Binder.getCallingPid(),UserHandle.getUserId(Binder.getCallingUid()), processName,r == null ? -1 : r.info.flags,crashInfo.exceptionClassName,crashInfo.exceptionMessage,crashInfo.throwFileName,crashInfo.throwLineNumber);FrameworkStatsLog.write(FrameworkStatsLog.APP_CRASH_OCCURRED,Binder.getCallingUid(),eventType,processName,Binder.getCallingPid(),(r != null && r.info != null) ? r.info.packageName : "",(r != null && r.info != null) ? (r.info.isInstantApp()? FrameworkStatsLog.APP_CRASH_OCCURRED__IS_INSTANT_APP__TRUE: FrameworkStatsLog.APP_CRASH_OCCURRED__IS_INSTANT_APP__FALSE): FrameworkStatsLog.APP_CRASH_OCCURRED__IS_INSTANT_APP__UNAVAILABLE,r != null ? (r.isInterestingToUserLocked()? FrameworkStatsLog.APP_CRASH_OCCURRED__FOREGROUND_STATE__FOREGROUND: FrameworkStatsLog.APP_CRASH_OCCURRED__FOREGROUND_STATE__BACKGROUND): FrameworkStatsLog.APP_CRASH_OCCURRED__FOREGROUND_STATE__UNKNOWN,processName.equals("system_server") ? ServerProtoEnums.SYSTEM_SERVER: (r != null) ? r.getProcessClassEnum(): ServerProtoEnums.ERROR_SOURCE_UNKNOWN);final int relaunchReason = r == null ? RELAUNCH_REASON_NONE: r.getWindowProcessController().computeRelaunchReason();final String relaunchReasonString = relaunchReasonToString(relaunchReason);if (crashInfo.crashTag == null) {crashInfo.crashTag = relaunchReasonString;} else {crashInfo.crashTag = crashInfo.crashTag + " " + relaunchReasonString;}addErrorToDropBox(eventType, r, processName, null, null, null, null, null, null, crashInfo);mAppErrors.crashApplication(r, crashInfo);}

addErrorToDropBox函数如果熟悉android Log系统的同学,都会知道这个是一个非常重要的Error处理函数。
这个我们会在后续Log的分析文章中,进行专门的说明。
这里我们关心的是mAppErrors.crashApplication(r, crashInfo);

    /*** Bring up the "unexpected error" dialog box for a crashing app.* Deal with edge cases (intercepts from instrumented applications,* ActivityController, error intent receivers, that sort of thing).* @param r the application crashing* @param crashInfo describing the failure*/void crashApplication(ProcessRecord r, ApplicationErrorReport.CrashInfo crashInfo) {final int callingPid = Binder.getCallingPid();final int callingUid = Binder.getCallingUid();final long origId = Binder.clearCallingIdentity();try {crashApplicationInner(r, crashInfo, callingPid, callingUid);} finally {Binder.restoreCallingIdentity(origId);}}

看下CrashApplicationInner的实现:

    void crashApplicationInner(ProcessRecord r, ApplicationErrorReport.CrashInfo crashInfo,int callingPid, int callingUid) {long timeMillis = System.currentTimeMillis();String shortMsg = crashInfo.exceptionClassName;String longMsg = crashInfo.exceptionMessage;String stackTrace = crashInfo.stackTrace;if (shortMsg != null && longMsg != null) {longMsg = shortMsg + ": " + longMsg;} else if (shortMsg != null) {longMsg = shortMsg;}if (r != null) {mPackageWatchdog.onPackageFailure(r.getPackageListWithVersionCode(),PackageWatchdog.FAILURE_REASON_APP_CRASH);mService.mProcessList.noteAppKill(r, (crashInfo != null&& "Native crash".equals(crashInfo.exceptionClassName))? ApplicationExitInfo.REASON_CRASH_NATIVE: ApplicationExitInfo.REASON_CRASH,ApplicationExitInfo.SUBREASON_UNKNOWN,"crash");}final int relaunchReason = r != null? r.getWindowProcessController().computeRelaunchReason() : RELAUNCH_REASON_NONE;AppErrorResult result = new AppErrorResult();int taskId;synchronized (mService) {/*** If crash is handled by instance of {@link android.app.IActivityController},* finish now and don't show the app error dialog.*/if (handleAppCrashInActivityController(r, crashInfo, shortMsg, longMsg, stackTrace,timeMillis, callingPid, callingUid)) {return;}// Suppress crash dialog if the process is being relaunched due to a crash during a free// resize.if (relaunchReason == RELAUNCH_REASON_FREE_RESIZE) {return;}/*** If this process was running instrumentation, finish now - it will be handled in* {@link ActivityManagerService#handleAppDiedLocked}.*/if (r != null && r.getActiveInstrumentation() != null) {return;}// Log crash in battery stats.if (r != null) {mService.mBatteryStatsService.noteProcessCrash(r.processName, r.uid);}AppErrorDialog.Data data = new AppErrorDialog.Data();data.result = result;data.proc = r;// If we can't identify the process or it's already exceeded its crash quota,// quit right away without showing a crash dialog.if (r == null || !makeAppCrashingLocked(r, shortMsg, longMsg, stackTrace, data)) {return;}final Message msg = Message.obtain();msg.what = ActivityManagerService.SHOW_ERROR_UI_MSG;taskId = data.taskId;msg.obj = data;mService.mUiHandler.sendMessage(msg);}int res = result.get();Intent appErrorIntent = null;MetricsLogger.action(mContext, MetricsProto.MetricsEvent.ACTION_APP_CRASH, res);if (res == AppErrorDialog.TIMEOUT || res == AppErrorDialog.CANCEL) {res = AppErrorDialog.FORCE_QUIT;}synchronized (mService) {if (res == AppErrorDialog.MUTE) {stopReportingCrashesLocked(r);}if (res == AppErrorDialog.RESTART) {mService.mProcessList.removeProcessLocked(r, false, true,ApplicationExitInfo.REASON_CRASH, "crash");if (taskId != INVALID_TASK_ID) {try {mService.startActivityFromRecents(taskId,ActivityOptions.makeBasic().toBundle());} catch (IllegalArgumentException e) {// Hmm...that didn't work. Task should either be in recents or associated// with a stack.Slog.e(TAG, "Could not restart taskId=" + taskId, e);}}}if (res == AppErrorDialog.FORCE_QUIT) {long orig = Binder.clearCallingIdentity();try {// Kill it with fire!mService.mAtmInternal.onHandleAppCrash(r.getWindowProcessController());if (!r.isPersistent()) {mService.mProcessList.removeProcessLocked(r, false, false,ApplicationExitInfo.REASON_CRASH, "crash");mService.mAtmInternal.resumeTopActivities(false /* scheduleIdle */);}} finally {Binder.restoreCallingIdentity(orig);}}if (res == AppErrorDialog.APP_INFO) {appErrorIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);appErrorIntent.setData(Uri.parse("package:" + r.info.packageName));appErrorIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);}if (res == AppErrorDialog.FORCE_QUIT_AND_REPORT) {appErrorIntent = createAppErrorIntentLocked(r, timeMillis, crashInfo);}if (r != null && !r.isolated && res != AppErrorDialog.RESTART) {// XXX Can't keep track of crash time for isolated processes,// since they don't have a persistent identity.mProcessCrashTimes.put(r.info.processName, r.uid,SystemClock.uptimeMillis());}}if (appErrorIntent != null) {try {mContext.startActivityAsUser(appErrorIntent, new UserHandle(r.userId));} catch (ActivityNotFoundException e) {Slog.w(TAG, "bug report receiver dissappeared", e);}}}

在出现Crash的情况下,将会调用mPackageWatchdogonPackageFailure函数。

            mPackageWatchdog.onPackageFailure(r.getPackageListWithVersionCode(),PackageWatchdog.FAILURE_REASON_APP_CRASH);

onPackageFailure的实现如下:

    /*** Called when a process fails due to a crash, ANR or explicit health check.** <p>For each package contained in the process, one registered observer with the least user* impact will be notified for mitigation.** <p>This method could be called frequently if there is a severe problem on the device.*/public void onPackageFailure(List<VersionedPackage> packages,@FailureReasons int failureReason) {if (packages == null) {Slog.w(TAG, "Could not resolve a list of failing packages");return;}mLongTaskHandler.post(() -> {synchronized (mLock) {if (mAllObservers.isEmpty()) {return;}boolean requiresImmediateAction = (failureReason == FAILURE_REASON_NATIVE_CRASH|| failureReason == FAILURE_REASON_EXPLICIT_HEALTH_CHECK);if (requiresImmediateAction) {handleFailureImmediately(packages, failureReason);} else {for (int pIndex = 0; pIndex < packages.size(); pIndex++) {VersionedPackage versionedPackage = packages.get(pIndex);// Observer that will receive failure for versionedPackagePackageHealthObserver currentObserverToNotify = null;int currentObserverImpact = Integer.MAX_VALUE;// Find observer with least user impactfor (int oIndex = 0; oIndex < mAllObservers.size(); oIndex++) {ObserverInternal observer = mAllObservers.valueAt(oIndex);PackageHealthObserver registeredObserver = observer.registeredObserver;if (registeredObserver != null&& observer.onPackageFailureLocked(versionedPackage.getPackageName())) {int impact = registeredObserver.onHealthCheckFailed(versionedPackage, failureReason);if (impact != PackageHealthObserverImpact.USER_IMPACT_NONE&& impact < currentObserverImpact) {currentObserverToNotify = registeredObserver;currentObserverImpact = impact;}}}// Execute action with least user impactif (currentObserverToNotify != null) {currentObserverToNotify.execute(versionedPackage, failureReason);}}}}});}

在Crash的原因为Native_Crash和FAILURE_REASON_EXPLICIT_HEALTH_CHECK时,将会调用RollBack进行处理,但是其余的情况,将会进行进一步的通知。我们这里注意的是非RollBack的处理:

                    for (int pIndex = 0; pIndex < packages.size(); pIndex++) {VersionedPackage versionedPackage = packages.get(pIndex);// Observer that will receive failure for versionedPackagePackageHealthObserver currentObserverToNotify = null;int currentObserverImpact = Integer.MAX_VALUE;// Find observer with least user impactfor (int oIndex = 0; oIndex < mAllObservers.size(); oIndex++) {ObserverInternal observer = mAllObservers.valueAt(oIndex);PackageHealthObserver registeredObserver = observer.registeredObserver;if (registeredObserver != null&& observer.onPackageFailureLocked(versionedPackage.getPackageName())) {int impact = registeredObserver.onHealthCheckFailed(versionedPackage, failureReason);if (impact != PackageHealthObserverImpact.USER_IMPACT_NONE&& impact < currentObserverImpact) {currentObserverToNotify = registeredObserver;currentObserverImpact = impact;}}}// Execute action with least user impactif (currentObserverToNotify != null) {currentObserverToNotify.execute(versionedPackage, failureReason);}}

这里首先会注册PackageHealthObserver,然后调用相应的execute的函数:

// Execute action with least user impact
if (currentObserverToNotify != null) {currentObserverToNotify.execute(versionedPackage, failureReason);
}

而我们救援模式的实现RescueParty,里面也继承并实现了PackageHealthObserver。

    /*** Handle mitigation action for package failures. This observer will be register to Package* Watchdog and will receive calls about package failures. This observer is persistent so it* may choose to mitigate failures for packages it has not explicitly asked to observe.*/public static class RescuePartyObserver implements PackageHealthObserver {@Overridepublic boolean execute(@Nullable VersionedPackage failedPackage,@FailureReasons int failureReason) {if (isDisabled()) {return false;}if (failureReason == PackageWatchdog.FAILURE_REASON_APP_CRASH|| failureReason == PackageWatchdog.FAILURE_REASON_APP_NOT_RESPONDING) {int triggerUid = getPackageUid(mContext, failedPackage.getPackageName());incrementRescueLevel(triggerUid);executeRescueLevel(mContext,failedPackage == null ? null : failedPackage.getPackageName());return true;} else {return false;}}}

incrementRescueLevel的实现主要是去调整救援的等级;
executeRescueLevel是去执行救援操作

    /*** Escalate to the next rescue level. After incrementing the level you'll* probably want to call {@link #executeRescueLevel(Context, String)}.*/private static void incrementRescueLevel(int triggerUid) {final int level = getNextRescueLevel();SystemProperties.set(PROP_RESCUE_LEVEL, Integer.toString(level));EventLogTags.writeRescueLevel(level, triggerUid);logCriticalInfo(Log.WARN, "Incremented rescue level to "+ levelToString(level) + " triggered by UID " + triggerUid);}

incrementRescueLevel是去调用getNextRescueLevel来进行计数;

    /*** Get the next rescue level. This indicates the next level of mitigation that may be taken.*/private static int getNextRescueLevel() {return MathUtils.constrain(SystemProperties.getInt(PROP_RESCUE_LEVEL, LEVEL_NONE) + 1,LEVEL_NONE, LEVEL_FACTORY_RESET);}

实现原理也很简单,每次对于计数+1.

    private static void executeRescueLevel(Context context, @Nullable String failedPackage) {final int level = SystemProperties.getInt(PROP_RESCUE_LEVEL, LEVEL_NONE);if (level == LEVEL_NONE) return;Slog.w(TAG, "Attempting rescue level " + levelToString(level));try {executeRescueLevelInternal(context, level, failedPackage);EventLogTags.writeRescueSuccess(level);logCriticalInfo(Log.DEBUG,"Finished rescue level " + levelToString(level));} catch (Throwable t) {logRescueException(level, t);}}

executeRescueLevel函数则是将当前的level和failedPackage进行传递,到executeRescueLevelInternal进行实现。

    private static void executeRescueLevelInternal(Context context, int level, @NullableString failedPackage) throws Exception {FrameworkStatsLog.write(FrameworkStatsLog.RESCUE_PARTY_RESET_REPORTED, level);switch (level) {case LEVEL_RESET_SETTINGS_UNTRUSTED_DEFAULTS:resetAllSettings(context, Settings.RESET_MODE_UNTRUSTED_DEFAULTS, failedPackage);break;case LEVEL_RESET_SETTINGS_UNTRUSTED_CHANGES:resetAllSettings(context, Settings.RESET_MODE_UNTRUSTED_CHANGES, failedPackage);break;case LEVEL_RESET_SETTINGS_TRUSTED_DEFAULTS:resetAllSettings(context, Settings.RESET_MODE_TRUSTED_DEFAULTS, failedPackage);break;case LEVEL_FACTORY_RESET:// Request the reboot from a separate thread to avoid deadlock on PackageWatchdog// when device shutting down.Runnable runnable = new Runnable() {@Overridepublic void run() {try {RecoverySystem.rebootPromptAndWipeUserData(context, TAG);} catch (Throwable t) {logRescueException(level, t);}}};Thread thread = new Thread(runnable);thread.start();break;}}

在FactoryReset之前,进行的都是resetAllSettings的操作。

    private static void resetAllSettings(Context context, int mode, @Nullable String failedPackage)throws Exception {// Try our best to reset all settings possible, and once finished// rethrow any exception that we encounteredException res = null;final ContentResolver resolver = context.getContentResolver();try {resetDeviceConfig(context, mode, failedPackage);} catch (Exception e) {res = new RuntimeException("Failed to reset config settings", e);}try {Settings.Global.resetToDefaultsAsUser(resolver, null, mode, UserHandle.USER_SYSTEM);} catch (Exception e) {res = new RuntimeException("Failed to reset global settings", e);}for (int userId : getAllUserIds()) {try {Settings.Secure.resetToDefaultsAsUser(resolver, null, mode, userId);} catch (Exception e) {res = new RuntimeException("Failed to reset secure settings for " + userId, e);}}if (res != null) {throw res;}}

系统Factory Reset级别救援实现

当触发FactoryReset的条件时, 也就是到达五次的时候,会进入下面的操作:

                // Request the reboot from a separate thread to avoid deadlock on PackageWatchdog// when device shutting down.Runnable runnable = new Runnable() {@Overridepublic void run() {try {RecoverySystem.rebootPromptAndWipeUserData(context, TAG);} catch (Throwable t) {logRescueException(level, t);}}};Thread thread = new Thread(runnable);thread.start();break;

将会调用RecoverySystem.rebootPromptAndWipeUserData来进行FactoryReset的操作。
也就是进入Factory Reset的界面了。

Android 救援模式(Rescue Mode)原理剖析相关推荐

  1. 写给 Android 应用工程师的 Binder 原理剖析

    2019独角兽企业重金招聘Python工程师标准>>> 一. 前言 这篇文章我酝酿了很久,参考了很多资料,读了很多源码,却依旧不敢下笔.生怕自己理解上还有偏差,对大家造成误解,贻笑大 ...

  2. 关于各种USB启动模式(MBR)的原理剖析

    转自:http://www.aiseminar.cn/bbs/forum.php?mod=viewthread&tid=1703 selected from: http://hi.baidu. ...

  3. CentOS 7 单用户模式+救援模式

    有时候大家可能会忘记自己的root密码,或者错误(命令输入错误,命令位置输入有误等)编辑了一个/etc目录下的核心文件导致系统不能正常启动了!怎么办?重新安装系统那是实在没有办法之举!那我们就进入如下 ...

  4. Android APM 系列一(原理篇)

    图片来自 https://unsplash.com 一. 前言 性能问题是导致 App 用户流失的罪魁祸首之一,如果用户在使用我们 App 的时候遇到诸如页面卡顿.响应速度慢.发热严重.流量电量消耗大 ...

  5. Ironic 的 Rescue 救援模式实现流程

    目录 文章目录 目录 救援模式 实现 UML 图 救援模式 以往只有虚拟机支持救援模式,裸机是不支持的.直到 Queen 版本 Ironic 实现了这个功能.救援模式下,用户可以完成修复.Troubl ...

  6. 深入剖析Redis系列(三) - Redis集群模式搭建与原理详解

    前言 在 Redis 3.0 之前,使用 哨兵(sentinel)机制来监控各个节点之间的状态.Redis Cluster 是 Redis 的 分布式解决方案,在 3.0 版本正式推出,有效地解决了 ...

  7. linux光盘补救,Linux_忘记root密码时使用Linux系统光盘进行补救的方法,救援模式即rescue ,这个模式主 - phpStudy...

    忘记root密码时使用Linux系统光盘进行补救的方法 救援模式即rescue ,这个模式主要是应用于,系统无法进入的情况.如,grub损坏或者某一个配置文件修改出错.如何使用rescue模式呢? 光 ...

  8. 进入Linux救援(rescue)模式的四大法门

    适用场景: 当误操作修改系统启动文件/etc/fstab, /etc/rc.d/rc.sysinit时,就会造成系统启动时读取磁盘或初始化环境失败,导致linux无法正常启动,此时就可以借助Linux ...

  9. Android View学习笔记(三):Scroller的原理剖析及使用(上)

    一.前言 上一篇文章中,讨论了View的几种基本滑动方式,但是这些滑动方式是生硬的,在一瞬间完成的,这给用户非常不好的体验,所以为了提高用户体验,我们需要将View弹性滑动.什么是弹性滑动?就是一个V ...

  10. 【Android 低电耗/Doze原理---设备运动状态和位置对Doze模式的影响】

    Android 低电耗/Doze原理---设备运动状态和位置对Doze模式的影响 基础知识 Deep Idle的状态机 STATE_IDLE_PENDING----监听运动状态变化 STATE_SEN ...

最新文章

  1. 惊了,AI已经学会刷LeetCode了!
  2. 【数据竞赛】Kaggle竞赛宝典国内外竞赛方案汇总
  3. arthas 查看哪个方法调用最耗时_Java开源诊断工具Arthas使用方法详解
  4. 网易云容器服务基于Kubernetes的实践探索
  5. 抽象类和接口到底是什么“垃圾“——教你分类
  6. 递归打印目录层次(java版)
  7. 图灵专栏微信小程序上线
  8. docker版本包 乌班图_在Ubuntu 18.04系统中安装指定docker版本的简单方法
  9. 阿里巴巴java开发编码规范—代码格式
  10. Django搭建个人博客之制作app并配置相关环境
  11. 基于矩阵分解的协同过滤推荐算法
  12. origin 画热图
  13. [XPlane11/12]同步更新Zibo737插件下载-更新至3.54.17-插件搬运
  14. 远程桌面无法连接解决方法
  15. 用s函数实现Lugre摩擦模型
  16. 2022爱分析・出海数字化系列报告之“出海实时互动与通信”厂商全景报告 | 爱分析报告
  17. 惊了,深圳房价比北京还高。。。
  18. vue项目权限:数据权限、菜单权限、按钮权限
  19. 解决PS中:无法将图片存储为Web存储格式,及如何将图片大小修改成10KB的问题
  20. PS图片无法保存ICO格式解决方法

热门文章

  1. 计算机网络实验:常用网络命令的使用(ping、ipconfig、netstat、tracert、arp)
  2. 安信可分享 | 安信可Wi-Fi模组对接华为云物联网IoT平台的指南。(附带源码)
  3. 桌面计算机恢复出厂设置,windows7电脑怎么恢复出厂设置
  4. 多线程实现飞花令-多诗库版
  5. 平面设计师okr_设计团队的KPI/OKR如何制定?
  6. matlib 多种方法实现图像旋转不使用imrotate函数
  7. 有了这四个网站 再也不用花钱啦
  8. c语言实现fft原理,新手小白一看就会,FFT算法的原理详解
  9. Simphony学习2 安全相关(密码和角色)
  10. rs232接口_终于有人把常用的三种通讯方式:RS485、RS232、RS422讲明白了