分享

Unity AssetBundle Usage Pattern

 tiancaiwrk 2018-05-14

This is the fifth chapter in a series of articles covering Assets, Resources and resource management in Unity 5.

The previous chapter in this series covered the fundamentals of AssetBundles, which included the low-level behavior of various loading APIs. This chapter discusses problems and potential solutions to various aspects of using AssetBundles in practice.

Managing loaded Assets

It is critical to carefully control the size and number of loaded Objects in memory-sensitive environments. Unity does not automatically unload Objects when they are removed from the active scene. Asset cleanup is triggered at specific times, and it can also be triggered manually.

AssetBundles themselves must be carefully managed. An AssetBundle backed by a file on local storage (either in the Unity cache or one loaded via AssetBundle.LoadFromFile) has minimal memory overhead, rarely consuming more than a few dozen kilobytes. However, this overhead can still become problematic if a large number of AssetBundles are present.

As most projects allow users to re-experience content (such as replaying a level), it's important to know when to load or unload an AssetBundle. If an AssetBundle is unloaded improperly, it can cause Object duplication in memory. Improperly unloading AssetBundles can also result in undesirable behavior in certain circumstances, such as causing textures to go missing. To understand why this can happen, refer to the Inter-Object references section of the Assets, Objects, and serialization chapter.

The most important thing to understand when managing assets and AssetBundles is the difference in behavior when calling AssetBundle.Unload with either true or false for the unloadAllLoadedObjects parameter.

This API will unload the header information of the AssetBundle being called. The unloadAllLoadedObjects parameter determines whether to also unload all Objects instantiated from this AssetBundle. If set to true, then all Objects originating from the AssetBundle will also be immediately unloaded – even if they are currently being used in the active scene.

For example, assume a material M was loaded from an AssetBundle AB, and assume M is currently in the active scene.

description

If AssetBundle.Unload(true) is called, then M will be removed from the scene, destroyed and unloaded. However, if AssetBundle.Unload(false) is called, then AB's header information will be unloaded but M will remain in the scene and will still be functional. Calling AssetBundle.Unload(false) breaks the link between M and AB. If AB is loaded again later, then fresh copies of the Objects contained in AB will be loaded into memory.

description

If AB is loaded again later, then a new copy of the AssetBundle's header information will be reloaded. However, M was not loaded from this new copy of AB. Unity does not establish any link between the new copy of AB and M.

description

If AssetBundle.LoadAsset() were called to reload M, Unity would not interpret the old copy of M as being an instance of the data in AB. Therefore, Unity will load a new copy of M and there will be two identical copies of M in the scene.

description

For most projects, this behavior is undesirable. Most projects should use AssetBundle.Unload(true) and adopt a method to ensure that Objects are not duplicated. Two common methods are:

  1. Having well-defined points during the application's lifetime at which transient AssetBundles are unloaded, such as between levels or during a loading screen. This is the simpler and most common option.

  2. Maintaining reference-counts for individual Objects and unload AssetBundles only when all of their constituent Objects are unused. This permits an application to unload and reload individual Objects without duplicating memory.

If an application must use AssetBundle.Unload(false), then individual Objects can only be unloaded in two ways:

  1. Eliminate all references to an unwanted Object, both in the scene and in code. After this is done, call Resources.UnloadUnusedAssets.

  2. Load a scene non-additively. This will destroy all Objects in the current scene and invoke Resources.UnloadUnusedAssets automatically.

If a project has well-defined points where the user can be made to wait for Objects to load and unload, such as in between game modes or levels, these points should be used to unload as many Objects as necessary and to load new Objects.

The simplest way to do this is to package discrete chunks of a project into scenes, and then build those scenes into AssetBundles, along with all of their dependencies. The application can then enter a "loading" scene, fully unload the AssetBundle containing the old scene, and then load the AssetBundle containing the new scene.

While this is the simplest flow, some projects require more complex AssetBundle management. As every project is different, there is no universal AssetBundle design pattern.

When deciding how to group Objects into AssetBundles, it is generally best to start by bundling Objects into AssetBundles if they must be loaded or updated at the same time. For example, consider a role-playing game. Individual maps and cutscenes can be grouped into AssetBundles by scene, but some Objects will be needed in most scenes. AssetBundles could be built to provide portraits, the in-game UI, and different character models and textures. These latter Objects and Assets could then be grouped into a second set of AssetBundles that are loaded at startup and remain loaded for the lifetime of the app.

