Android PMS应用安装流程源码-安装包的写入
注:
- PMS应用安装流程系统源码以Android12为基础
- 文章中PackageManagerService简称为PMS
- 测试机为pixel 3
一、引言
该篇文章为PMS应用安装流程源码分析上篇,下篇可参考Android PMS应用安装流程源码分析下篇-安装包校验及安装;该文章主要涉及到如下几个过程的相关源码:
- 用户点击安装包开始安装->Installer被拉起开始执行安装包安装过程;
- Installer通过system_server将安装包写入到指定临时文件路径(data/app/xx);
- Installer触发PMS后续应用安装流程。
二、原生Installer源码
1、Installer源码获取
- Installer包名:com.谷歌.android.packageinstaller;
- Installer源码路径获取命令:adb shell dumpsys package com.谷歌.android.packageinstaller | grep "code";
- Installer apk获取:adb pull xx(上述获取到的codePath);
- jadx反编译上述步骤pull出来的base.apk,即可开始阅读Installer相关源码;
2、开始安装
当用户进入到文件管理系统,点击某个待安装的应用apk之后,就会跳转到Installer的 com.android.packageinstaller.InstallStart
页面接着会自动跳转到com.android.packageinstaller.PackageInstallerActivity
页面以弹窗提醒用户是否继续安装当前应用(防止静默安装);当用户点击确认之后则会跳转到com.android.packageinstaller.InstallInstalling
页面开始真正的安装流程。
2.1 InstallInstalling页面
该页面作为应用安装的真正触发页面,其主要是创建Session对象以及将待安装应用apk通过system_server写入到指定临时文件目录下:
- 在onCreate函数中调用到PackageInstallerService.java的
createSession
函数以创建Session供后续使用; - onResume函数调用到PackageInstallerSession.java的
openWrite
函数将待安装的应用apk写入到data/app/xx.tmp目录下并开始触发后续的应用apk安装流程。
步骤1:onCreate函数创建session
该函数首先获取Intent,接着从Intent中获取待安装应用apk路径以及其他相关参数,随后开始解析apk以获取待安装应用包名、安装位置以及大小,最后调用到PackageInstallerService.java中以创建对应Session对象并将返回的sessionId进行记录供后续所使用。
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
ApplicationInfo applicationInfo = (ApplicationInfo) getIntent().getParcelableExtra("com.android.packageinstaller.applicationInfo");
Uri data = getIntent().getData();
this.mPackageURI = data;
if ("package".equals(data.getScheme())) {
try {
getPackageManager().installExistingPackage(applicationInfo.packageName);
launchSuccess();
return;
} catch (PackageManager.NameNotFoundException unused) {
launchFailure(1, -110, null);
return;
}
}
PackageUtil.AppSnippet appSnippet = PackageUtil.getAppSnippet(this, applicationInfo, new File(this.mPackageURI.getPath()));
//取消弹框
......
requireViewById((int) R.id.installing).setVisibility(0);
//如果是新启动的Activity,那么bundle为空
if (bundle != null) {
......
} else {
//创建SessionParams对象,其中参数1表示如果此次是覆盖安装,那么会将旧的apk完全覆盖掉(还有一种安装方式是部分替换)
PackageInstaller.SessionParams sessionParams = new PackageInstaller.SessionParams(1);
//设置是否是临时应用(类似于快应用)
sessionParams.setInstallAsInstantApp(false);
//其他相关参数
sessionParams.setReferrerUri((Uri) getIntent().getParcelableExtra("android.intent.extra.REFERRER"));
sessionParams.setOriginatingUri((Uri) getIntent().getParcelableExtra("android.intent.extra.ORIGINATING_URI"));
sessionParams.setOriginatingUid(getIntent().getIntExtra("android.intent.extra.ORIGINATING_UID", -1));
//安装来源
sessionParams.setInstallerPackageName(getIntent().getStringExtra("android.intent.extra.INSTALLER_PACKAGE_NAME"));
//安装原因,4表示由用户触发安装
sessionParams.setInstallReason(4);
//安装包文件路径
File file = new File(this.mPackageURI.getPath());
try {
//解析安装包
ParseResult parsePackageLite = ApkLiteParseUtils.parsePackageLite(ParseTypeImpl.forDefaultParsing().reset(), file, 0);
if (parsePackageLite.isError()) {
......
} else {
PackageLite packageLite = (PackageLite) parsePackageLite.getResult();
//设置待安装应用包名
sessionParams.setAppPackageName(packageLite.getPackageName());
//应用安装路径(安装在外部存储还是内部存储,一般都是内部存储,即data/app/xx目录)
sessionParams.setInstallLocation(packageLite.getInstallLocation());
sessionParams.setSize(PackageHelper.calculateInstalledSize(packageLite, sessionParams.abiOverride));
}
} catch (IOException unused3) {
}
......
try {
//创建对应Session对象,记录返回的sessionId供后续使用
this.mSessionId = getPackageManager().getPackageInstaller().createSession(sessionParams);
} catch (IOException unused5) {
}
}
this.mCancelButton = ((AlertActivity) this).mAlert.getButton(-2);
}
步骤2:onResume函数将应用apk写入指定临时文件路径
该函数代码不是很难理解,首先如果已经触发了应用安装流程则结束不必重复触发,否则获取上述创建的Session对象以将待安装的应用apk写入到指定临时文件路径,最后如果apk文件写入完成则触发后续apk的真正安装流程。
protected void onResume() {
super.onResume();
if (this.mInstallingTask == null) {
//根据上述获取到的sessionId获取对应的SessionInfo
PackageInstaller.SessionInfo sessionInfo = getPackageManager().getPackageInstaller().getSessionInfo(this.mSessionId);
//判断是否已经触发了后续的apk安装流程,如果已经触发了则没必要重复触发
if (sessionInfo != null && !sessionInfo.isActive()) {
//创建AsyncTask将apk文件写入到指定位置
InstallingAsyncTask installingAsyncTask = new InstallingAsyncTask();
this.mInstallingTask = installingAsyncTask;
//执行task
installingAsyncTask.execute(new Void[0]);
return;
}
this.mCancelButton.setEnabled(false);
setFinishOnTouchOutside(false);
}
}
public final class InstallingAsyncTask extends AsyncTask<Void, Void, PackageInstaller.Session> {
volatile boolean isDone;
@Override
public PackageInstaller.Session doInBackground(Void... voidArr) {
try {
//获取在onCreate函数中创建的Session对象,同时在system_server中创建存储临时apk的文件路径
PackageInstaller.Session openSession = InstallInstalling.this.getPackageManager().getPackageInstaller().openSession(InstallInstalling.this.mSessionId);
openSession.setStagingProgress(0.0f);
try {
try {
//待安装应用apk文件路径
File file = new File(InstallInstalling.this.mPackageURI.getPath());
FileInputStream fileInputStream = new FileInputStream(file);
try {
//后续就是通过io流将apk文件写入到指定位置了
long length = file.length();
OutputStream openWrite = openSession.openWrite("PackageInstaller", 0L, length);
byte[] bArr = new byte[1048576];
while (true) {
int read = fileInputStream.read(bArr);
//写入apk文件完成
if (read == -1) {
openSession.fsync(openWrite);
break;
//取消安装
} else if (isCancelled()) {
openSession.close();
break;
} else {
//逐步将应用apk文件写入到指定临时文件路径下
openWrite.write(bArr, 0, read);
if (length > 0) {
openSession.addProgress(read / ((float) length));
}
}
}
......
return openSession;
} catch (Throwable th) {
.......
}
} catch (IOException | SecurityException e) {
......
}
} catch (Throwable th3) {
......
}
} catch (IOException unused) {
......
}
}
public void onPostExecute(PackageInstaller.Session session) {
......
Intent intent = new Intent("com.android.packageinstaller.ACTION_INSTALL_COMMIT");
intent.setFlags(268435456);
intent.setPackage(InstallInstalling.this.getPackageName());
intent.putExtra("EventResultPersister.EXTRA_ID", InstallInstalling.this.mInstallId);
//上述文件写入完成之后会调用到PackageInstallerSession的commit函数中,开始后续的apk安装流程
session.commit(PendingIntent.getBroadcast(InstallInstalling.this, ((InstallInstalling) r1).mInstallId, intent, 167772160).getIntentSender());
......
}
}
三、将apk文件写入到临时文件路径
1、IPackageInstaller对象获取
上述Installer的Installing页面获取PackageInstaller对象首先会进入到类ApplicationPackageManager.java的如下函数。
public PackageInstaller getPackageInstaller() {
if (mInstaller == null) {
try {
mInstaller = new PackageInstaller(mPM.getPackageInstaller(),
mContext.getPackageName(), mContext.getAttributionTag(), getUserId());
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}
return mInstaller;
}
其中的mPM.getPackageInstaller()
则会调用到PMS(即通过Binder通信获取);上述Installer的onCreate
函数所调用到的createSession
函数就是通过该对象进行调用。PMS中mPM.getPackageInstaller()
代码如下:
public IPackageInstaller getPackageInstaller() {
if (getInstantAppPackageName(Binder.getCallingUid()) != null) {
return null;
}
return mInstallerService;
}
而mInstallerService
对象则是在PMS的构造函数中通过mInjector
对象进行获取。核心代码如下:
public PackageManagerService(Injector injector, boolean onlyCore, boolean factoryTest,
final String buildFingerprint, final boolean isEngBuild,
final boolean isUserDebugBuild, final int sdkVersion, final String incrementalVersion) {
......
mInstallerService = mInjector.getPackageInstallerService();
......
}
对象mInjector
则是在PMS的main
函数中被创建,核心代码如下(注意其中的lambada表达式):
public static PackageManagerService main(Context context, Installer installer,
@NonNull DomainVerificationService domainVerificationService, boolean factoryTest,
boolean onlyCore) {
......
Injector injector = new Injector(
context, lock, installer, installLock, new PackageAbiHelperImpl(),
backgroundHandler,
SYSTEM_PARTITIONS,
......
//该处使用的lambda表达式
(i, pm) -> new PackageInstallerService(
i.getContext(), pm, i::getScanningPackageParser),
(i, pm, cn) -> new InstantAppResolverConnection(
i.getContext(), cn, Intent.ACTION_RESOLVE_INSTANT_APP_PACKAGE),
......
new DefaultSystemWrapper(),
LocalServices::getService,
context::getSystemService);
......
}
//Injector中getPackageInstallerService函数实现,即会调用到上述lambada表达式
public PackageInstallerService getPackageInstallerService() {
return mPackageInstallerServiceProducer.get(this, mPackageManager);
}
所以根据如上源码分析可以知道,最终createSession
函数的实现是在类PackageInstallerService.java。
2、createSession源码
该函数会判断当前应用是否满足Session创建条件,如果满足条件则首先会随机生成sessionId,接着创建Session相关对象,最后以sessionId为key创建好的Session对象为value存储到SparseArray中:
- 判断安装器当前是否具备Session创建条件,比如特定权限判断、当前安装器活跃session是否超过一定数量等;
- 对SessionParams中的installFlags新增、减少某些flag;
- 随机生成sessionId;
- 获取apk临时存放文件路径(注意这里只是获取,没有创建);
- 创建PackageInstallerSession对象,并以上述生成的sessionId作为key存放到mSessions这个SparseArray中以便后续进行获取。
public int createSession(SessionParams params, String installerPackageName,String callingAttributionTag, int userId) {
try {
return createSessionInternal(params, installerPackageName, callingAttributionTag,userId);
} catch (IOException e) {
throw ExceptionUtils.wrap(e);
}
}
//installerPackageName:安装器包名
private int createSessionInternal(SessionParams params, String installerPackageName, String installerAttributionTag, int userId) throws IOException {
final int callingUid = Binder.getCallingUid();
//判断应用是否具备createSession权限
mPm.enforceCrossUserPermission(callingUid, userId, true, true, "createSession");
//判断当前user是否禁止安装应用
if (mPm.isUserRestricted(userId, UserManager.DISALLOW_INSTALL_APPS)) {
throw new SecurityException("User restriction prevents installing");
}
......
//应用包名不允许超过255个字符
if (params.appPackageName != null && params.appPackageName.length() > SessionParams.MAX_PACKAGE_NAME_LENGTH) {
params.appPackageName = null;
}
//应用名获取
params.appLabel = TextUtils.trimToSize(params.appLabel,PackageItemInfo.MAX_SAFE_LABEL_LENGTH);
//安装来源应用包名(installing页面从Intent中获取)
String requestedInstallerPackageName = (params.installerPackageName != null && params.installerPackageName.length() < SessionParams.MAX_PACKAGE_NAME_LENGTH) ? params.installerPackageName : installerPackageName;
//判断安装来源应用uid是否是shell或者系统
if ((callingUid == Process.SHELL_UID) || (callingUid == Process.ROOT_UID)) {
params.installFlags |= PackageManager.INSTALL_FROM_ADB;
installerPackageName = null;
} else {
//如果触发安装不是系统应用,则要求实际调用方uid与包名都属于同一个应用,如果不是则抛出异常(防止调用方伪造包名)
if (callingUid != Process.SYSTEM_UID) {
mAppOps.checkPackage(callingUid, installerPackageName);
}
//如果安装来源包名与实际触发安装包名不一致且触发安装应用无应用安装权限,则检测调用方uid与安装来源包名是否一致,如果不一致则抛出异常
if (!TextUtils.equals(requestedInstallerPackageName, installerPackageName)) {
if (mContext.checkCallingOrSelfPermission(Manifest.permission.INSTALL_PACKAGES)
!= PackageManager.PERMISSION_GRANTED) {
mAppOps.checkPackage(callingUid, requestedInstallerPackageName);
}
}
//从installFlags中去掉不必要的flag
......
}
......
//只有系统允许降级应用,如果不是系统应用请求安装,则从installFlags中去掉对应flag
......
//判断是否移除不需要校验apk步骤(一般都需要)
......
//apex应用等相关,这里不涉及,所以去掉
......
mBypassNextStagedInstallerCheck = false;
mBypassNextAllowedApexUpdateCheck = false;
if (!params.isMultiPackage) {
//判断是否允许在安装时赋予被安装应用运行时权限(一般来说只有系统能够做到)
if ((params.installFlags & PackageManager.INSTALL_GRANT_RUNTIME_PERMISSIONS) != 0
&& mContext.checkCallingOrSelfPermission(Manifest.permission
.INSTALL_GRANT_RUNTIME_PERMISSIONS) == PackageManager.PERMISSION_DENIED) {
throw new SecurityException("You need the "
"android.permission.INSTALL_GRANT_RUNTIME_PERMISSIONS permission "
"to use the PackageManager.INSTALL_GRANT_RUNTIME_PERMISSIONS flag");
}
//待安装应用icon相关
......
//这里的MODE_FULL_INSTALL
switch (params.mode) {
case SessionParams.MODE_FULL_INSTALL:
case SessionParams.MODE_INHERIT_EXISTING:
break;
default:
throw new IllegalArgumentException("Invalid install mode: " params.mode);
}
//安装位置相关判断(内部或者外部)
......
final int sessionId;
final PackageInstallerSession session;
synchronized (mSessions) {
//判断当前安装器的活跃session是否超过一定数量,如果超过一定数量则世界抛出异常
......
//随机生成sessionId
sessionId = allocateSessionIdLocked();
}
final long createdMillis = System.currentTimeMillis();
File stageDir = null;
String stageCid = null;
if (!params.isMultiPackage) {
//获取apk需要临时写入的文件路径
if ((params.installFlags & PackageManager.INSTALL_INTERNAL) != 0) {
stageDir = buildSessionDir(sessionId, params);
} else {
stageCid = buildExternalStageCid(sessionId);
}
}
......
//创建对应的session,用户后续将apk写入到指定文件路径下
InstallSource installSource = InstallSource.create(installerPackageName,
originatingPackageName, requestedInstallerPackageName,
installerAttributionTag);
session = new PackageInstallerSession(mInternalCallback, mContext, mPm, this,
mSilentUpdatePolicy, mInstallThread.getLooper(), mStagingManager, sessionId,
userId, callingUid, installSource, params, createdMillis, 0L, stageDir, stageCid,
null, null, false, false, false, false, null, SessionInfo.INVALID_ID,
false, false, false, SessionInfo.STAGED_SESSION_NO_ERROR, "");
synchronized (mSessions) {
mSessions.put(sessionId, session);
}
mCallbacks.notifySessionCreated(session.sessionId, session.userId);
mSettingsWriteRequest.schedule();
//返回生成的sessionId
return sessionId;
}
//随机生成sessionId
private int allocateSessionIdLocked() {
int n = 0;
int sessionId;
do {
//随机生成整数
sessionId = mRandom.nextInt(Integer.MAX_VALUE - 1) 1;
//判断随机生成整数是否已经被占用,如果被占用,则重新生成
if (!mAllocatedSessions.get(sessionId, false)) {
mAllocatedSessions.put(sessionId, true);
return sessionId;
}
} while (n < 32);
throw new IllegalStateException("Failed to allocate session ID");
}
//获取apk临时存储文件路径
private File buildSessionDir(int sessionId, SessionParams params) {
//该if为false
if (params.isStaged || (params.installFlags & PackageManager.INSTALL_APEX) != 0) {
final File sessionStagingDir = Environment.getDataStagingDirectory(params.volumeUuid);
return new File(sessionStagingDir, "session_" sessionId);
}
//会调用到该处
return buildTmpSessionDir(sessionId, params.volumeUuid);
}
//返回/data/app
private File getTmpSessionDir(String volumeUuid) {
return Environment.getDataAppDirectory(volumeUuid);
}
private File buildTmpSessionDir(int sessionId, String volumeUuid) {
final File sessionStagingDir = getTmpSessionDir(volumeUuid);
// 返回data/app/vmdlxx.tmp
return new File(sessionStagingDir, "vmdl" sessionId ".tmp");
}
3、openSession
该函数对应流程代码就更简单了,首先通过sessionId获取已经创建好的Session对象,接着创建临时存储apk的文件路径,最后返回获取到的Session对象。
public IPackageInstallerSession openSession(int sessionId) {
try {
return openSessionInternal(sessionId);
} catch (IOException e) {
throw ExceptionUtils.wrap(e);
}
}
private IPackageInstallerSession openSessionInternal(int sessionId) throws IOException {
synchronized (mSessions) {
//根据sessionId获取上述创建的Session对象
final PackageInstallerSession session = mSessions.get(sessionId);
if (session == null || !isCallingUidOwner(session)) {
throw new SecurityException("Caller has no access to session " sessionId);
}
session.open();
return session;
}
}
public void open() throws IOException {
if (mActiveCount.getAndIncrement() == 0) {
mCallback.onSessionActiveChanged(this, true);
}
boolean wasPrepared;
synchronized (mLock) {
wasPrepared = mPrepared;
if (!mPrepared) {
if (stageDir != null) {
prepareStageDir(stageDir);
}
......
mPrepared = true;
}
}
......
}
static void prepareStageDir(File stageDir) throws IOException {
if (stageDir.exists()) {
throw new IOException("Session dir already exists: " stageDir);
}
try {
//创建存放apk的临时文件路径,并修改其读写权限
Os.mkdir(stageDir.getAbsolutePath(), 0775);
Os.chmod(stageDir.getAbsolutePath(), 0775);
} catch (ErrnoException e) {
}
......
}
4、openWrite
该函数首先会创建一对本地socket作为client端和server端用于后续apk文件数据传输,并构建临时存储apk文件描述符用于后续文件写入,最后返回创建的client socket给到Installer。
private ParcelFileDescriptor doWriteInternal(String name, long offsetBytes, long lengthBytes, ParcelFileDescriptor incomingFd) throws IOException {
final RevocableFileDescriptor fd;
final FileBridge bridge;
synchronized (mLock) {
//获取SystemPoperties中key = fw.revocable_fd的值,默认为false
//这里为false
if (PackageInstaller.ENABLE_REVOCABLE_FD) {
......
} else {
fd = null;
//注意这里是重点,其会创建一对socket用于后续文件写
bridge = new FileBridge();
mBridges.add(bridge);
}
}
try {
if (!FileUtils.isValidExtFilename(name)) {
throw new IllegalArgumentException("Invalid name: " name);
}
final File target;
final long identity = Binder.clearCallingIdentity();
try {
//构造apk临时存储文件路径(data/app/vmdlsessionId.tmp/xx.apk)
target = new File(stageDir, name);
} finally {
Binder.restoreCallingIdentity(identity);
}
//获取临时apk文件描述符
ParcelFileDescriptor targetPfd = openTargetInternal(target.getAbsolutePath(),O_CREAT | O_WRONLY, 0644);
//修改文件访问权限
Os.chmod(target.getAbsolutePath(), 0644);
if (stageDir != null && lengthBytes > 0) {
//存储空间分配
mContext.getSystemService(StorageManager.class).allocateBytes(targetPfd.getFileDescriptor(), lengthBytes,PackageHelper.translateAllocateFlags(params.installFlags));
}
//文件偏移量设置(这里为0)
if (offsetBytes > 0) {
Os.lseek(targetPfd.getFileDescriptor(), offsetBytes, OsConstants.SEEK_SET);
}
//这里incomingFd为null,因此省略其代码
if (incomingFd != null) {
......
//获取SystemPoperties中key = fw.revocable_fd的值,默认为false
//这里为false
} else if (PackageInstaller.ENABLE_REVOCABLE_FD) {
return createRevocableFdInternal(fd, targetPfd);
} else {
bridge.setTargetFile(targetPfd);
//FileBridge继承至Thread
bridge.start();
return bridge.getClientSocket();
}
} catch (ErrnoException e) {
throw e.rethrowAsIOException();
}
}
//上述调用了start之后,就会阻塞等待client数据写入
public void run() {
final ByteBuffer tempBuffer = ByteBuffer.allocateDirect(8192);
final byte[] temp = tempBuffer.hasArray() ? tempBuffer.array() : new byte[8192];
try {
//等待客户端写入数据
while (IoBridge.read(mServer.getFileDescriptor(), temp,0, MSG_LENGTH) == MSG_LENGTH) {
final int cmd = Memory.peekInt(temp, 0, ByteOrder.BIG_ENDIAN);
//将数据写入到上述的临时文件中
if (cmd == CMD_WRITE) {
int len = Memory.peekInt(temp, 4, ByteOrder.BIG_ENDIAN);
while (len > 0) {
int n = IoBridge.read(mServer.getFileDescriptor(), temp, 0,Math.min(temp.length, len));
if (n == -1) {
throw new IOException("Unexpected EOF; still expected " len " bytes");
}
IoBridge.write(mTarget.getFileDescriptor(), temp, 0, n);
len -= n;
}
//数据写入完成,执行同步操作
} else if (cmd == CMD_FSYNC) {
Os.fsync(mTarget.getFileDescriptor());
IoBridge.write(mServer.getFileDescriptor(), temp, 0, MSG_LENGTH);
//关闭socket链接
} else if (cmd == CMD_CLOSE) {
Os.fsync(mTarget.getFileDescriptor());
mTarget.close();
mClosed = true;
IoBridge.write(mServer.getFileDescriptor(), temp, 0, MSG_LENGTH);
break;
}
}
} catch (ErrnoException | IOException e) {
} finally {
forceClose();
}
}
5、commit
public void commit(@NonNull IntentSender statusReceiver, boolean forTransfer) {
......
//对该流程加锁,防止后续重复触发
if (!markAsSealed(statusReceiver, forTransfer)) {
return;
}
if (isMultiPackage()) {
......
}
dispatchSessionSealed();
}
//后续经过一系列调用就会调用到该函数
private void handleStreamValidateAndCommit() {
PackageManagerException unrecoverableFailure = null;
boolean allSessionsReady = false;
try {
//这里就会将session标记为活跃状态(Installer的onResume函数中有使用到)
allSessionsReady = streamValidateAndCommit();
} catch (PackageManagerException e) {
unrecoverableFailure = e;
}
if (isMultiPackage()) {
......
}
if (!allSessionsReady) {
return;
}
//真正触发应用的安装流程
mHandler.obtainMessage(MSG_INSTALL).sendToTarget();
}
四、总结
如上即为Installer从Session创建到最终触发应用开始安装的全流程大致源码分析了。其主要做了如下三件事情:
- Session对象的创建,用于后续应用安装;
- 将待安装的应用apk通过system_server写入到临时文件路径下;
- 触发后续的应用安装流程。
这篇好文章是转载于:学新通技术网
- 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
- 本站站名: 学新通技术网
- 本文地址: /boutique/detail/tanhgahiga
-
photoshop保存的图片太大微信发不了怎么办
PHP中文网 06-15 -
Android 11 保存文件到外部存储,并分享文件
Luke 10-12 -
word里面弄一个表格后上面的标题会跑到下面怎么办
PHP中文网 06-20 -
《学习通》视频自动暂停处理方法
HelloWorld317 07-05 -
photoshop扩展功能面板显示灰色怎么办
PHP中文网 06-14 -
微信公众号没有声音提示怎么办
PHP中文网 03-31 -
excel下划线不显示怎么办
PHP中文网 06-23 -
怎样阻止微信小程序自动打开
PHP中文网 06-13 -
excel打印预览压线压字怎么办
PHP中文网 06-22 -
photoshop蒙版画笔没反应怎么办
PHP中文网 06-24