App体积缩减(R文件删除)-自定义transform

说到App的体积缩减,大家首先能想到的是一些比较常规的方式:

  1. 图片压缩
  2. 配置 ndk的abiFilter
  3. 配置resConfigs
  4. 代码混淆
  5. 资源混淆
  6. xxxx

那么今天,我将和大家一起探索一种新型的体积缩减方案:R文件删除
R文件的一些特征:
R文件也是会像resource、assets这些资源一样进行合并的,比如说是A模块依赖B模块,那么A在编译期间生成的R文件中就包含了B模块中生成的R文件中的值,所以在app模块的R文件中,它涵盖了项目中所有的R文件的值,因此我们可以将library或者aar中的R文件进行删除,删除这些R文件还有一个比较好的效果就是它大大减少了应用中的字段数量

拿上述的图片压缩来减少包大小体积来说,应该怎么实现呢?最简单粗暴的方式就是手动将项目中用到的图片全部手动压一遍,再不就是利用Android studio自带的转webp功能将图片转成webp格式的,这样也没啥问题,但是有些缺点,那就是我引入的一些aar中的图片,应该如何做到删除呢?
解决方案之一就是自定义gradle 插件在编译期间进行图片压缩

今天不拿图片压缩来举例子,图片压缩这个例子应该是属于自定义task,今天的主角是自定义transform,虽然它也属于一个task,但是代码层面稍稍有些不一样

在这里我就不介绍transform是什么东西了,不熟悉的同学科学上网搜搜也能知道个大概啦

常规的支持增量的自定义transform如下所示:

package com.kunio.plugin;

import com.android.build.api.transform.DirectoryInput;
import com.android.build.api.transform.Format;
import com.android.build.api.transform.JarInput;
import com.android.build.api.transform.QualifiedContent;
import com.android.build.api.transform.Status;
import com.android.build.api.transform.Transform;
import com.android.build.api.transform.TransformInput;
import com.android.build.api.transform.TransformInvocation;
import com.android.build.api.transform.TransformOutputProvider;
import com.android.build.gradle.internal.pipeline.TransformManager;
import com.android.ide.common.internal.WaitableExecutor;
import com.android.utils.FileUtils;
import com.edu.assets.merge.pre.LottieClassVisitor;

import org.apache.commons.io.IOUtils;
import org.gradle.api.Project;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collection;
import java.util.Enumeration;
import java.util.Map;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.zip.ZipEntry;

public class KunioTransform extends Transform {
    private WaitableExecutor executor = WaitableExecutor.useGlobalSharedThreadPool();
    private static final String NAME = "AssetsLottie";
    private final Project project;

    public KunioTransform(Project project) {
        this.project = project;
    }

    /**
     * @return 返回transform 的名称
     * <p>
     * 最后会有transformClassesWithXxxForDebug、transformClassesWithXxxForRelease等task
     */
    @Override
    public String getName() {
        return NAME;
    }