Another problem can arise if Unity must reload an Object from its AssetBundle after the AssetBundle has been unloaded. In this case, the reload will fail and the Object will appear in the Unity Editor's hierarchy as a (Missing) Object.

This primarily occurs when Unity loses and regains control over its graphics context, such as when a mobile app is suspended or the user locks their PC. In this case, Unity must re-upload textures and shaders to the GPU. If the source AssetBundle for these assets is unavailable, the application will render Objects in the scene as magenta.

4.2. Distribution

There are two basic ways to distribute a project's AssetBundles to clients: installing them simultaneously with the project or downloading them after installation.

The decision whether to ship AssetBundles within or after installation is driven by the capabilities and restrictions of the platforms on which the project will run. Mobile projects usually opt for post-install downloads to reduce initial install size and remain below over-the-air download size limits. Console and PC projects generally ship AssetBundles with their initial install.

Proper architecture permits patching new or revised content into your project post-install regardless of how the AssetBundles are delivered initially. For more information on this, see the Patching with AssetBundles section of the Unity Manual.

4.2.1. Shipped with project

Shipping AssetBundles with the project is the simplest way to distribute them as it does not require additional download-management code. There are two major reasons why a project might include AssetBundles with the install:

  • To reduce project build times and permit simpler iterative development. If these AssetBundles do not need to be updated separately from the application itself, then the AssetBundles can be included with the application by storing the AssetBundles in Streaming Assets. See the Streaming Assets section, below.

  • To ship an initial revision of updatable content. This is commonly done to save end-users time after their initial install or to serve as the basis for later patching. Streaming Assets is not ideal for this case. However, if writing a custom downloading and caching system is not an option, then an initial revision of updatable content can be loaded into the Unity cache from Streaming Assets.

4.2.1.1. Streaming Assets

The easiest way to include any type of content, including AssetBundles, within a Unity application at install time is to build the content into the /Assets/StreamingAssets/ folder, prior to building the project. Anything contained in the StreamingAssets folder at build time will be copied into the final application.

The full path to the StreamingAssets folder on local storage is accessible via the property Application.streamingAssetsPath at runtime. The AssetBundles can then be loaded with via AssetBundle.LoadFromFile on most platforms.

Android Developers: On Android, assets in the StreamingAssets folders are stored into the APK and may take more time to load if they are compressed, as files stored in an APK can use different storage algorithms. The algorithm used may vary from one Unity version to another. You can use an archiver such as 7-zip to open the APK to determine if the files are compressed or not. If they are, you can expect AssetBundle.LoadFromFile() to perform more slowly. In this case, you can retrieve the cached version by using UnityWebRequest.GetAssetBundle as a workaround. By using UnityWebRequest, the AssetBundle will be uncompressed and cached during the first run, allowing for following executions to be faster. Note that this will will take more storage space, as the AssetBundle will be copied to the cache. Alternatively, you can export your Gradle project and add an extension to your AssetBundles at build time. You can then edit the build.gradle file and add that extension to the noCompress section. Once done, you should be able to use AssetBundle.LoadFromFile() without having to pay the decompression performance cost.

Note: Streaming Assets is not a writable location on some platforms. If a project's AssetBundles need to be updated after installation, either use WWW.LoadFromCacheOrDownload or write a custom downloader.

4.2.2. Downloaded post-install

The favored method of delivering AssetBundles to mobile devices is to download them after app installation. This also allows the content to be updated after installation without forcing users to re-download the entire application. On many platforms, application binaries must undergo an expensive and lengthy re-certification process. Therefore, developing a good system for post-install downloads is vital.

The simplest way to deliver AssetBundles is to place them on a web server and deliver them via UnityWebRequest. Unity will automatically cache downloaded AssetBundles on local storage. If the downloaded AssetBundle is LZMA compressed, the AssetBundle will be stored in the cache either as uncompressed or re-compressed as LZ4 (dependent on the Caching.compressionEnabled setting), for faster loading in the future. If the downloaded bundle is LZ4 compressed, the AssetBundle will be stored compressed. If the cache fills up, Unity will remove the least recently used AssetBundle from the cache. See the Built-in caching section for more details.

It is generally recommended to start by using UnityWebRequest when possible, or WWW.LoadFromCacheOrDownload only if using Unity 5.2 or older. Only invest in a custom download system if the built-in APIs' memory consumption, caching behavior or performance are unacceptable for a specific project, or if a project must run platform-specific code to achieve its requirements.

Examples of situations which may prevent the use of UnityWebRequest or WWW.LoadFromCacheOrDownload:

  • When fine-grained control over the AssetBundle cache is required

  • When a project needs to implement a custom compression strategy

  • When a project wishes to use platform-specific APIs to satisfy certain requirements, such as the need to stream data while inactive.

    • Example: Using iOS' Background Tasks API to download data while in the background.
  • When AssetBundles must be delivered over SSL on platforms where Unity does not have proper SSL support (such as PC).

4.2.3. Built-in caching

Unity has a built-in AssetBundle caching system that can be used to cache AssetBundles downloaded via the UnityWebRequest API, which has an overload accepting an AssetBundle version number as an argument. This number is not stored inside the AssetBundle, and is not generated by the AssetBundle system.

The caching system keeps track of the last version number passed to UnityWebRequest. When this API is called with a version number, the caching system checks to see if there is a cached AssetBundle by comparing version numbers. If these numbers match, the system will load the cached AssetBundle. If the numbers do not match, or there is no cached AssetBundle, then Unity will download a new copy. This new copy will be associated with the new version number.

AssetBundles in the caching system are identified only by their file names, and not by the full URL from which they are downloaded. This means that an AssetBundle with the same file name can be stored in multiple different locations, such as a Content Delivery Network. As long as the file names are identical, the caching system will recognize them as the same AssetBundle.

It is up to each individual application to determine an appropriate strategy for assigning version numbers to AssetBundles, and to pass these numbers to UnityWebRequest. The numbers may come from a unique identifiers of sorts, such as a CRC value. Note that while AssetBundleManifest.GetAssetBundleHash() may also be used for this purpose, we don’t recommend this function for versioning, as it provides just an estimation, and not a true hash calculation).

See the Patching with AssetBundles section of the Unity Manual for more details.

In Unity 2017.1 onward, the Caching API has been extended to provide more granular control, by allow developers to select an active cache from multiple caches. Prior versions of Unity may only modify Caching.expirationDelay and Caching.maximumAvailableDiskSpace to remove cached items (these properties remain in Unity 2017.1 in the Cache class).

expirationDelay is the minimum number of seconds that must elapse before an AssetBundle is automatically deleted. If an AssetBundle is not accessed during this time, it will be deleted automatically.

maximumAvailableDiskSpace specifies the amount of space on local storage, in bytes, that the cache may use before it begins deleting AssetBundles that have been used less recently than the expirationDelay. When the limit is reached, Unity will delete the AssetBundle in the cache which was least recently opened (or marked as used via Caching.MarkAsUsed). Unity will delete cached AssetBundles until there is sufficient space to complete the new download.

4.2.3.1. Cache Priming

Because AssetBundles are identified by their file names, it is possible to "prime" the cache with AssetBundles shipped with the application. To do this, store the initial or base version of each AssetBundle in /Assets/StreamingAssets/. The process is identical to the one detailed in the Shipped with project section.

The cache can be populated by loading AssetBundles from Application.streamingAssetsPath the first time the application is run. From then on, the application can call UnityWebRequest normally (UnityWebRequest can also be used to initially load AssetBundles from the StreamingAssets path as well).

4.2.3. Custom downloaders

Writing a custom downloader gives an application full control over how AssetBundles are downloaded, decompressed and stored. As the engineering work involved is non-trivial, we recommend this approach only for larger teams. There are four major considerations when writing a custom downloader:

  • Download mechanism

  • Storage location

  • Compression type

  • Patching

For information on patching AssetBundles, see the Patching with AssetBundles section.

4.2.3.1. Downloading

For most applications, HTTP is the simplest method to download AssetBundles. However, implementing an HTTP-based downloader is not the simplest task. Custom downloaders must avoid excessive memory allocations, excessive thread usage and excessive thread wakeups. Unity's WWW class is unsuitable for reasons exhaustively described here.

When writing a custom downloader, there are three options:

  • C#'s HttpWebRequest and WebClient classes

  • Custom native plugins

  • Asset store packages

