一個Uri引起的思考

今天有同事問我問題,調(diào)用系統(tǒng)分享,QQ分享總是提示圖片獲取不到。過來幾分鐘又告訴我搞定了,原來是uri的問題。他開始是這樣寫的:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                imageUri = FileProvider.getUriForFile(context, context.getApplicationContext().getPackageName() + ".FileProvider", file);
            } else {
                imageUri = Uri.fromFile(imageFile);
            }

后來把判斷去掉,統(tǒng)一只用Uri#parse就沒問題了。
雖然他解決了問題,但是卻激起了我的好奇心,然后就開始看源碼,然后就有了這篇文章。
在分析之前,咱們先來科普一下7.0以前及以后通過file獲取uri的方法和內(nèi)部具體實現(xiàn)。

7.0以前 Uri#fromFile
val uri = Uri.fromFile(file)
public static Uri fromFile(File file) {
        if (file == null) {
            throw new NullPointerException("file");
        }

        PathPart path = PathPart.fromDecoded(file.getAbsolutePath());
        return new HierarchicalUri(
                "file", Part.EMPTY, path, Part.NULL, Part.NULL);
    }

先通過我們傳入的file的絕對路徑創(chuàng)建一個叫PathPart的對象,然后創(chuàng)建HierarchicalUri并返回。

static PathPart fromDecoded(String decoded) {
            return from(NOT_CACHED, decoded);
        }

static PathPart from(String encoded, String decoded) {
            if (encoded == null) {
                return NULL;
            }

            if (encoded.length() == 0) {
                return EMPTY;
            }

            return new PathPart(encoded, decoded);
        }

其實就是new了一個PathPart,encoded是NOT_CACHED,decoded是文件絕對路徑。

static class PathPart extends AbstractPart {
private PathPart(String encoded, String decoded) {
            super(encoded, decoded);
        }
}

再去父類AbstractPart 看看:

static abstract class AbstractPart {
volatile String encoded;
        volatile String decoded;

        AbstractPart(String encoded, String decoded) {
            this.encoded = encoded;
            this.decoded = decoded;
        }
}

其實就是維護(hù)了兩個變量encoded和decoded。

abstract String getEncoded();

        final String getDecoded() {
            @SuppressWarnings("StringEquality")
            boolean hasDecoded = decoded != NOT_CACHED;
            return hasDecoded ? decoded : (decoded = decode(encoded));
        }

兩個變量的get方法,encoded的抽象方法,decoded是final的不能復(fù)寫。
getDecoded方法意思是如果decoded的值不是默認(rèn)值NOT_CACHED,就需要做編碼,否則不需要。

String getEncoded() {
            @SuppressWarnings("StringEquality")
            boolean hasEncoded = encoded != NOT_CACHED;

            // Don't encode '/'.
            return hasEncoded ? encoded : (encoded = encode(decoded, "/"));
        }

這是PathPart的具體實現(xiàn)方法,其實跟父類的getDecoded是一個意思。
然后我們來看一下創(chuàng)建并返回的HierarchicalUri,很顯然他是一個Uri。

private static class HierarchicalUri extends AbstractHierarchicalUri {

        /** Used in parcelling. */
        static final int TYPE_ID = 3;

        private final String scheme; // can be null
        private final Part authority;
        private final PathPart path;
        private final Part query;
        private final Part fragment;

        private HierarchicalUri(String scheme, Part authority, PathPart path,
                Part query, Part fragment) {
            this.scheme = scheme;
            this.authority = Part.nonNull(authority);
            this.path = path == null ? PathPart.NULL : path;
            this.query = Part.nonNull(query);
            this.fragment = Part.nonNull(fragment);
        }
}

他維護(hù)了幾個變量,都是通過構(gòu)造函數(shù)傳入的。

return new HierarchicalUri(
                "file", Part.EMPTY, path, Part.NULL, Part.NULL);

我再貼一次代碼,對照著看一下。第一個參數(shù)協(xié)議傳入了file,第二個參數(shù)授權(quán)傳入Part.EMPTY,他也是AbstractPart的子類,encoded和decoded都是空字符串,第三個參數(shù)路徑就是我們剛才創(chuàng)建的PathPart,第四個參數(shù)查詢傳入的是 Part.NULL,他也是AbstractPart的子類,encoded和decoded都是null,第五個參數(shù)碎片傳入的也是Part.NULL。
然后我們來看下他的toString方法:

private volatile String uriString = NOT_CACHED;

@Override
        public String toString() {
            @SuppressWarnings("StringEquality")
            boolean cached = (uriString != NOT_CACHED);
            return cached ? uriString
                    : (uriString = makeUriString());
        }

uriString默認(rèn)是NOT_CACHED,所以會走makeUriString方法:

private String makeUriString() {
            StringBuilder builder = new StringBuilder();

            if (scheme != null) {
                builder.append(scheme).append(':');
            }

            appendSspTo(builder);

            if (!fragment.isEmpty()) {
                builder.append('#').append(fragment.getEncoded());
            }

            return builder.toString();
        }

其實就是把維護(hù)的幾個變量拼接起來。首先是協(xié)議+冒號

private void appendSspTo(StringBuilder builder) {
            String encodedAuthority = authority.getEncoded();
            if (encodedAuthority != null) {
                // Even if the authority is "", we still want to append "http://".
                builder.append("http://").append(encodedAuthority);
            }

            String encodedPath = path.getEncoded();
            if (encodedPath != null) {
                builder.append(encodedPath);
            }

            if (!query.isEmpty()) {
                builder.append('?').append(query.getEncoded());
            }
        }

authority#getEncoded

String getEncoded() {
            @SuppressWarnings("StringEquality")
            boolean hasEncoded = encoded != NOT_CACHED;
            return hasEncoded ? encoded : (encoded = encode(decoded));
        }

授權(quán)之前傳的是空字符串,所以返回空字符串。
因為空字符串不等于null,所以要拼接雙斜杠。
路徑是我們new出來的PathPart對象,他的encoded == NOT_CACHED,所以path#getEncoded返回的就是編碼過的decoded。然后我們來看下編碼過程。

public static String encode(String s, String allow) {
        if (s == null) {
            return null;
        }

        // Lazily-initialized buffers.
        StringBuilder encoded = null;

        int oldLength = s.length();

        // This loop alternates between copying over allowed characters and
        // encoding in chunks. This results in fewer method calls and
        // allocations than encoding one character at a time.
        int current = 0;
        while (current < oldLength) {
            // Start in "copying" mode where we copy over allowed chars.

            // Find the next character which needs to be encoded.
            int nextToEncode = current;
            while (nextToEncode < oldLength
                    && isAllowed(s.charAt(nextToEncode), allow)) {
                nextToEncode++;
            }

            // If there's nothing more to encode...
            if (nextToEncode == oldLength) {
                if (current == 0) {
                    // We didn't need to encode anything!
                    return s;
                } else {
                    // Presumably, we've already done some encoding.
                    encoded.append(s, current, oldLength);
                    return encoded.toString();
                }
            }

            if (encoded == null) {
                encoded = new StringBuilder();
            }

            if (nextToEncode > current) {
                // Append allowed characters leading up to this point.
                encoded.append(s, current, nextToEncode);
            } else {
                // assert nextToEncode == current
            }

            // Switch to "encoding" mode.

            // Find the next allowed character.
            current = nextToEncode;
            int nextAllowed = current + 1;
            while (nextAllowed < oldLength
                    && !isAllowed(s.charAt(nextAllowed), allow)) {
                nextAllowed++;
            }

            // Convert the substring to bytes and encode the bytes as
            // '%'-escaped octets.
            String toEncode = s.substring(current, nextAllowed);
            try {
                byte[] bytes = toEncode.getBytes(DEFAULT_ENCODING);
                int bytesLength = bytes.length;
                for (int i = 0; i < bytesLength; i++) {
                    encoded.append('%');
                    encoded.append(HEX_DIGITS[(bytes[i] & 0xf0) >> 4]);
                    encoded.append(HEX_DIGITS[bytes[i] & 0xf]);
                }
            } catch (UnsupportedEncodingException e) {
                throw new AssertionError(e);
            }

            current = nextAllowed;
        }

        // Encoded could still be null at this point if s is empty.
        return encoded == null ? s : encoded.toString();
    }

s就是文件的絕對路徑,current從0開始循環(huán)到s的長度-1,里面還有個while循環(huán),nextToEncode從0開始,直接s的長度-1,相當(dāng)于遍歷s的每一個字符,如果都沒有問題,循環(huán)正常結(jié)束,nextToEncode的會等于s的長度,因為current等于0,直接返回了s。結(jié)論就是path#getEncoded返回的是文件的絕對路徑。
最后兩個query和fragment我們都傳的是PART.NULL,所以不需要拼接。
那么最終拼出來的就是協(xié)議+冒號+雙斜杠+文件的絕對路徑。
比如我們在外部存儲上創(chuàng)建了一個文件叫110.jpg,那么uri就是file:///storage/emulated/0/110.jpg

7.0以前 Uri#parse
public static Uri parse(String uriString) {
        return new StringUri(uriString);
    }

創(chuàng)建了一個StringUri并返回,他也是個uri的子類。

private static class StringUri extends AbstractHierarchicalUri {
private StringUri(String uriString) {
            if (uriString == null) {
                throw new NullPointerException("uriString");
            }

            this.uriString = uriString;
        }
}

public String toString() {
            return uriString;
        }

toString返回的就是構(gòu)造函數(shù)傳入的值。如果還是在外部存儲的文件110.jpg,那么結(jié)果就是/storage/emulated/0/110.jpg,那么是否意味著沒有協(xié)議了呢?是的,我們來看下:

public String getScheme() {
            @SuppressWarnings("StringEquality")
            boolean cached = (scheme != NOT_CACHED);
            return cached ? scheme : (scheme = parseScheme());
        }

scheme默認(rèn)為NOT_CACHED,會走parseScheme方法

private String parseScheme() {
            int ssi = findSchemeSeparator();
            return ssi == NOT_FOUND ? null : uriString.substring(0, ssi);
        }

private int findSchemeSeparator() {
            return cachedSsi == NOT_CALCULATED
                    ? cachedSsi = uriString.indexOf(':')
                    : cachedSsi;
        }

cachedSsi默認(rèn)等于NOT_CALCULATED,uriString并不包含冒號,所以ssi就等于-1,而NOT_FOUND是-1,所以協(xié)議為null。

7.0后 fileProvider
public static Uri getUriForFile(@NonNull Context context, @NonNull String authority,
            @NonNull File file) {
        final PathStrategy strategy = getPathStrategy(context, authority);
        return strategy.getUriForFile(file);
    }

我們通過fileProvider的這個靜態(tài)方法獲取uri。authority是我們自己定義的,一般就是包名+"FileProvider"。先來看getPathStrategy方法。

private static PathStrategy getPathStrategy(Context context, String authority) {
        PathStrategy strat;
        synchronized (sCache) {
            strat = sCache.get(authority);
            if (strat == null) {
                try {
                    strat = parsePathStrategy(context, authority);
                } catch (IOException e) {
                    throw new IllegalArgumentException(
                            "Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e);
                } catch (XmlPullParserException e) {
                    throw new IllegalArgumentException(
                            "Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e);
                }
                sCache.put(authority, strat);
            }
        }
        return strat;
    }

sCache是一個靜態(tài)變量hashmap,以authority為key保存PathStrategy,如果能取到就取出來用,不能取出就去創(chuàng)建并保存。PathStrategy是通過parsePathStrategy創(chuàng)建的。

private static PathStrategy parsePathStrategy(Context context, String authority)
            throws IOException, XmlPullParserException {
        final SimplePathStrategy strat = new SimplePathStrategy(authority);

        final ProviderInfo info = context.getPackageManager()
                .resolveContentProvider(authority, PackageManager.GET_META_DATA);
        final XmlResourceParser in = info.loadXmlMetaData(
                context.getPackageManager(), META_DATA_FILE_PROVIDER_PATHS);
        if (in == null) {
            throw new IllegalArgumentException(
                    "Missing " + META_DATA_FILE_PROVIDER_PATHS + " meta-data");
        }

        int type;
        while ((type = in.next()) != END_DOCUMENT) {
            if (type == START_TAG) {
                final String tag = in.getName();

                final String name = in.getAttributeValue(null, ATTR_NAME);
                String path = in.getAttributeValue(null, ATTR_PATH);

                File target = null;
                if (TAG_ROOT_PATH.equals(tag)) {
                    target = DEVICE_ROOT;
                } else if (TAG_FILES_PATH.equals(tag)) {
                    target = context.getFilesDir();
                } else if (TAG_CACHE_PATH.equals(tag)) {
                    target = context.getCacheDir();
                } else if (TAG_EXTERNAL.equals(tag)) {
                    target = Environment.getExternalStorageDirectory();
                } else if (TAG_EXTERNAL_FILES.equals(tag)) {
                    File[] externalFilesDirs = ContextCompat.getExternalFilesDirs(context, null);
                    if (externalFilesDirs.length > 0) {
                        target = externalFilesDirs[0];
                    }
                } else if (TAG_EXTERNAL_CACHE.equals(tag)) {
                    File[] externalCacheDirs = ContextCompat.getExternalCacheDirs(context);
                    if (externalCacheDirs.length > 0) {
                        target = externalCacheDirs[0];
                    }
                } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
                        && TAG_EXTERNAL_MEDIA.equals(tag)) {
                    File[] externalMediaDirs = context.getExternalMediaDirs();
                    if (externalMediaDirs.length > 0) {
                        target = externalMediaDirs[0];
                    }
                }

                if (target != null) {
                    strat.addRoot(name, buildPath(target, path));
                }
            }
        }

        return strat;
    }

這里實際上是解析我們配置路徑的xml文件:

<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <paths>
        <!-- common -->
        <files-path
            name="files"
            path="."/>
        <root-path
            name="root"
            path="."/>
        <external-path
            name="external"
            path="."/>
    </paths>
</paths>

遍歷每一個標(biāo)簽,拿標(biāo)簽名跟一些靜態(tài)常量做對比。

private static final String TAG_ROOT_PATH = "root-path";
    private static final String TAG_FILES_PATH = "files-path";
    private static final String TAG_CACHE_PATH = "cache-path";
    private static final String TAG_EXTERNAL = "external-path";
    private static final String TAG_EXTERNAL_FILES = "external-files-path";
    private static final String TAG_EXTERNAL_CACHE = "external-cache-path";
    private static final String TAG_EXTERNAL_MEDIA = "external-media-path";

是不是跟我們xml配置的標(biāo)簽對上了。比如標(biāo)簽名是external-path,就會創(chuàng)建一個路徑為外部存儲根目錄的文件,標(biāo)簽名是files-path,就會創(chuàng)建context#getFilesDir的文件,然后把這些跟我們xml配置的標(biāo)簽匹配上而創(chuàng)建的文件都存入SimplePathStrategy并返回SimplePathStrategy對象。
保存的key是我們在xml配置時的name對應(yīng)的值,比如external,value是經(jīng)過處理的文件。怎么處理的呢?

private static File buildPath(File base, String... segments) {
        File cur = base;
        for (String segment : segments) {
            if (segment != null) {
                cur = new File(cur, segment);
            }
        }
        return cur;
    }

void addRoot(String name, File root, HashMap<String, File> mRoots) {
        if (TextUtils.isEmpty(name)) {
            throw new IllegalArgumentException("Name must not be empty");
        }

        try {
            // Resolve to canonical path to keep path checking fast
            root = root.getCanonicalFile();
        } catch (IOException e) {
            throw new IllegalArgumentException(
                    "Failed to resolve canonical path for " + root, e);
        }

        mRoots.put(name, root);
    }

其實很簡單,就是以之前創(chuàng)建的文件為根目錄,再以我們xml中配置的path的值為目錄名創(chuàng)建一個新目錄。以external為例,path的值是".",那么最終文件的路徑就是/storage/emulated/0/.
然后調(diào)用addRoot方法,注意,這里會把文件的路徑轉(zhuǎn)成標(biāo)準(zhǔn)路徑,比如我上面的路徑轉(zhuǎn)成標(biāo)準(zhǔn)路徑,最后的點就沒有了,最后把文件存入hashmap。
ok,我們再回到最初的代碼

public static Uri getUriForFile(@NonNull Context context, @NonNull String authority,
            @NonNull File file) {
        final PathStrategy strategy = getPathStrategy(context, authority);
        return strategy.getUriForFile(file);
    }

strategy已經(jīng)創(chuàng)建完成,他是SimplePathStrategy,也是PathStrategy的實現(xiàn)類,調(diào)用了他的getUriForFile方法

@Override
        public Uri getUriForFile(File file) {
            String path;
            try {
                path = file.getCanonicalPath();
            } catch (IOException e) {
                throw new IllegalArgumentException("Failed to resolve canonical path for " + file);
            }

            // Find the most-specific root path
            Map.Entry<String, File> mostSpecific = null;
            for (Map.Entry<String, File> root : mRoots.entrySet()) {
                final String rootPath = root.getValue().getPath();
                if (path.startsWith(rootPath) && (mostSpecific == null
                        || rootPath.length() > mostSpecific.getValue().getPath().length())) {
                    mostSpecific = root;
                }
            }

            if (mostSpecific == null) {
                throw new IllegalArgumentException(
                        "Failed to find configured root that contains " + path);
            }

            // Start at first char of path under root
            final String rootPath = mostSpecific.getValue().getPath();
            if (rootPath.endsWith("/")) {
                path = path.substring(rootPath.length());
            } else {
                path = path.substring(rootPath.length() + 1);
            }

            // Encode the tag and path separately
            path = Uri.encode(mostSpecific.getKey()) + '/' + Uri.encode(path, "/");
            return new Uri.Builder().scheme("content")
                    .authority(mAuthority).encodedPath(path).build();
        }

path是我們最初自己文件的路徑,我們的最終目的也就是要把這個文件的路徑轉(zhuǎn)為uri。然后循環(huán)剛才的hashmap,如果我們自己文件的路徑是以hashmap中的某個文件的路徑開始的,那么就拿這個文件的路徑作為根目錄,而我們這里的例子就是/storage/emulated/0,path是/storage/emulated/0/110.jpg。

if (rootPath.endsWith("/")) {
                path = path.substring(rootPath.length());
            } else {
                path = path.substring(rootPath.length() + 1);
            }

這里實際上就把path變成了110.jpg。Uri#encode我們之前已經(jīng)分析過,只要傳入的參數(shù)是合法的,那么就是返回參數(shù)本身。所以path又變成了external/110.jpg。最后通過builder方法構(gòu)建了一個uri。我之前說過,builder構(gòu)建法直接看build方法即可

public Uri build() {
            if (opaquePart != null) {
                if (this.scheme == null) {
                    throw new UnsupportedOperationException(
                            "An opaque URI must have a scheme.");
                }

                return new OpaqueUri(scheme, opaquePart, fragment);
            } else {
                // Hierarchical URIs should not return null for getPath().
                PathPart path = this.path;
                if (path == null || path == PathPart.NULL) {
                    path = PathPart.EMPTY;
                } else {
                    // If we have a scheme and/or authority, the path must
                    // be absolute. Prepend it with a '/' if necessary.
                    if (hasSchemeOrAuthority()) {
                        path = PathPart.makeAbsolute(path);
                    }
                }

                return new HierarchicalUri(
                        scheme, authority, path, query, fragment);
            }
        }

就是new了一個HierarchicalUri,這個類之前已經(jīng)分析過了,那么最終的uri字符串就是content://包名$.FileProvider/external/110.jpg

源碼分析完了,我們發(fā)現(xiàn)其實fileProvider創(chuàng)建的uri跟Uri#fromFile創(chuàng)建的uri對象是一樣的,都是HierarchicalUri,只不過拼接規(guī)則不一樣,比如協(xié)議一個是file,一個是content;比如外部路徑一個是/storage/emulated/0,一個是/external,僅此而已。那7.0開始為什么要這么做呢?我們用一個7.0以上的手機(jī)用Uri#fromFile返回的uri來調(diào)用系統(tǒng)相機(jī),看看錯誤日志里的方法棧是怎么樣的。

at android.os.StrictMode.onFileUriExposed(StrictMode.java:1975)
        at android.net.Uri.checkFileUriExposed(Uri.java:2363)
        at android.content.ClipData.prepareToLeaveProcess(ClipData.java:941)
        at android.content.Intent.prepareToLeaveProcess(Intent.java:9952)
        at android.content.Intent.prepareToLeaveProcess(Intent.java:9937)
        at android.app.Instrumentation.execStartActivity(Instrumentation.java:1622)
        at android.app.Activity.startActivityForResult(Activity.java:4762)
        at android.support.v4.app.FragmentActivity.startActivityForResult(FragmentActivity.java:767)
        at android.app.Activity.startActivityForResult(Activity.java:4702)
        at android.support.v4.app.FragmentActivity.startActivityForResult(FragmentActivity.java:754)

我們來看Instrumentation#execStartActivity

public ActivityResult execStartActivity(
            Context who, IBinder contextThread, IBinder token, Activity target,
            Intent intent, int requestCode, Bundle options) {
try {
            intent.migrateExtraStreamToClipData();
            intent.prepareToLeaveProcess(who);
            int result = ActivityManager.getService()
                .startActivity(whoThread, who.getBasePackageName(), intent,
                        intent.resolveTypeIfNeeded(who.getContentResolver()),
                        token, target != null ? target.mEmbeddedID : null,
                        requestCode, 0, null, options);
            checkStartActivityResult(result, intent);
        } catch (RemoteException e) {
            throw new RuntimeException("Failure from system", e);
        }
        return null;
}

是Intent.prepareToLeaveProcess這行報錯了,為什么叫準(zhǔn)備離開進(jìn)程呢?因為下一行就是會通過binder去調(diào)用ams的startActivity,ams我們之前分析過不多說了,確實下一行代碼就離開我們自己的進(jìn)程了。

public void prepareToLeaveProcess(Context context) {
        final boolean leavingPackage = (mComponent == null)
                || !Objects.equals(mComponent.getPackageName(), context.getPackageName());
        prepareToLeaveProcess(leavingPackage);
    }

public void prepareToLeaveProcess(boolean leavingPackage) {
if (mClipData != null) {
            mClipData.prepareToLeaveProcess(leavingPackage, getFlags());
        }
}

public void prepareToLeaveProcess(boolean leavingPackage) {
        // Assume that callers are going to be granting permissions
        prepareToLeaveProcess(leavingPackage, Intent.FLAG_GRANT_READ_URI_PERMISSION);
    }

public void prepareToLeaveProcess(boolean leavingPackage, int intentFlags) {
        final int size = mItems.size();
        for (int i = 0; i < size; i++) {
            final Item item = mItems.get(i);
            if (item.mIntent != null) {
                item.mIntent.prepareToLeaveProcess(leavingPackage);
            }
            if (item.mUri != null && leavingPackage) {
                if (StrictMode.vmFileUriExposureEnabled()) {
                    item.mUri.checkFileUriExposed("ClipData.Item.getUri()");
                }
                if (StrictMode.vmContentUriWithoutPermissionEnabled()) {
                    item.mUri.checkContentUriWithoutPermission("ClipData.Item.getUri()",
                            intentFlags);
                }
            }
        }
    }

通過mComponent#getPackageName獲取的包名是目標(biāo)包名,也就是系統(tǒng)相機(jī)的包名,很顯然跟context獲取的我們自己的包名,不同,所以leavingPackage為true。因為是跨包的,所以最后會調(diào)用Uri#checkFileUriExposed

public void checkFileUriExposed(String location) {
        if ("file".equals(getScheme())
                && (getPath() != null) && !getPath().startsWith("/system/")) {
            StrictMode.onFileUriExposed(this, location);
        }
    }

public static void onFileUriExposed(Uri uri, String location) {
        final String message = uri + " exposed beyond app through " + location;
        if ((sVmPolicy.mask & PENALTY_DEATH_ON_FILE_URI_EXPOSURE) != 0) {
            throw new FileUriExposedException(message);
        } else {
            onVmPolicyViolation(new FileUriExposedViolation(message));
        }
    }

原來如此,只要這個uri的協(xié)議是file,會直接拋異?!,F(xiàn)在總算是明白了,只要啟動的activity所在進(jìn)程的包名,和目標(biāo)activity所在進(jìn)程的包名不同相同,就會去做檢測uri的協(xié)議是不是file開頭的。這里注意,如果一個應(yīng)用開啟了多進(jìn)程,那么雖然是跨進(jìn)程了但是并不算跨包。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容