    /**
     * @return 返回需要处理的输入类型,这里我们处理class文件
     */
    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }

    /**
     * @return 返回处理的作用域范围,我们这里处理整个项目中的class文件,包括aar中的
     */
    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }

    /**
     * @return 是否支持增量,需要在transform时二次判断来决策当前是否是增量的
     */
    @Override
    public boolean isIncremental() {
        return true;
    }

    @Override
    public void transform(TransformInvocation transformInvocation) throws InterruptedException, IOException {
        boolean isIncremental = transformInvocation.isIncremental();
        TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();
        if (!isIncremental) {
            outputProvider.deleteAll();
        }
        Collection<TransformInput> inputs = transformInvocation.getInputs();
        for (TransformInput input : inputs) {
            Collection<JarInput> jarInputs = input.getJarInputs();
            for (JarInput jarInput : jarInputs) {
                executor.execute(() -> {
                    processJarInputIncremental(jarInput, outputProvider, isIncremental);
                    return null;
                });
            }

            Collection<DirectoryInput> directoryInputs = input.getDirectoryInputs();
            for (DirectoryInput directoryInput : directoryInputs) {
                executor.execute(() -> {
                    processDirectoryInputIncremental(directoryInput, outputProvider, isIncremental);
                    return null;
                });
            }
        }
        executor.waitForTasksWithQuickFail(true);
    }

    private void processDirectoryInputIncremental(DirectoryInput directoryInput, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException {
        File inputDir = directoryInput.getFile();
        File outputDir = outputProvider.getContentLocation(
                directoryInput.getName(),
                directoryInput.getContentTypes(),
                directoryInput.getScopes(),
                Format.DIRECTORY);
        if (isIncremental) {
            Set<Map.Entry<File, Status>> entries = directoryInput.getChangedFiles().entrySet();
            for (Map.Entry<File, Status> entry : entries) {
                File file = entry.getKey();
                File destFile = new File(file.getAbsolutePath().replace(inputDir.getAbsolutePath(), outputDir.getAbsolutePath()));
                Status status = entry.getValue();
                switch (status) {
                    case ADDED:
                        FileUtils.mkdirs(destFile);
                        FileUtils.copyFile(file, destFile);
                        break;
                    case CHANGED:
                        FileUtils.deleteIfExists(destFile);
                        FileUtils.mkdirs(destFile);
                        FileUtils.copyFile(file, destFile);
                        break;
                    case REMOVED:
                        FileUtils.deleteIfExists(destFile);
                        break;
                    case NOTCHANGED:
                        break;
                }
            }
        } else {
            FileUtils.copyDirectory(directoryInput.getFile(), outputDir);
        }
    }

    private void processJarInputIncremental(JarInput jarInput, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException {
        File dest = outputProvider.getContentLocation(
                jarInput.getFile().getAbsolutePath(),
                jarInput.getContentTypes(),
                jarInput.getScopes(),
                Format.JAR);
        if (isIncremental) {
            //处理增量编译
            switch (jarInput.getStatus()) {
                case NOTCHANGED:
                    break;
                case ADDED:
                    processJarInput(jarInput, dest);
                    break;
                case CHANGED:
                    //处理有变化的
                    FileUtils.deleteIfExists(dest);
                    processJarInput(jarInput, dest);
                    break;
                case REMOVED:
                    //移除Removed
                    FileUtils.deleteIfExists(dest);
                    break;
            }
        } else {
            //不处理增量编译
            processJarInput(jarInput, dest);
        }
    }

    private void processJarInput(JarInput jarInput, File dest) throws IOException {
        String name = jarInput.getName();
//        com.airbnb.android:lottie:3.6.1
//        androidx.cardview:cardview:1.0.0
//        androidx.coordinatorlayout:coordinatorlayout:1.1.0
//        androidx.fragment:fragment:1.1.0
//        androidx.constraintlayout:constraintlayout:2.0.4
//        androidx.appcompat:appcompat:1.2.0
//        do something
        realProcessJarInput(jarInput);
        FileUtils.copyFile(jarInput.getFile(), dest);
    }

    private void realProcessJarInput(JarInput jarInput) throws IOException {
        File file = jarInput.getFile();
        File tempJar = new File(file.getParentFile(), file.getName() + ".temp");
        JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tempJar));
        JarFile jar = new JarFile(file);
        Enumeration<JarEntry> entries = jar.entries();
        boolean changed = false;
        while (entries.hasMoreElements()) {
            JarEntry jarEntry = entries.nextElement();
            InputStream inputStream = jar.getInputStream(jarEntry);
            jarOutputStream.putNextEntry(new ZipEntry(jarEntry.getName()));
//            com/airbnb/lottie/L.class
//            com/airbnb/lottie/L.class
//            com/airbnb/lottie/Lottie.class
//            com/airbnb/lottie/Lottie.class
//            com/airbnb/lottie/LottieAnimationView.class
            if (true) {
                // 如果是需要处理这个class文件
                changed = true;
                byte[] bytes = insertCodeToConstructors(inputStream);
                jarOutputStream.write(bytes);
            } else {
                jarOutputStream.write(IOUtils.toByteArray(inputStream));
            }
            inputStream.close();
            jarOutputStream.closeEntry();
        }
        jar.close();
        jarOutputStream.close();
        if (changed) {
            FileUtils.delete(file);
            tempJar.renameTo(file);
        } else {
            FileUtils.delete(tempJar);
        }
//        FileUtils.delete(tempJar);
    }


    /**
     * 利用一些字节码工具来动态改变类的行为
     */
    private byte[] insertCodeToConstructors(InputStream inputStream) throws IOException {

        //1. 构建ClassReader对象
        ClassReader classReader = new ClassReader(inputStream);
        //2. 构建ClassVisitor的实现类ClassWriter
        ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS);
        KunioClassVisitor visitor = new KunioClassVisitor(Opcodes.ASM6, classWriter);
        classReader.accept(visitor, ClassReader.EXPAND_FRAMES);
        //4. 通过classWriter对象的toByteArray方法拿到完整的字节流
        return classWriter.toByteArray();
    }
}


public class KunioClassVisitor extends ClassVisitor {
    public KunioClassVisitor(int api) {
        super(api);
    }

    public KunioClassVisitor(int api, ClassVisitor classVisitor) {
        super(api, classVisitor);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
        if ("init".equals(name) && "(Landroid/util/AttributeSet;I)V".equals(descriptor)) {
//            System.out.println("access = " + access);
//            System.out.println("name = " + name);
//            System.out.println("descriptor = " + descriptor);
//            System.out.println("signature = " + signature);
            return new initMethodVisitor(api, methodVisitor,access,name,descriptor);
        } else {
            return methodVisitor;
        }
    }

    static class initMethodVisitor extends AdviceAdapter {
        initMethodVisitor(int api, MethodVisitor methodVisitor, int access, String name, String descriptor) {
            super(api, methodVisitor, access, name, descriptor);
        }

        @Override
        protected void onMethodExit(int opcode) {
            super.onMethodExit(opcode);
            mv.visitVarInsn(ALOAD, 0);
            mv.visitMethodInsn(INVOKESTATIC, "com/edu/assets/lottie/bitmap/delegate/BitmapDelegate", "setDelegate", "(Lcom/airbnb/lottie/LottieAnimationView;)V", false);
        }
    }
}

在这个例子中,我们所需要做的不是去动态更改class文件,而是删除一些文件,这更简单了:

1.

app模块的代码一般是属于DirectoryInput类型的,一般情况下该类型的我们不做处理,因为这里面的R文件是应用中所有的R引用了,保存即可

2.

对于jarInput类型的输入,我们需要作出如下的功能和判断:
· 当前不包含R文件,那么我们需要对该类中的R引用进行替换
· 包含了R文件,但是该类是配置在了白名单中,那么该类就不作变换
· 包含了R文件,但是该R文件是以app模块中的包名开头,也无需作出变换
· 可以做一个开关,在打debug包时,不做R文件的删除,以此来节省部分编译时间

差不多就是这么多,下面开始写代码了:
定义如下实体类:

 class UnifyRExtension {
     // app 模块包名
    public String appPackageName;
    // 类白名单包名,处于此包下的类不处理
    public List<String> whitePackage;
    // debug模式下跳过处理
    public boolean skipDebug = true
}

gradle plgin类:

public class KunioPlugin implements Plugin<Project> {
    private static final String CONFIG_NAME = "UnifyRExtension";

    @Override
    public void apply(@NotNull Project project) {
        boolean hasAppPlugin = project.getPlugins().hasPlugin("com.android.application");
        if (!hasAppPlugin) {
            throw new GradleException("this plugin can't use in library module");
        }
        AppExtension android = (AppExtension) project.getExtensions().findByName("android");
        if (android == null) {
            throw new NullPointerException("application module not have \"android\" block!");
        }
        project.getExtensions().create(CONFIG_NAME, UnifyRExtension.class);
        android.registerTransform(new UnifyRTransform(project));
    }
}

transform:

package com.edu.android.plugin;

import com.android.build.api.transform.DirectoryInput;
import com.android.build.api.transform.Format;
import com.android.build.api.transform.JarInput;
import com.android.build.api.transform.QualifiedContent;
import com.android.build.api.transform.Status;
import com.android.build.api.transform.Transform;
import com.android.build.api.transform.TransformException;
import com.android.build.api.transform.TransformInput;
import com.android.build.api.transform.TransformInvocation;
import com.android.build.api.transform.TransformOutputProvider;
import com.android.build.gradle.internal.pipeline.TransformManager;
import com.android.ide.common.internal.WaitableExecutor;
import com.android.utils.FileUtils;

import org.apache.commons.io.IOUtils;
import org.gradle.api.Project;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.zip.ZipEntry;

