`

JNI Examples for Android

 
阅读更多

原文地址:http://android.wooyd.org/JNIExample/#NWD1sCYeT-C


Important notice

The instructions in these document are applicable to older Androidfirmwares. Starting with firmware version 1.5 the Android NDK hasbeen released, which makes it much easier to deal with native code.If you are coding for firmware 1.5 or newer, it is strongly recommendedto use Android NDK instead of following this guide.

JNI Examples for Android

Jurij Smakov
jurij@wooyd.org

Table of Contents

  • Licence
  • Introduction
  • Java interface
  • Native library implementation
    • Headers and global variables
    • Calling Java functions from Java and native threads
    • Implementation of other native functions
    • The JNI_OnLoad() function implementation
  • Building the native library
  • Using native functions in Java code
  • Unresolved issues and bugs

[*]Licence

This document and the code generated from it are subject to the following licence:
Copyright (C) 2009 Jurij Smakov <jurij@wooyd.org>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

[*]Introduction

While JNI is a pretty exciting way to greatly extend Android functionalityand port existing software to it, to date there is not a lot of detaileddocumentation on how to create the native libraries and interface with them from the Android's Java Virtual Machine (JVM). This document aims at filling thisgap, by providing a comprehensive example of creating a native JNI library, andusing it from Java.

This document has been generated from source usingnoweb, a literate programming tool. TheJNIExample.nw is thesource in noweb format. It can be used to generate the document output in a varietyof formats (for example,PDF),as well as generate the JNI example source code.

The complete Android project, including the source code generated fromJNIExample.nw isavailable fordownload. So,if you are impatient, just grab it and check out the "Building the native library"section[->], which describes prerequisites for the build and the buildprocedure itself.

This document is not a replacement for other general JNI documentation. If you arenot familiar with JNI, you may want to have a look at the following resources:

Also, there are a couple of blog entries, which contain some bits of useful information:

If you notice any errors or omissions (there are a couple of known bugs and unresolvedissues[->]), or have a suggestion on how to improve this document, feel freeto contact me using the email address mentioned above.

[*]Java interface

We start by defining a Java class JNIExampleInterface, which will provide theinterface to calling the native functions, defined in a native (C++) library.The native functions corresponding to Java functions will need to have matchingcall signatures (i.e. the count and types of the arguments, as well as returntype). The easiest way to get the correct function signatures in the nativelibrary is to first write down their Java prototypes, and then use thejavahtool to generate the native JNI header with native function prototypes. Thesecan be cut and pasted into the C++ file for implementation.

The Java functions which are backed by the corresponding native functions aredeclared in a usual way, adding anative qualifier. We also want todemonstrate how we could do the callbacks, i.e. calling the Java code fromnative code. That leads to the following high-level view of our interface class:

<JNIExampleInterface.java>=
package org.wooyd.android.JNIExample;

import android.os.Handler;
import android.os.Bundle;
import android.os.Message;
import org.wooyd.android.JNIExample.Data;

public class JNIExampleInterface {
    static Handler h;
    <Example constructors>
    <Example native functions>
    <Example callback>
}

One valid question about this definition is why we need a Handler class attribute. It turns out that it will come in handy in situations, when thenative library wants to pass some information to the Java process through acallback. If the callback will be called by a native thread (for extendeddiscussion see "Calling Java functions" section[->]), and thenwill try to modify theapplication's user interface (UI) in any way, an exception will be thrown, asAndroid only allows the thread which created the UI (the UI thread) to modifyit. To overcome this problem we are going to use the message-passing interfaceprovided byHandler to dispatch the data received by a callback to the UIthread, and allow it to do the UI modifications. In order for this to work,we are going to accept aHandler instance as an argument for non-trivialconstructor (reasons for keeping trivial one will become apparent later), andsave it in a class attribute, and that's pretty much the only task for theconstructor:

<Example constructors>= (<-U)
    public JNIExampleInterface() {}
    public JNIExampleInterface(Handler h) {
        this.h = h;
    }

To illustrate various argument-passing techniques, we define three nativefunctions:

  • callVoid(): takes no arguments and returns nothing;
  • getNewData(): takes two arguments and constructs a new classinstance using them;
  • getDataString(): extracts a value from an object, which ispassed as an argument.

<Example native functions>= (<-U)
    public static native void callVoid();
    public static native Data getNewData(int i, String s);
    public static native String getDataString(Data d);

The callback will receive a string as an argument, and dispatch it to theHandler instance recorded in the constructor, after wrapping it inaBundle:

<Example callback>= (<-U)
    public static void callBack(String s) {
        Bundle b = new Bundle();
        b.putString("callback_string", s);
        Message m = Message.obtain();
        m.setData(b);
        m.setTarget(h);
        m.sendToTarget();
    }

We also need a definition of a dummy Data class, used purely forillustrative purposes:

<Data.java>=
package org.wooyd.android.JNIExample;

public class Data {
    public int i;
    public String s;
    public Data() {}
    public Data(int i, String s) {
        this.i = i;
        this.s = s;
    }
}

After the source files Data.java and JNIExampleInterface.java are compiled,we can generate the JNI header file, containing the prototypesof the native functions, corresponding to their Java counterparts:

$ javac -classpath /path/to/sdk/android.jar \
        org/wooyd/android/JNIExample/*.java
$ javah -classpath . org.wooyd.android.JNIExample.JNIExampleInterface

Native library implementation

At a high level, the Java library (consisting, in this case, of a singlesource fileJNIExample.cpp) will look like that:

<JNIExample.cpp>=
<JNI includes>
<Miscellaneous includes>
<Global variables>
#ifdef __cplusplus
extern "C" {
#endif
<callVoid implementation>
<getNewData implementation>
<getDataString implementation>
<initClassHelper implementation>
<JNIOnLoad implementation>
#ifdef __cplusplus
}
#endif

Headers and global variables

The following includes define the functions provided by Android's versionof JNI, as well as some useful helpers:

<JNI includes>= (<-U)
#include <jni.h>
#include <JNIHelp.h>
#include <android_runtime/AndroidRuntime.h>

Various other things which will come in handy:

<Miscellaneous includes>= (<-U)
#include <string.h>
#include <unistd.h>
#include <pthread.h>

It is useful to have some global variables to cache things which we know willnot change during the lifetime of our program, and can be safely used acrossmultiple threads. One of such things is the JVM handle. We can retrieve itevery time it's needed (for example, using android::AndroidRuntime::getJavaVM()function), but as it does not change, it's better to cache it.

We can also use global variables to cache the referencesto required classes. As described below, it is not always easy to do classresolution in native code, especially when it is done from native threads (see"Calling Java functions" section [->] for details).Here we are just providing the global variables to hold instances ofDataand JNIExampleInterface class objects, as well as defining some constantstrings which will come in handy:

<Global variables>= (<-U)
static JavaVM *gJavaVM;
static jobject gInterfaceObject, gDataObject;
const char *kInterfacePath = "org/wooyd/android/JNIExample/JNIExampleInterface";
const char *kDataPath = "org/wooyd/android/JNIExample/Data";
Defines gJavaVM,jobject,kDataPath,kInterfacePath (links are to index).

[*]Calling Java functions from Java and native threads

The callVoid() function is the simplest one, as it does not take anyarguments, and returns nothing. We will use it to illustrate how the datacan be passed back to Java through the callback mechanism, by callingthe JavacallBack() function.

At this point it is important to recognize that there are two distinctpossibilities here: the Java function may be called either from a thread whichoriginated in Java or from a native thread, which has been started in the nativecode, and of which JVM has no knowledge of. In the former case the call may beperformed directly, in the latter we must first attach the native thread to theJVM. That requires an additional layer, a native callback handler, which willdo the right thing in either case. We will also need a function to create thenative thread, so structurally the implementation will look like this:

<callVoid implementation>= (<-U)
<Callback handler>
<Thread start function>
<callVoid function>

Native callback handler gets the JNI environment (attaching the nativethread if necessary), uses a cached reference to thegInterfaceObject to get to JNIExampleInterface class,obtainscallBack() method reference, and calls it:

<Callback handler>= (<-U)
static void callback_handler(char *s) {
    int status;
    JNIEnv *env;
    bool isAttached = false;
   
    status = gJavaVM->GetEnv((void **) &env, JNI_VERSION_1_4);
    if(status < 0) {
        LOGE("callback_handler: failed to get JNI environment, "
             "assuming native thread");
        status = gJavaVM->AttachCurrentThread(&env, NULL);
        if(status < 0) {
            LOGE("callback_handler: failed to attach "
                 "current thread");
            return;
        }
        isAttached = true;
    }
    /* Construct a Java string */
    jstring js = env->NewStringUTF(s);
    jclass interfaceClass = env->GetObjectClass(gInterfaceObject);
    if(!interfaceClass) {
        LOGE("callback_handler: failed to get class reference");
        if(isAttached) gJavaVM->DetachCurrentThread();
        return;
    }
    /* Find the callBack method ID */
    jmethodID method = env->GetStaticMethodID(
        interfaceClass, "callBack", "(Ljava/lang/String;)V");
    if(!method) {
        LOGE("callback_handler: failed to get method ID");
        if(isAttached) gJavaVM->DetachCurrentThread();
        return;
    }
    env->CallStaticVoidMethod(interfaceClass, method, js);
    if(isAttached) gJavaVM->DetachCurrentThread();
}
Defines callback_handler (links are to index).

A few comments are in order:

  • The JNI environment, returned by the JNI GetEnv() function is uniquefor each thread, so must be retrieved every time we enter the function. TheJavaVM pointer, on the other hand, is per-program, so can be cached (youwill see it done in the JNI_OnLoad() function), and safely used acrossthreads.

  • When we attach a native thread, the associated Java environment comeswith a bootstrap class loader. That means that even if we would try to get aclass reference in the function (the normal way to do it would be to useFindClass() JNI function), it would trigger an exception. Because of thatwe use a cached copy of JNIExampleInterface object to get a classreference (amusingly, we cannot cache the reference to the class itself, asany attempt to use it triggers an exception from JVM, who thinks that suchreference should not be visible to native code). This caching is also doneinJNI_OnLoad(), which might be the only function called by Android Javaimplementation with a functional class loader.

  • In order to retrieve the method ID of the callBack() method, we needto specify its name and JNI signature. In this case the signature indicates that the function takes ajava.lang.String object as an argument,and returns nothing (i.e. has return typevoid). Consult JNI documentationfor more information on function signatures, one useful tip is that you can usejavap utility to look up the function signatures of non-native functions(for native functions the signature information is already included as commentsinto the header, generated by javah).

  • Someone more paranoid than me could use locking to avoid race conditionsassociated with setting and checking of theisAttached variable.

In order to test calling from native threads, we will also need a function which is started in a separate thread. Its only role is to call the callbackhandler:

<Thread start function>= (<-U)
void *native_thread_start(void *arg) {
    sleep(1);
    callback_handler((char *) "Called from native thread");
}
Defines native_thread_start (links are to index).

We now have all necessary pieces to implement the native counterpart of thecallVoid() function:

<callVoid function>= (<-U)
/*
 * Class:     org_wooyd_android_JNIExample_JNIExampleInterface
 * Method:    callVoid
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_org_wooyd_android_JNIExample_JNIExampleInterface_callVoid
  (JNIEnv *env, jclass cls) {
    pthread_t native_thread;

    callback_handler((char *) "Called from Java thread");
    if(pthread_create(&native_thread, NULL, native_thread_start, NULL)) {
        LOGE("callVoid: failed to create a native thread");
    }
}
Defines JNICALL (links are to index).

Implementation of other native functions

The getNewData() function illustrates creation of a new Java object inthe native library, which is then returned to the caller. Again, we use acachedData object reference in order to obtain the class and createa new instance.
<getNewData implementation>= (<-U)
/*
 * Class:     org_wooyd_android_JNIExample_JNIExampleInterface
 * Method:    getNewData
 * Signature: (ILjava/lang/String;)Lorg/wooyd/android/JNIExample/Data;
 */
JNIEXPORT jobject JNICALL Java_org_wooyd_android_JNIExample_JNIExampleInterface_getNewData
  (JNIEnv *env, jclass cls, jint i, jstring s) {
    jclass dataClass = env->GetObjectClass(gDataObject);
    if(!dataClass) {
        LOGE("getNewData: failed to get class reference");
        return NULL;
    }
    jmethodID dataConstructor = env->GetMethodID(
        dataClass, "<init>", "(ILjava/lang/String;)V");
    if(!dataConstructor) {
        LOGE("getNewData: failed to get method ID");
        return NULL;
    }
    jobject dataObject = env->NewObject(dataClass, dataConstructor, i, s);
    if(!dataObject) {
        LOGE("getNewData: failed to create an object");
        return NULL;
    }
    return dataObject;
}
Defines jobject (links are to index).

The getDataString() function illustrates how a value stored in an object'sattribute can be retrieved in a native function.

<getDataString implementation>= (<-U)
/*
 * Class:     org_wooyd_android_JNIExample_JNIExampleInterface
 * Method:    getDataString
 * Signature: (Lorg/wooyd/android/JNIExample/Data;)Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_org_wooyd_android_JNIExample_JNIExampleInterface_getDataString
  (JNIEnv *env, jclass cls, jobject dataObject) {
    jclass dataClass = env->GetObjectClass(gDataObject);
    if(!dataClass) {
        LOGE("getDataString: failed to get class reference");
        return NULL;
    }
    jfieldID dataStringField = env->GetFieldID(
        dataClass, "s", "Ljava/lang/String;");
    if(!dataStringField) {
        LOGE("getDataString: failed to get field ID");
        return NULL;
    }
    jstring dataStringValue = (jstring) env->GetObjectField(
        dataObject, dataStringField);
    return dataStringValue;
}
Defines jstring (links are to index).

The JNI_OnLoad() function implementation

The JNI_OnLoad() function must be provided by the native library in orderfor the JNI to work with Android JVM. It will be called immediately after thenative library is loaded into the JVM. We already mentioned a couple of taskswhich should be performed in this function: caching of the global JavaVMpointer and caching of the object instances to enable us to call into Java.In addition, any native methods which we want to call from Java must beregistered, otherwise Android JVM will not be able to resolve them. Theoverall structure of the function thus can be written down as follows:

<JNIOnLoad implementation>= (<-U)
jint JNI_OnLoad(JavaVM* vm, void* reserved)
{
    JNIEnv *env;
    gJavaVM = vm;
    LOGI("JNI_OnLoad called");
    if (vm->GetEnv((void**) &env, JNI_VERSION_1_4) != JNI_OK) {
        LOGE("Failed to get the environment using GetEnv()");
        return -1;
    }
    <Class instance caching>
    <Native function registration>
    return JNI_VERSION_1_4;
}

We need some way to cache a reference to a class, because native threads do nothave access to a functional classloader. As explained above, we can't cache theclass references themselves, as it makes JVM unhappy. Instead we cacheinstances of these classes, so that we can later retrieve class referencesusingGetObjectClass() JNI function. One thing to remember is that theseobjects must be protected from garbage-collecting usingNewGlobalRef(),as that guarantees that they will remain available to different threads duringJVM lifetime. Creating the instances and storing them in the global variablesis the job for theinitClassHelper() function:

<initClassHelper implementation>= (<-U)
void initClassHelper(JNIEnv *env, const char *path, jobject *objptr) {
    jclass cls = env->FindClass(path);
    if(!cls) {
        LOGE("initClassHelper: failed to get %s class reference", path);
        return;
    }
    jmethodID constr = env->GetMethodID(cls, "<init>", "()V");
    if(!constr) {
        LOGE("initClassHelper: failed to get %s constructor", path);
        return;
    }
    jobject obj = env->NewObject(cls, constr);
    if(!obj) {
        LOGE("initClassHelper: failed to create a %s object", path);
        return;
    }
    (*objptr) = env->NewGlobalRef(obj);

}
Defines initClassHelper (links are to index).

With this function defined, class instance caching is trivial:

<Class instance caching>= (<-U)
    initClassHelper(env, kInterfacePath, &gInterfaceObject);
    initClassHelper(env, kDataPath, &gDataObject);

In order to register the native functions, we create an arrayofJNINativeMethod structures, which containfunction names, signatures (they can be simply copied from the comments,generated byjavah), and pointers to the implementing functions. Thisarray is then passed to Android'sregisterNativeMethods() function:

<Native function registration>= (<-U)
    JNINativeMethod methods[] = {
        {
            "callVoid",
            "()V",
            (void *) Java_org_wooyd_android_JNIExample_JNIExampleInterface_callVoid
        },
        {
            "getNewData",
            "(ILjava/lang/String;)Lorg/wooyd/android/JNIExample/Data;",
            (void *) Java_org_wooyd_android_JNIExample_JNIExampleInterface_getNewData
        },
        {
            "getDataString",
            "(Lorg/wooyd/android/JNIExample/Data;)Ljava/lang/String;",
            (void *) Java_org_wooyd_android_JNIExample_JNIExampleInterface_getDataString
        }
    };
    if(android::AndroidRuntime::registerNativeMethods(
        env, kInterfacePath, methods, NELEM(methods)) != JNI_OK) {
        LOGE("Failed to register native methods");
        return -1;
    }

[*]Building the native library

In order to build the native library, you need to includeAndroid's native headers and link against native libraries.The only way I know to get those is to check out and build the entireAndroid source code, and then build it. Procedure is described in detail atAndroid Get Sourcepage. Make sure that you use the branch tag matching your SDK version,for example code in therelease-1.0 branch matches Android 1.1 SDK.

For an example of CXXFLAGS and LDFLAGS you need to use tocreate a shared library with Android toolchain, check out theMakefile, included in theexample project tarball.They are derived from build/core/combo/linux-arm.mk in Androidsource.

You will probably want to build the entire example project, so youwill need a copy of the SDK as well. This code has been tested to buildwith Android's 1.1 SDK and run on the currently released version of thephone. Once you downloaded the SDK and the example tarball andunpacked them, you can build the project using the command

ANDROID_DIR=/path/to/android/source SDK_DIR=/path/to/sdk make

Using native functions in Java code

We will now create a simple activity, taking advantage of theJNI functions. One non-trivial task we will have to do inonCreate() method of the activity is to load the nativeJNI library, to make the functions defined there accessible toJava. Overall structure:

<JNIExample.java>=
package org.wooyd.android.JNIExample;
<Imports>
public class JNIExample extends Activity
{
    TextView callVoidText, getNewDataText, getDataStringText;
    Button callVoidButton, getNewDataButton, getDataStringButton;
    Handler callbackHandler;
    JNIExampleInterface jniInterface;

    @Override
    public void onCreate(Bundle savedInstanceState) 
    {
         super.onCreate(savedInstanceState);
         setContentView(R.layout.main);
         <Load JNI library>
         <callVoid demo>
         <getNewData demo>
         <getDataString demo>
    }
}

Imports needed to draw the UI and display it to the user:

<Imports>= (<-U) [D->]
    import android.app.Activity;
    import android.view.View;
    import android.widget.Button;
    import android.widget.TextView;

Imports needed to enable communication between the Java callbackand the UI thread:

<Imports>+= (<-U) [<-D->]
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;

Imports for manipulation with the native library:

<Imports>+= (<-U) [<-D->]
    import java.util.zip.*;
    import java.io.InputStream;
    import java.io.OutputStream;
    import java.io.FileOutputStream;
    import java.io.File;

We will also need access to our JNI interface class and toyDataclass:

<Imports>+= (<-U) [<-D->]
    import org.wooyd.android.JNIExample.JNIExampleInterface;
    import org.wooyd.android.JNIExample.Data;

Logging utilities will also come in handy:

<Imports>+= (<-U) [<-D]
    import android.util.Log;

At this time the only officialy supported way to create an Android applicationis by using the Java API. That means, that no facilities are provided to easily build and package shared libraries, and automatically load themon application startup. One possible way to include the library into theapplication package (file with extension.apk) is to place it into theassets subdirectory of the Android project, created withactivitycreator.During the package build it will be automatically included into the APK package,however we still will have to load it by hand when our application starts up.Luckily, the location where APK is installed is known, and APK is simply a ZIParchive, so we can extract the library file from Java and copy it into theapplication directory, allowing us to load it:

<Load JNI library>= (<-U)
    try {
        String cls = "org.wooyd.android.JNIExample";
        String lib = "libjniexample.so";
        String apkLocation = "/data/app/" + cls + ".apk";
        String libLocation = "/data/data/" + cls + "/" + lib;
        ZipFile zip = new ZipFile(apkLocation);
        ZipEntry zipen = zip.getEntry("assets/" + lib);
        InputStream is = zip.getInputStream(zipen);
        OutputStream os = new FileOutputStream(libLocation);
        byte[] buf = new byte[8092];
        int n;
        while ((n = is.read(buf)) > 0) os.write(buf, 0, n);
        os.close();
        is.close();
        System.load(libLocation);
    } catch (Exception ex) {
         Log.e("JNIExample", "failed to install native library: " + ex);
    }

The rest simply demonstrates the functionality, provided by the nativelibrary, by calling the native functions and displaying the results.For thecallVoid() demo we need to initialize a handler first, andpass it to the JNI interface class, to enable us to receive callbackmessages:

<callVoid demo>= (<-U) [D->]
    callVoidText = (TextView) findViewById(R.id.callVoid_text);
    callbackHandler = new Handler() {
        public void handleMessage(Message msg) {
            Bundle b = msg.getData();
            callVoidText.setText(b.getString("callback_string"));
        }
    };
    jniInterface = new JNIExampleInterface(callbackHandler);

We also set up a button which will call callVoid() fromthe native library when pressed:

<callVoid demo>+= (<-U) [<-D]
    callVoidButton = (Button) findViewById(R.id.callVoid_button);
    callVoidButton.setOnClickListener(new Button.OnClickListener() {
        public void onClick(View v) {
            jniInterface.callVoid();
            
        } 
    });

For getNewData() we pass the parameters to the native functionand expect to get theData object back:

<getNewData demo>= (<-U)
    getNewDataText = (TextView) findViewById(R.id.getNewData_text);  
    getNewDataButton = (Button) findViewById(R.id.getNewData_button);
    getNewDataButton.setOnClickListener(new Button.OnClickListener() {
        public void onClick(View v) {
            Data d = jniInterface.getNewData(42, "foo");
            getNewDataText.setText(
                "getNewData(42, \"foo\") == Data(" + d.i + ", \"" + d.s + "\")");
        }
    });

And pretty much the same for getDataString():

<getDataString demo>= (<-U)
    getDataStringText = (TextView) findViewById(R.id.getDataString_text);  
    getDataStringButton = (Button) findViewById(R.id.getDataString_button);
    getDataStringButton.setOnClickListener(new Button.OnClickListener() {
        public void onClick(View v) {
            Data d = new Data(43, "bar");
            String s = jniInterface.getDataString(d);
            getDataStringText.setText(
                "getDataString(Data(43, \"bar\")) == \"" + s + "\"");
        }
    });

Try pushing the buttons and see whether it actually works!

[*]Unresolved issues and bugs

Even though the example is fully functional, there are a couple unresolved issuesremaining, which I was not able to figure out so far. Problems appear when youstart the activity, then press the Back button to hide it, and then start itagain. In my experience, calls to native functions in such restarted activitywill fail spectacularly.callVoid() simply crashes with a segmentation fault, while calls togetNewData() and getDataString() cause JVM to abortwith an error, because it is no longer happy with the globally cached objectreference. It appears that activity restart somehow invalidates our cachedobject references, even though they are protected with NewGlobalRef(), and theactivity is running within the original JVM (activity restart does not meanthat JVM itself is restarted). I don't have a good explanation on why thathappens, so if you have any ideas, please let me know.


分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics