美文网首页
一个Uri引起的思考

一个Uri引起的思考

作者: kwbsky | 来源:发表于2019-12-05 18:14 被阅读0次

今天有同事问我问题,调用系统分享,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);
            }

后来把判断去掉,统一只用Uri#parse就没问题了。
虽然他解决了问题,但是却激起了我的好奇心,然后就开始看源码,然后就有了这篇文章。
在分析之前,咱们先来科普一下7.0以前及以后通过file获取uri的方法和内部具体实现。

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的绝对路径创建一个叫PathPart的对象,然后创建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;
        }
}

其实就是维护了两个变量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的不能复写。
getDecoded方法意思是如果decoded的值不是默认值NOT_CACHED,就需要做编码,否则不需要。

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

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

这是PathPart的具体实现方法,其实跟父类的getDecoded是一个意思。
然后我们来看一下创建并返回的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);
        }
}

他维护了几个变量,都是通过构造函数传入的。

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

我再贴一次代码,对照着看一下。第一个参数协议传入了file,第二个参数授权传入Part.EMPTY,他也是AbstractPart的子类,encoded和decoded都是空字符串,第三个参数路径就是我们刚才创建的PathPart,第四个参数查询传入的是 Part.NULL,他也是AbstractPart的子类,encoded和decoded都是null,第五个参数碎片传入的也是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默认是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();
        }

其实就是把维护的几个变量拼接起来。首先是协议+冒号

private void appendSspTo(StringBuilder builder) {
            String encodedAuthority = authority.getEncoded();
            if (encodedAuthority != null) {
                // Even if the authority is "", we still want to append "//".
                builder.append("//").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));
        }

授权之前传的是空字符串,所以返回空字符串。
因为空字符串不等于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开始循环到s的长度-1,里面还有个while循环,nextToEncode从0开始,直接s的长度-1,相当于遍历s的每一个字符,如果都没有问题,循环正常结束,nextToEncode的会等于s的长度,因为current等于0,直接返回了s。结论就是path#getEncoded返回的是文件的绝对路径。
最后两个query和fragment我们都传的是PART.NULL,所以不需要拼接。
那么最终拼出来的就是协议+冒号+双斜杠+文件的绝对路径。
比如我们在外部存储上创建了一个文件叫110.jpg,那么uri就是file:///storage/emulated/0/110.jpg

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

创建了一个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返回的就是构造函数传入的值。如果还是在外部存储的文件110.jpg,那么结果就是/storage/emulated/0/110.jpg,那么是否意味着没有协议了呢?是的,我们来看下:

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

scheme默认为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默认等于NOT_CALCULATED,uriString并不包含冒号,所以ssi就等于-1,而NOT_FOUND是-1,所以协议为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的这个静态方法获取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是一个静态变量hashmap,以authority为key保存PathStrategy,如果能取到就取出来用,不能取出就去创建并保存。PathStrategy是通过parsePathStrategy创建的。

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>

遍历每一个标签,拿标签名跟一些静态常量做对比。

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配置的标签对上了。比如标签名是external-path,就会创建一个路径为外部存储根目录的文件,标签名是files-path,就会创建context#getFilesDir的文件,然后把这些跟我们xml配置的标签匹配上而创建的文件都存入SimplePathStrategy并返回SimplePathStrategy对象。
保存的key是我们在xml配置时的name对应的值,比如external,value是经过处理的文件。怎么处理的呢?

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);
    }

其实很简单,就是以之前创建的文件为根目录,再以我们xml中配置的path的值为目录名创建一个新目录。以external为例,path的值是".",那么最终文件的路径就是/storage/emulated/0/.
然后调用addRoot方法,注意,这里会把文件的路径转成标准路径,比如我上面的路径转成标准路径,最后的点就没有了,最后把文件存入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已经创建完成,他是SimplePathStrategy,也是PathStrategy的实现类,调用了他的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是我们最初自己文件的路径,我们的最终目的也就是要把这个文件的路径转为uri。然后循环刚才的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我们之前已经分析过,只要传入的参数是合法的,那么就是返回参数本身。所以path又变成了external/110.jpg。最后通过builder方法构建了一个uri。我之前说过,builder构建法直接看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,这个类之前已经分析过了,那么最终的uri字符串就是content://包名$.FileProvider/external/110.jpg

源码分析完了,我们发现其实fileProvider创建的uri跟Uri#fromFile创建的uri对象是一样的,都是HierarchicalUri,只不过拼接规则不一样,比如协议一个是file,一个是content;比如外部路径一个是/storage/emulated/0,一个是/external,仅此而已。那7.0开始为什么要这么做呢?我们用一个7.0以上的手机用Uri#fromFile返回的uri来调用系统相机,看看错误日志里的方法栈是怎么样的。

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这行报错了,为什么叫准备离开进程呢?因为下一行就是会通过binder去调用ams的startActivity,ams我们之前分析过不多说了,确实下一行代码就离开我们自己的进程了。

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获取的包名是目标包名,也就是系统相机的包名,很显然跟context获取的我们自己的包名,不同,所以leavingPackage为true。因为是跨包的,所以最后会调用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的协议是file,会直接抛异常。现在总算是明白了,只要启动的activity所在进程的包名,和目标activity所在进程的包名不同相同,就会去做检测uri的协议是不是file开头的。这里注意,如果一个应用开启了多进程,那么虽然是跨进程了但是并不算跨包。

相关文章

  • 一个Uri引起的思考

    今天有同事问我问题,调用系统分享,QQ分享总是提示图片获取不到。过来几分钟又告诉我搞定了,原来是uri的问题。他开...

  • 使用FileProvider解决file:// URI引起的Fi

    问题 以下是一段简单的代码,它调用系统的相机app来拍摄照片: 在一般情况下,运行没有任何问题;可是当把targe...

  • android本地mipmap图片转url、绝对路径转URL

    标签: url uri file pathFile to URI:File file = ...;URI uri ...

  • 一个游戏引起的思考

    一天上人本主义治疗,早上9点开课,但整个教室就我和另外一个同学2个人。老师等了一会,过了一会班主任过来说开课吧,不...

  • 一个电话引起的思考

    闺蜜打电话过来了。 “喂,怎么了?”我有些好奇地问。 “没啥事儿,就想问问你十一放假不放。” “哦,我十一得值班,...

  • 一个梦引起的思考

    昨晚,做了一个梦,醒来后发现有段情节很清晰。 我梦见自己回去参加了初中同学聚会,聚会的时候很多同学都到场。最后问问...

  • 一个故事引起的思考

    故事“母亲的礼物”,讲述了一个孩子在13岁生日那一天,母亲给了他一个特别的礼物:自由。 我们家的小儿子刚好13岁,...

  • 一个节目引起的思考

    那一年的暑假我开始看百家讲坛,那一年的暑假于丹的论语系列讲座开始热播,我成了粉丝。于丹老师讲话的声音总是带着一...

  • 一个饭局引起的思考

    最近由于某些原因,我打算中午请小组里的同事吃顿饭,日期定在假期后的第一个工作日。不知道是由于假期长的原因(总共也就...

  • 一个音响引起的思考

    最近在读《孤独六讲》,其实并没有透彻地懂,只是有一种极其赞同又非常不舒服的感觉。今早我突然有了一种不一样的不...

网友评论

      本文标题:一个Uri引起的思考

      本文链接:https://www.haomeiwen.com/subject/gjtjgctx.html