public class UnifyRTransform extends Transform {
    private static final List<String> R = Arrays.asList(
            "R$xml",
            "R$transition",
            "R$styleable",
            "R$style",
            "R$string",
            "R$raw",
            "R$plurals",
            "R$mipmap",
            "R$menu",
            "R$layout",
            "R$interpolator",
            "R$integer",
            "R$id",
            "R$fraction",
            "R$font",
            "R$drawable",
            "R$dimen",
            "R$color",
            "R$bool",
            "R$attr",
            "R$array",
            "R$animator",
            "R$anim");
    private WaitableExecutor executor = WaitableExecutor.useGlobalSharedThreadPool();
    private static final String NAME = "UnifyR";
    private final Project project;
    private String appPackagePrefix;
    private List<String> whitePackages;

    public UnifyRTransform(Project project) {
        this.project = project;
    }

    @Override
    public String getName() {
        return NAME;
    }

    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }

    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }

    @Override
    public boolean isIncremental() {
        return true;
    }

    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        UnifyRExtension unifyRExtension = project.getExtensions().findByType(UnifyRExtension.class);
        boolean skipDebugUnifyR = transformInvocation.getContext().getVariantName().toLowerCase().contains("debug") && unifyRExtension.skipDebug;
        if (skipDebugUnifyR) {
            copyOnly(transformInvocation.getInputs(), transformInvocation.getOutputProvider());
            return;
        }
        appPackagePrefix = unifyRExtension.packageName.replace('.', '/') + '/';
        List<String> whitePackage = unifyRExtension.whitePackage;
        List<String> whites = new ArrayList<>();
        if (whitePackage != null) {
            for (String s : whitePackage) {
                whites.add(s.replace('.', '/'));
            }
        }
        whitePackages = new ArrayList<>(whites);

        boolean isIncremental = transformInvocation.isIncremental();
        TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();
        if (!isIncremental) {
            outputProvider.deleteAll();
        }
        Collection<TransformInput> inputs = transformInvocation.getInputs();
        for (TransformInput input : inputs) {
            Collection<JarInput> jarInputs = input.getJarInputs();
            for (JarInput jarInput : jarInputs) {
                executor.execute(() -> {
                    processJarInputWithIncremental(jarInput, outputProvider, isIncremental);
                    return null;
                });
            }

            Collection<DirectoryInput> directoryInputs = input.getDirectoryInputs();
            for (DirectoryInput directoryInput : directoryInputs) {
                executor.execute(() -> {
                    processDirectoryInputWithIncremental(directoryInput, outputProvider, isIncremental);
                    return null;
                });
            }
        }
        executor.waitForTasksWithQuickFail(true);
    }

    private void processDirectoryInputWithIncremental(DirectoryInput directoryInput, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException {
        File inputDir = directoryInput.getFile();
        File outputDir = outputProvider.getContentLocation(
                directoryInput.getName(),
                directoryInput.getContentTypes(),
                directoryInput.getScopes(),
                Format.DIRECTORY);
        if (isIncremental) {
            Set<Map.Entry<File, Status>> entries = directoryInput.getChangedFiles().entrySet();
            for (Map.Entry<File, Status> entry : entries) {
                File file = entry.getKey();
                File destFile = new File(file.getAbsolutePath().replace(inputDir.getAbsolutePath(), outputDir.getAbsolutePath()));
                Status status = entry.getValue();
                switch (status) {
                    case ADDED:
                        FileUtils.mkdirs(destFile);
                        FileUtils.copyFile(file, destFile);
                        break;
                    case CHANGED:
                        FileUtils.deleteIfExists(destFile);
                        FileUtils.mkdirs(destFile);
                        FileUtils.copyFile(file, destFile);
                        break;
                    case REMOVED:
                        FileUtils.deleteIfExists(destFile);
                        break;
                    case NOTCHANGED:
                        break;
                }
            }
        } else {
            FileUtils.copyDirectory(directoryInput.getFile(), outputDir);
        }
    }

    private void processJarInputWithIncremental(JarInput jarInput, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException {
        File dest = outputProvider.getContentLocation(
                jarInput.getFile().getAbsolutePath(),
                jarInput.getContentTypes(),
                jarInput.getScopes(),
                Format.JAR);
        if (isIncremental) {
            //处理增量编译
            switch (jarInput.getStatus()) {
                case NOTCHANGED:
                    break;
                case ADDED:
                    processJarInput(jarInput, dest);
                    break;
                case CHANGED:
                    //处理有变化的
                    FileUtils.deleteIfExists(dest);
                    processJarInput(jarInput, dest);
                    break;
                case REMOVED:
                    //移除Removed
                    if (dest.exists()) {
                        FileUtils.delete(dest);
                    }
                    break;
            }
        } else {
            //不处理增量编译
            processJarInput(jarInput, dest);
        }
    }

    private void processJarInput(JarInput jarInput, File dest) throws IOException {
        processClass(jarInput);
        FileUtils.copyFile(jarInput.getFile(), dest);
    }

    private void processClass(JarInput jarInput) throws IOException {
        File file = jarInput.getFile();
        File tempJar = new File(file.getParentFile(), file.getName() + ".temp");
        JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tempJar));
        JarFile jar = new JarFile(file);
        Enumeration<JarEntry> entries = jar.entries();
        while (entries.hasMoreElements()) {
            byte[] destBytes = null;
            JarEntry jarEntry = entries.nextElement();
            InputStream inputStream = jar.getInputStream(jarEntry);
            String name = jarEntry.getName();
            if (name.endsWith(".class")) {
                boolean keep = false;
                for (String s : whitePackages) {
                    if (name.contains(s)) {
                        keep = true;
                        break;
                    }
                }
                if (keep) {
                    destBytes = IOUtils.toByteArray(inputStream);
                } else {
                    if (!hasR(name)) {
                        destBytes = unifyR(name, inputStream);
                    } else if (name.startsWith(appPackagePrefix)) {
                        destBytes = IOUtils.toByteArray(inputStream);
                    }
                }
            } else {
                destBytes = IOUtils.toByteArray(inputStream);
            }
            if (destBytes != null) {
                jarOutputStream.putNextEntry(new ZipEntry(jarEntry.getName()));
                jarOutputStream.write(destBytes);
                jarOutputStream.closeEntry();
            }
            inputStream.close();
        }
        jar.close();
        jarOutputStream.close();
        FileUtils.delete(file);
        tempJar.renameTo(file);
    }

    private byte[] unifyR(String entryName, InputStream inputStream) throws IOException {
        ClassReader cr = new ClassReader(inputStream);
        ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);
        ClassVisitor cv = new ClassVisitor(Opcodes.ASM6, cw) {

            @Override
            public MethodVisitor visitMethod(int access, String name, String desc,
                                             String signature, String[] exceptions) {
                MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
                return new MethodVisitor(Opcodes.ASM6, mv) {
                    @Override
                    public void visitFieldInsn(int opcode, String owner, String fName, String fDesc) {
                        if (hasR(owner) && !owner.contains(appPackagePrefix)) {
                            super.visitFieldInsn(opcode, appPackagePrefix + "R$" + owner.substring(owner.indexOf("R$") + 2), fName, fDesc);
                        } else {
                            super.visitFieldInsn(opcode, owner, fName, fDesc);
                        }
                    }
                };
            }

        };
        cr.accept(cv, ClassReader.EXPAND_FRAMES);
        return cw.toByteArray();
    }

    private static void copyOnly(Collection<TransformInput> inputs, TransformOutputProvider outputProvider) throws IOException {
        for (TransformInput input : inputs) {
            Collection<JarInput> jarInputs = input.getJarInputs();
            for (JarInput jarInput : jarInputs) {
                File dest = outputProvider.getContentLocation(
                        jarInput.getName(),
                        jarInput.getContentTypes(),
                        jarInput.getScopes(),
                        Format.JAR);
                FileUtils.copyFile(jarInput.getFile(), dest);
            }

            Collection<DirectoryInput> directoryInputs = input.getDirectoryInputs();
            for (DirectoryInput directoryInput : directoryInputs) {
                File dest = outputProvider.getContentLocation(
                        directoryInput.getName(),
                        directoryInput.getContentTypes(),
                        directoryInput.getScopes(),
                        Format.DIRECTORY);
                FileUtils.copyDirectory(directoryInput.getFile(), dest);
            }
        }
    }

    /**
     * 判断这个字符串里面有没有R文件的标识
     *
     * @param check 待检测的字符串
     * @return 有标识的话返回true
     */
    private static boolean hasR(String check) {
        for (String s : R) {
            if (check.contains(s)) {
                return true;
            }
        }
        return false;
    }
}

经过上述Transform + ASM处理,可以对aar及library中打包生成的R文件删除,达到减少字段及包大小的目的。

最后

1. whitePackage需要将androidx.constraintlayout作为白名单填入
2. packageName需要填写成为app模块的包名即可
2. 上述示例只是给出了transform与plugin代码,如何发布以及应用plugin需要自己查询相关资料
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343