4.2.3.1.1. C# classes

If an application does not require HTTPS/SSL support, C#'s WebClient class provides the simplest possible mechanism for downloading AssetBundles. It is capable of asynchronously downloading any file directly to local storage without excessive managed memory allocation.

To download an AssetBundle with WebClient, allocate an instance of the class and pass it the URL of the AssetBundle to download and a destination path. If more control is required over the request's parameters, it is possible to write a downloader using C#'s HttpWebRequest class:

  1. Get a byte stream from HttpWebResponse.GetResponseStream.

  2. Allocate a fixed-size byte buffer on the stack.

  3. Read from the response stream into the buffer.

  4. Write the buffer to disk using C#'s File.IO APIs, or any other streaming IO system.

4.2.3.1.2. Asset Store Packages

Several asset store packages offer native-code implementations to download files via HTTP, HTTPS and other protocols. Before writing a custom native-code plugin for Unity, it is recommended to evaluate available Asset Store packages.

4.2.3.1.3. Custom Native Plugins

Writing a custom native plugin is the most time-intensive, but most flexible method for downloading data in Unity. Due to the high programming time requirements and high technical risk, this method is only recommended if no other method is capable of satisfying an application's requirements. For example, a custom native plugin may be necessary if an application must use SSL communication on platforms without C# SSL support in Unity.

A custom native plugin will generally wrap a target platform's native downloading APIs. Examples include NSURLConnection on iOS and .HttpURLConnection on Android. Consult each platform's native documentation for further details on using these APIs.

4.2.3.2. Storage

On all platforms, Application.persistentDataPath points to a writable location that should be used for storing data that should persist between runs of an application. When writing a custom downloader, it is strongly recommended to use a subdirectory of Application.persistentDataPath to store downloaded data.

Application.streamingAssetPath is not writable and is a poor choice for an AssetBundle cache. Example locations for streamingAssetsPath include:

  • OSX: Within .app package; not writable.

  • Windows: Within install directory (e.g. Program Files); usually not writable

  • iOS: Within .ipa package; not writable

  • Android: Within .apk file; not writable

4.3. Asset Assignment Strategies

Deciding how to divide a project's assets into AssetBundles is not simple. It is tempting to adopt a simplistic strategy, such as placing all Objects in their own AssetBundle or using only a single AssetBundle, but these solutions have significant drawbacks:

  • Having too few AssetBundles...

    • Increases runtime memory usage

    • Increases loading times

    • Requires larger downloads

  • Having too many AssetBundles...

    • Increases build times

    • Can complicate development

    • Increases total download time

The key decision is how to group Objects into AssetBundles. The primary strategies are:

  • Logical entities

  • Object Types

  • Concurrent content

More information about these grouping strategies can be found in the Manual.

4.4. Common pitfalls

This section describes several problems that commonly appear in projects using AssetBundles.

4.5.1. Asset duplication

Unity 5's AssetBundle system will discover all dependencies of an Object when the Object is built into an AssetBundle. This dependency information is used to determine the set of Objects that will be included in an AssetBundle.

Objects that are explicitly assigned to an AssetBundle will only be built into that AssetBundle. An Object is "explicitly assigned" when that Object's AssetImporter has its assetBundleName property set to a non-empty string. This can be done in the Unity Editor by selecting an AssetBundle in the Object's Inspector, or from Editor scripts.

Objects can also be assigned to an AssetBundle by defining them as part of an AssetBundle building map, which is to be used in conjunction with the overloaded BuildPipeline.BuildAssetBundles() function that takes in an array of AssetBundleBuild.

Any Object that is not explicitly assigned in an AssetBundle will be included in all AssetBundles that contain 1 or more Objects that reference the untagged Object.

For example, if two different Objects are assigned to two different AssetBundles, but both have references to a common dependency Object, then that dependency Object will be copied into both AssetBundles. The duplicated dependency will also be instanced, meaning that the two copies of the dependency Object will be considered different Objects with a different identifiers. This will increase the total size of the application's AssetBundles. This will also cause two different copies of the Object to be loaded into memory if the application loads both of its parents.

There are several ways to address this problem:

  1. Ensure that Objects built into different AssetBundles do not share dependencies. Any Objects which do share dependencies can be placed into the same AssetBundle without duplicating their dependencies.

    • This method usually is not viable for projects with many shared dependencies. It produces monolithic AssetBundles that must be rebuilt and re-downloaded too frequently to be convenient or efficient.
  2. Segment AssetBundles so that no two AssetBundles that share a dependency will be loaded at the same time.

    • This method may work for certain types of projects, such as level-based games. However, it still unnecessarily increases the size of the project's AssetBundles, and increases both build times and loading times.
  3. Ensure that all dependency assets are built into their own AssetBundles. This entirely eliminates the risk of duplicated assets, but also introduces complexity. The application must track dependencies between AssetBundles, and ensure that the right AssetBundles are loaded before calling any AssetBundle.LoadAsset APIs.

Object dependencies are tracked via the AssetDatabase API, located in the UnityEditor namespace. As the namespace implies, this API is only available in the Unity Editor and not at runtime. AssetDatabase.GetDependencies can be used to locate all of the immediate dependencies of a specific Object or Asset. Note that these dependencies may have their own dependencies. Additionally, the AssetImporter API can be used to query the AssetBundle to which any specific Object is assigned.

By combining the AssetDatabase and AssetImporter APIs, it is possible to write an Editor script that ensures that all of an AssetBundle's direct or indirect dependencies are assigned to AssetBundles, or that no two AssetBundles share dependencies that have not been assigned to an AssetBundle. Due to the memory cost of duplicating assets, it is recommended that all projects have such a script.

4.5.2. Sprite atlas duplication

Any automatically-generated sprite atlas will be assigned to the AssetBundle containing the Sprite Objects from which the sprite atlas was generated. If the sprite Objects are assigned to multiple AssetBundles, then the sprite atlas will not be assigned to an AssetBundle and will be duplicated. If the Sprite Objects are not assigned to an AssetBundle, then the sprite atlas will also not be assigned to an AssetBundle.

To ensure that sprite atlases are not duplicated, check that all sprites tagged into the same sprite atlas are assigned to the same AssetBundle.

Note that in Unity 5.2.2p3 and older, automatically-generated sprite atlases will never be assigned to an AssetBundle. Because of this, they will be included in any AssetBundles containing their constituent sprites and also any AssetBundles referencing their constituent sprites. Because of this problem, it is strongly recommended that all Unity 5 projects using Unity's sprite packer upgrade to Unity 5.2.2p4, 5.3 or any newer version of Unity.

4.5.3. Android textures

Due to heavy device fragmentation in the Android ecosystem, it is often necessary to compress textures into several different formats. While all Android devices support ETC1, ETC1 does not support textures with alpha channels. Should an application not require OpenGL ES 2 support, the cleanest way to solve the problem is to use ETC2, which is supported by all Android OpenGL ES 3 devices.

Most applications need to ship on older devices where ETC2 support is unavailable. One way to solve this problem is with Unity 5's AssetBundle Variants (refer to Unity's Android optimization guide for details on other options).

To use AssetBundle Variants, all textures that cannot be cleanly compressed using ETC1 must be isolated into texture-only AssetBundles. Next, create sufficient variants of these AssetBundles to support the non-ETC2-capable slices of the Android ecosystem, using vendor-specific texture compression formats such as DXT5, PVRTC and ATITC. For each AssetBundle Variant, change the included textures' TextureImporter settings to the compression format appropriate to the Variant.

At runtime, support for the different texture compression formats can be detected using the SystemInfo.SupportsTextureFormat API. This information should be used to select and load the AssetBundle Variant containing textures compressed in a supported format.

More information on Android texture compression formats can be found here.

4.5.4. iOS file handle overuse

Current versions of Unity are not affected by this issue.

In versions prior to Unity 5.3.2p2, Unity would hold an open file handle to an AssetBundle the entire time that the AssetBundle is loaded. This is not a problem on most platforms. However, iOS limits the number of file handles a process may simultaneously have open to 255. If loading an AssetBundle causes this limit to be exceeded, the loading call will fail with a "Too Many Open File Handles" error.

This was a common problem for projects trying to divide their content across many hundreds or thousands of AssetBundles.

For projects unable to upgrade to a patched version of Unity, temporary solutions are:

  • Reducing the number of AssetBundles in use by merging related AssetBundles

  • Using AssetBundle.Unload(false) to close an AssetBundle's file handle, and managing the loaded Objects' lifecycles manually

4.5. AssetBundle Variants

A key feature of the AssetBundle system is the introduction of AssetBundle Variants. The purpose of Variants is to allow an application to adjust its content to better suit its runtime environment. Variants permit different UnityEngine.Objects in different AssetBundle files to appear as being the "same" Object when loading Objects and resolving Instance ID references. Conceptually, it permits two UnityEngine.Objects to appear to share the same File GUID & Local ID, and identifies the actual UnityEngine.Object to load by a string Variant ID.

There are two primary use cases for this system:

  1. Variants simplify the loading of AssetBundles appropriate for a given platform.

    • Example: A build system might create an AssetBundle containing high-resolution textures and complex shaders suitable for a standalone DirectX11 Windows build, and a second AssetBundle with lower-fidelity content intended for Android. At runtime, the project's resource loading code can then load the appropriate AssetBundle Variant for its platform, and the Object names passed into the AssetBundle.Load API do not need to change.
  2. Variants allow an application to load different content on the same platform, but with different hardware.

    • This is key for supporting a wide range of mobile devices. An iPhone 4 is incapable of displaying the same fidelity of content as the latest iPhone in any real-world application.

    • On Android, AssetBundle Variants can be used to tackle the immense fragmentation of screen aspect ratios and DPIs between devices.

4.5.1. Limitations

A key limitation of the AssetBundle Variant system is that it requires Variants to be built from distinct Assets. This limitation applies even if the only variations between those Assets is their import settings. If the only distinction between a texture built into Variant A and Variant B is the specific texture compression algorithm selected in the Unity texture importer, Variant A and Variant B must still be entirely different Assets. This means that Variant A and Variant B must be separate files on disk.

This limitation complicates the management of large projects as multiple copies of a specific Asset must be kept in source control. All copies of an Asset must be updated when developers wish to change the content of the Asset. There are no built-in workarounds for this problem.

Most teams implement their own form of AssetBundle Variants. This is done by building AssetBundles with well-defined suffixes appended to their filenames, in order to identify the specific variant a given AssetBundle represents. Custom code programmatically alters the importer settings of the included Assets when building these AssetBundles. Some developers have extended their custom systems to also be able to alter parameters on components attached to prefabs.

4.6. Compressed or uncompressed?

Whether to compress AssetBundles requires several important considerations, which include:

  • Loading time: Uncompressed AssetBundles are much faster to load than compressed AssetBundles when loading from local storage or a local cache.

  • Build time: LZMA and LZ4 are very slow when compressing files, and the Unity Editor processes AssetBundles serially. Projects with a large number of AssetBundles will spend a lot of time compressing them.

  • Application size: If the AssetBundles are shipped in the application, compressing them will reduce the application's total size. Alternatively, the AssetBundles can be downloaded post-install.

  • Memory usage: Prior to Unity 5.3, all of Unity's decompression mechanisms required the entire compressed AssetBundle to be loaded into memory prior to decompression. If memory usage is important, use either uncompressed or LZ4 compressed AssetBundles.

  • Download time: Compression may only be necessary if the AssetBundles are large, or if users are in a bandwidth-constrained environment, such as downloading on low-speed or metered connections. If only a few tens of megabytes of data are being delivered to PCs on high-speed connections, it may be possible to omit compression.

4.6.1. Crunch Compression

Bundles which consist primarily of DXT-compressed textures which use the Crunch compression algorithm should be built uncompressed.

4.7. AssetBundles and WebGL

Unity strongly recommends that developers not use compressed AssetBundles on WebGL projects.

All AssetBundle decompression and loading in a WebGL project must occur on the main thread. This is because Unity's WebGL export option does not currently support worker threads. The downloading of AssetBundles is delegated to the browser using XMLHttpRequest, which executes on Unity's main thread. This means that compressed AssetBundles are extremely expensive to load on WebGL.

If you are using Unity 5.5 or older, consider avoiding LZMA for your AssetBundles and compress using LZ4 instead, which is decompressed very efficiently on-demand. If you need smaller compression sizes then LZ4 delivers, you can configure your web server to gzip-compress the files on the HTTP protocol level (on top of LZ4 compression). Unity 5.6 removes LZMA as an compression option for the WebGL platform.

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多